From 841503152c6a0a6885566bd8b0a21b2bde4a7aa1 Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:48:54 +0200 Subject: [PATCH 001/387] fix(runtime-environments): don't crash when a server removed via the CLI still responds (#22517) * fix(runtime-environments): don't crash when a server removed via the CLI still responds orca environment rm edits the environment store behind the running app, so the next ok response on a live socket called markEnvironmentUsed, which threw 'Unknown environment' out of an unguarded socket callback. Main-process callers now use markEnvironmentUsedIfPresent, which skips a missing environment and keeps every other store error; the status owner pauses shared control instead of re-establishing it for a removed server. * fix(runtime-environments): guard usage bookkeeping at the main-process boundary Keep one strict store contract and move the leniency to the caller that cannot report a failure to anyone. - Revert markEnvironmentUsedIfPresent: drawing the line around one error string left corrupt, unreadable and oversized store files still fatal on the same unguarded socket callback. - Add recordRuntimeEnvironmentUsage, a named main-process boundary that says lastUsedAt is advisory and swallows every store failure. Route only the three sites with no observer through it (subscription onResponse in transport- and support-routing, and the status owner's verified hook, where a throw skips settleWaiters and hangs refresh callers). Awaited request paths stay strict. - Guard onResponse/onBinary in the subscription frame router the way the sibling request router already guards validateStatus, so no consumer throw can reach the ws 'message' emitter and become main_uncaught_exception. - Drop the status-owner `capable && present` gate: pauseStandingRetry no-ops while subscriptions exist, and removal teardown belongs to #21048's watcher. Co-authored-by: mmarabel * test(runtime-environments): cover the real socket path a consumer throw escapes The existing tests invoke the captured onResponse directly, which never touches the surface that actually kills the app. Drive a real WebSocket server through subscribeRemoteRuntimeRequest so the throw travels ws 'message' -> handleFrame -> consumer; without the frame-router guard vitest reports it as an unhandled error, which is main_uncaught_exception in production. --------- Co-authored-by: Neil Co-authored-by: mmarabel --- ...me-environment-cli-removal-routing.test.ts | 180 ++++++++++++++++++ .../ipc/runtime-environment-status-owner.ts | 4 +- .../runtime-environment-support-routing.ts | 3 +- .../runtime-environment-transport-routing.ts | 3 +- .../ipc/runtime-environment-usage-record.ts | 26 +++ ...-runtime-subscription-frame-router.test.ts | 59 +++++- ...emote-runtime-subscription-frame-router.ts | 22 ++- ...emote-runtime-subscription-request.test.ts | 20 ++ 8 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 src/main/ipc/runtime-environment-cli-removal-routing.test.ts create mode 100644 src/main/ipc/runtime-environment-usage-record.ts diff --git a/src/main/ipc/runtime-environment-cli-removal-routing.test.ts b/src/main/ipc/runtime-environment-cli-removal-routing.test.ts new file mode 100644 index 00000000000..d0dd7de03e2 --- /dev/null +++ b/src/main/ipc/runtime-environment-cli-removal-routing.test.ts @@ -0,0 +1,180 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import { + addEnvironmentFromPairingCode, + getEnvironmentStorePath, + removeEnvironment +} from '../../shared/runtime-environment-store' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + applyRuntimeEnvironmentCapabilityVerdict, + captureRuntimeEnvironmentCapabilityEvidence, + resetRuntimeEnvironmentCapabilityEvidence, + runtimeEnvironmentCapabilityOutcome +} from './runtime-environment-capability-evidence' + +type ResponseCallbacks = { + onResponse: (response: RuntimeRpcResponse) => void +} + +const { subscribeMock, supportsMock } = vi.hoisted(() => ({ + subscribeMock: vi.fn(), + supportsMock: vi.fn() +})) + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('../../shared/remote-runtime-client', async (importOriginal) => ({ + ...(await importOriginal()), + subscribeRemoteRuntimeRequest: subscribeMock +})) +vi.mock('./runtime-environment-shared-control-support', async (importOriginal) => ({ + ...(await importOriginal()), + supportsSharedControl: supportsMock +})) + +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' +import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' + +let userDataPath: string + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-removal-routing-')) + resetRuntimeEnvironmentCapabilityEvidence() + subscribeMock.mockReset() + supportsMock.mockReset() +}) + +afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) +}) + +describe('runtime responses the app can no longer attribute to a saved server', () => { + it('keeps verifying status after the store stops resolving the environment', () => { + const environment = seedEnvironment() + const transport = { + isReady: () => false, + request: vi.fn(), + establish: vi.fn(), + pause: vi.fn() + } + const owner = createRuntimeEnvironmentStatusOwner(userDataPath, environment, transport) + removeEnvironment(userDataPath, environment.id) + + // A throw here escapes `void verify()` as an unhandled rejection and skips settleWaiters(), + // hanging every refresh() caller until its own 15s timeout. + expect(() => owner.acceptVerified(statusResponse())).not.toThrow() + // Teardown for a removed environment belongs to the removal watcher, not to a status reader. + expect(transport.establish).toHaveBeenCalledOnce() + owner.dispose() + }) + + it.each([ + ['direct subscription', 'browser.screencast'], + ['support-routed subscription on a host without shared control', 'files.watch'] + ])('forwards a %s response after `orca environment rm`', async (_label, method) => { + const { deliver, onEvent, environment } = await subscribed(method) + // Mirrors the CLI: it edits the store without telling the running app. + removeEnvironment(userDataPath, environment.id) + const response = { + id: method, + ok: true as const, + result: {}, + _meta: { runtimeId: 'r' } + } + + expect(() => deliver(response)).not.toThrow() + expect(onEvent).toHaveBeenCalledWith({ type: 'response', response }) + }) + + it.each([ + ['direct subscription', 'browser.screencast'], + ['support-routed subscription on a host without shared control', 'files.watch'] + ])('forwards a %s response when the store file is unreadable', async (_label, method) => { + const { deliver, onEvent } = await subscribed(method) + // Any store failure is fatal on this path, not just a removal: a half-written or + // ACL-hardened settings file reaches the same unguarded socket callback. + writeFileSync(getEnvironmentStorePath(userDataPath), '{not json') + const response = { + id: method, + ok: true as const, + result: {}, + _meta: { runtimeId: 'r' } + } + + expect(() => deliver(response)).not.toThrow() + expect(onEvent).toHaveBeenCalledWith({ type: 'response', response }) + }) +}) + +function statusResponse() { + return { + id: 'status.get', + ok: true as const, + result: { + runtimeId: 'r', + rendererGraphEpoch: 1, + graphStatus: 'ready' as const, + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }, + _meta: { runtimeId: 'r' } + } +} + +async function subscribed(method: string) { + const environment = seedEnvironment() + supportsMock.mockResolvedValue(absentOutcome(environment)) + let callbacks: ResponseCallbacks | null = null + subscribeMock.mockImplementation( + async (_pairing, _method, _params, _timeoutMs, received: ResponseCallbacks) => { + callbacks = received + return { close: () => {} } satisfies Partial + } + ) + const onEvent = vi.fn() + await subscribeRuntimeEnvironment(userDataPath, environment.id, method, {}, 1000, { + onEvent, + onClose: () => {} + }) + return { + deliver: (response: RuntimeRpcResponse) => callbacks?.onResponse(response), + onEvent, + environment + } +} + +function seedEnvironment(): KnownRuntimeEnvironment { + return addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'device-token', + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + }) + }) +} + +function absentOutcome(environment: KnownRuntimeEnvironment) { + const evidence = captureRuntimeEnvironmentCapabilityEvidence( + environment.id, + getPreferredPairingOffer(environment) + ) + applyRuntimeEnvironmentCapabilityVerdict({ + evidence, + verdict: 'absent', + runtimeId: 'r' + }) + return runtimeEnvironmentCapabilityOutcome(evidence, 'absent', 'r') +} diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 4ac3c067f74..26b5264ef9f 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -8,7 +8,7 @@ import { getPreferredPairingOffer, type KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' import { RUNTIME_HOST_STATUS_CHANNEL, @@ -61,7 +61,7 @@ export function createRuntimeEnvironmentStatusOwner( runtimeId: response._meta.runtimeId }) if (accepted && active && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - markEnvironmentUsed(userDataPath, environment.id, { + recordRuntimeEnvironmentUsage(userDataPath, environment.id, { runtimeId: response._meta.runtimeId, pairedDeviceId: response.result.pairedDeviceId }) diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index 9566b2fc1b7..f81d045a568 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -7,6 +7,7 @@ import type { import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store' +import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { subscribeRemoteRuntimeRequest, @@ -262,7 +263,7 @@ function subscriptionCallbacks( return { onResponse: (response: RuntimeRpcResponse) => { if (response.ok && shouldMarkUsed()) { - markEnvironmentUsed(args.userDataPath, args.environment.id, { + recordRuntimeEnvironmentUsage(args.userDataPath, args.environment.id, { runtimeId: response._meta.runtimeId }) } diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index f39962c20cb..327f1428ea7 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,6 +1,7 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, @@ -195,7 +196,7 @@ export async function subscribeRuntimeEnvironment( return } markedUsed = true - markEnvironmentUsed(userDataPath, environment.id, { runtimeId }) + recordRuntimeEnvironmentUsage(userDataPath, environment.id, { runtimeId }) } const callbacksWithMarkUsed = { onResponse: (response: RuntimeRpcResponse) => { diff --git a/src/main/ipc/runtime-environment-usage-record.ts b/src/main/ipc/runtime-environment-usage-record.ts new file mode 100644 index 00000000000..af56ede3a30 --- /dev/null +++ b/src/main/ipc/runtime-environment-usage-record.ts @@ -0,0 +1,26 @@ +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' + +/** + * Records `lastUsedAt` for callers that cannot report a failure to anyone. + * + * Why: subscription frames arrive on a websocket callback with no promise to reject and no + * caller to observe the result, so a throw from the store unwinds into the socket emitter and + * kills the main process. `orca environment rm` makes that routine — it edits the store behind + * the running app's back, so every later response resolves an environment that is gone. Usage + * bookkeeping has no consumer, so nothing here is worth an exception. Awaited request paths keep + * calling `markEnvironmentUsed` directly: there the rejection is observable and correct. + */ +export function recordRuntimeEnvironmentUsage( + userDataPath: string, + selector: string, + args: { runtimeId?: string | null; pairedDeviceId?: string } = {} +): void { + try { + markEnvironmentUsed(userDataPath, selector, args) + } catch (error) { + console.warn( + `Skipped last-used bookkeeping for runtime environment ${selector}:`, + error instanceof Error ? error.message : error + ) + } +} diff --git a/src/shared/remote-runtime-subscription-frame-router.test.ts b/src/shared/remote-runtime-subscription-frame-router.test.ts index 8563c9b0d2f..e75ca2e8b6d 100644 --- a/src/shared/remote-runtime-subscription-frame-router.test.ts +++ b/src/shared/remote-runtime-subscription-frame-router.test.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { describe, expect, it, vi } from 'vitest' -import { encrypt } from './e2ee-crypto' +import { encrypt, encryptBytes } from './e2ee-crypto' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { RemoteRuntimeSubscriptionFrameRouter } from './remote-runtime-subscription-frame-router' @@ -58,3 +58,60 @@ describe('RemoteRuntimeSubscriptionFrameRouter authentication frames', () => { }) }) }) + +function createReadyRouter(callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void +}) { + const fail = vi.fn<(error: RemoteRuntimeClientError) => void>() + const router = new RemoteRuntimeSubscriptionFrameRouter({ + sharedKey: SHARED_KEY, + serializedAuth: '{}', + serializedRequest: '{}', + requestId: 'request-1', + send: vi.fn(), + fail, + onAuthenticated: vi.fn(), + callbacks + }) + router.state = 'ready' + return { fail, router } +} + +describe('RemoteRuntimeSubscriptionFrameRouter consumer callbacks', () => { + it('fails the subscription instead of throwing out of the socket message handler', () => { + const { fail, router } = createReadyRouter({ + onResponse: () => { + throw new Error('Unknown environment: env-1') + } + }) + const response = JSON.stringify({ + id: 'request-1', + ok: true, + result: {}, + _meta: { runtimeId: 'r' } + }) + + expect(() => handleEncryptedAuthFrame(router, response)).not.toThrow() + expect(fail).toHaveBeenCalledOnce() + expect(fail.mock.calls[0][0]).toMatchObject({ + code: 'runtime_error', + message: 'Unknown environment: env-1' + }) + }) + + it('fails the subscription when a binary consumer throws', () => { + const { fail, router } = createReadyRouter({ + onResponse: vi.fn(), + onBinary: () => { + throw new Error('consumer exploded') + } + }) + + expect(() => + router.handleFrame(Buffer.from(encryptBytes(new Uint8Array([1, 2, 3]), SHARED_KEY)), true) + ).not.toThrow() + expect(fail).toHaveBeenCalledOnce() + expect(fail.mock.calls[0][0]).toMatchObject({ code: 'runtime_error' }) + }) +}) diff --git a/src/shared/remote-runtime-subscription-frame-router.ts b/src/shared/remote-runtime-subscription-frame-router.ts index fa8e6f3dce4..e6f2e2e3986 100644 --- a/src/shared/remote-runtime-subscription-frame-router.ts +++ b/src/shared/remote-runtime-subscription-frame-router.ts @@ -116,7 +116,7 @@ export class RemoteRuntimeSubscriptionFrameRouter { } const response = parsed.data as RuntimeRpcResponse if (response.id === this.options.requestId) { - this.options.callbacks.onResponse(response) + this.deliver(() => this.options.callbacks.onResponse(response)) return } if (this.options.resolvePendingRequest?.(response as RuntimeRpcResponse)) { @@ -150,6 +150,24 @@ export class RemoteRuntimeSubscriptionFrameRouter { ) return } - this.options.callbacks.onBinary?.(plaintext) + this.deliver(() => this.options.callbacks.onBinary?.(plaintext)) + } + + // Why: `handleFrame` runs straight off the ws 'message' emitter, so a consumer throw becomes an + // uncaught exception that kills the process. The request router already routes one to + // `finishError`; mirror that here rather than leaving the subscription path unguarded. + private deliver(emit: () => void): void { + try { + emit() + } catch (error) { + this.options.fail( + error instanceof RemoteRuntimeClientError + ? error + : new RemoteRuntimeClientError( + 'runtime_error', + error instanceof Error ? error.message : String(error) + ) + ) + } } } diff --git a/src/shared/remote-runtime-subscription-request.test.ts b/src/shared/remote-runtime-subscription-request.test.ts index 14f904308cc..800c0a78184 100644 --- a/src/shared/remote-runtime-subscription-request.test.ts +++ b/src/shared/remote-runtime-subscription-request.test.ts @@ -214,6 +214,26 @@ describe('remote runtime subscription JSON requests', () => { ) } ) + + // Why: `ws.on('message')` calls the frame router directly, so before this was guarded a consumer + // throw here left the emitter and became main_uncaught_exception, killing the whole app. + it('fails the subscription instead of crashing the process when a consumer throws', async () => { + const server = await createServer() + const onError = vi.fn<(error: unknown) => void>() + const onClose = vi.fn<() => void>() + await subscribe(server.pairing, { + onResponse: () => { + throw new Error('Unknown environment: env-1') + }, + onError, + onClose + }) + + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'runtime_error', message: 'Unknown environment: env-1' }) + ) + }) }) function requireSender(subscription: RemoteRuntimeSubscription) { From 2796a3ac153e04cc86b0da7aef07cea00b575817 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:57:31 -0700 Subject: [PATCH 002/387] fix(claude): prove a stopped chat's child processes gone when they exit with it (#22918) * fix(claude): prove a stopped chat's child processes gone when they exit with it Stopping a Claude chat snapshots its child processes, closes Claude, and then verifies each child is gone before the stop counts as proven. The verifier only accepted a child as gone after that child had appeared in one of its own process-table reads. When Claude exits gracefully it takes its short-lived children with it before the first read, so none of them was ever seen again. Every read confirmed them absent, and the verdict was still "unverifiable" after the full 3.5 s window. Measured live: 37 complete reads, target absent from all, verdict unverifiable, on every idle stop. The snapshot is itself a table read that saw each child alive, so it now counts as the first sighting. An absence counts only from a read that started after the child was last seen, which keeps what the old rule protected against: a shared or in-flight read begun before the snapshot cannot list a child forked since. Two such absences prove a child gone. Live, the same stop now proves the tree gone in about 150 ms. The daemon's terminal shutdown uses the same verifier and gets the same rule. Test reads that reused one capture stamped with the snapshot's own time now stamp each read when it starts, as real scans do. * fix(claude): keep the latest sighting and count the final read as an absence A matching read that started earlier but resolved later could move a target's last sighting back and let an older absence count; the sighting now only moves forward. The read after the deadline now records its absences the same way the polling loop does, so a second qualifying absence there proves the target gone. --- .../terminal-descendant-shutdown.test.ts | 5 +- .../pty-descendant-exit-verification.test.ts | 92 +++++++++++++++---- src/main/pty-descendant-exit-verification.ts | 57 +++++++----- src/main/pty-descendant-termination.test.ts | 3 +- 4 files changed, 112 insertions(+), 45 deletions(-) diff --git a/src/main/daemon/terminal-descendant-shutdown.test.ts b/src/main/daemon/terminal-descendant-shutdown.test.ts index 52258d0a650..c47727f3f90 100644 --- a/src/main/daemon/terminal-descendant-shutdown.test.ts +++ b/src/main/daemon/terminal-descendant-shutdown.test.ts @@ -28,9 +28,10 @@ describe('terminal shutdown process-table batching', () => { startedAt: 'Mon Jul 13 12:54:47 2026' } ]).flat() + // Each scan is stamped when it starts, after the walks that produced the snapshots. readProcessTable - .mockResolvedValueOnce({ rows, capturedAtMs: Date.now() }) - .mockResolvedValue({ rows: [], capturedAtMs: Date.now() }) + .mockImplementationOnce(async () => ({ rows, capturedAtMs: Date.now() + 1 })) + .mockImplementation(async () => ({ rows: [], capturedAtMs: Date.now() + 1 })) const shutdowns = Array.from({ length: 20 }, (_, index) => { const rootPid = 100 + index const snapshot = collectDescendantRows(rootPid, [ diff --git a/src/main/pty-descendant-exit-verification.test.ts b/src/main/pty-descendant-exit-verification.test.ts index 4b92a6444c0..72b331492ad 100644 --- a/src/main/pty-descendant-exit-verification.test.ts +++ b/src/main/pty-descendant-exit-verification.test.ts @@ -32,9 +32,9 @@ describe('descendant exit verification across partial process-table reads', () = const snapshot = collectDescendantRows(10, [row(10, 1), first, later], CAPTURED_AT) const readTable = vi .fn() - .mockResolvedValueOnce(capture([first])) - .mockResolvedValueOnce(capture([first, later])) - .mockResolvedValue(capture([])) + .mockImplementationOnce(async () => capture([first])) + .mockImplementationOnce(async () => capture([first, later])) + .mockImplementation(async () => capture([])) const sendSignal = vi.fn() const pending = terminateDescendantSnapshotWithVerdict(snapshot, { readTable, @@ -52,16 +52,37 @@ describe('descendant exit verification across partial process-table reads', () = ]) }) - it('keeps an omitted target unverifiable when partial reads never show its identity', async () => { - const first = row(20) - const omitted = row(30) - const snapshot = collectDescendantRows(10, [row(10, 1), first, omitted], CAPTURED_AT) + it('proves a target gone that only the snapshot saw, once two later reads miss it', async () => { + // A root that exits on its own takes a short-lived child with it before the first poll. + const snapshot = collectDescendantRows(10, [row(10, 1), row(20)], CAPTURED_AT) const sendSignal = vi.fn() const pending = terminateDescendantSnapshotWithVerdict(snapshot, { - readTable: vi - .fn() - .mockResolvedValueOnce(capture([first])) - .mockResolvedValue(capture([])), + readTable: vi.fn().mockImplementation(async () => capture([])), + sendSignal, + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 3_500 + }) + let verdict: string | undefined + void pending.then((value) => { + verdict = value + }) + // Proven within a few polls, not by waiting out the verification window. + await vi.advanceTimersByTimeAsync(200) + + expect(verdict).toBe('exited') + expect(sendSignal).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(4_000) + }) + + it('does not count an absence from a read that started before the target was seen', async () => { + const unseen = row(30) + const snapshot = collectDescendantRows(10, [row(10, 1), unseen], CAPTURED_AT) + const sendSignal = vi.fn() + // A shared read already in flight when the snapshot ran cannot list a descendant forked since. + const staleRead = { rows: [], capturedAtMs: CAPTURED_AT - 1 } + const pending = terminateDescendantSnapshotWithVerdict(snapshot, { + readTable: vi.fn().mockResolvedValue(staleRead), sendSignal, requireIdentityBeforeSignal: true, graceMs: 0, @@ -70,8 +91,45 @@ describe('descendant exit verification across partial process-table reads', () = await vi.advanceTimersByTimeAsync(200) await expect(pending).resolves.toBe('unverifiable') - expect(sendSignal).toHaveBeenCalledWith(first.pid, 'SIGTERM') - expect(sendSignal).not.toHaveBeenCalledWith(omitted.pid, 'SIGTERM') + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('keeps the latest sighting when an earlier-started read that matches resolves later', async () => { + const target = row(20) + const snapshot = collectDescendantRows(10, [row(10, 1), target], CAPTURED_AT) + const readTable = vi + .fn() + .mockResolvedValueOnce({ rows: [target], capturedAtMs: CAPTURED_AT + 300 }) + .mockResolvedValueOnce({ rows: [target], capturedAtMs: CAPTURED_AT + 100 }) + // Began between the two sightings, so it cannot prove the later one gone. + .mockResolvedValue({ rows: [], capturedAtMs: CAPTURED_AT + 200 }) + const pending = terminateDescendantSnapshotWithVerdict(snapshot, { + readTable, + sendSignal: vi.fn(), + requireIdentityBeforeSignal: true, + graceMs: 10_000, + verifyMs: 300 + }) + await vi.advanceTimersByTimeAsync(400) + + await expect(pending).resolves.toBe('unverifiable') + }) + + it('counts the read after the deadline as an absence', async () => { + const snapshot = collectDescendantRows(10, [row(10, 1), row(20)], CAPTURED_AT) + const readTable = vi.fn().mockImplementation(async () => capture([])) + // One poll fits in the window; the final read supplies the second absence. + const pending = terminateDescendantSnapshotWithVerdict(snapshot, { + readTable, + sendSignal: vi.fn(), + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 50 + }) + await vi.advanceTimersByTimeAsync(100) + + await expect(pending).resolves.toBe('exited') + expect(readTable).toHaveBeenCalledTimes(2) }) it('escalates a survivor omitted at the first force-kill read when it reappears', async () => { @@ -80,10 +138,10 @@ describe('descendant exit verification across partial process-table reads', () = const snapshot = collectDescendantRows(10, [row(10, 1), first, later], CAPTURED_AT) const readTable = vi .fn() - .mockResolvedValueOnce(capture([first, later])) - .mockResolvedValueOnce(capture([first])) - .mockResolvedValueOnce(capture([first, later])) - .mockResolvedValue(capture([])) + .mockImplementationOnce(async () => capture([first, later])) + .mockImplementationOnce(async () => capture([first])) + .mockImplementationOnce(async () => capture([first, later])) + .mockImplementation(async () => capture([])) const sendSignal = vi.fn() const pending = terminateDescendantSnapshotWithVerdict(snapshot, { readTable, diff --git a/src/main/pty-descendant-exit-verification.ts b/src/main/pty-descendant-exit-verification.ts index 27ed62ca0a0..bc23faf2958 100644 --- a/src/main/pty-descendant-exit-verification.ts +++ b/src/main/pty-descendant-exit-verification.ts @@ -124,7 +124,32 @@ export async function terminateDescendantSnapshotWithVerdict( const forced = new Set() const signalled = new Set() const missingObservations = new Map(snapshot.descendants.map((row) => [row.pid, 0])) - const observedPids = new Set() + // The snapshot is itself a table read that saw each target alive. An absence is evidence only + // from a read that started after the target was last seen: a shared or in-flight read begun + // earlier can miss a descendant forked since, but a later full scan cannot miss a live one. + const lastSeenAtMs = new Map( + snapshot.descendants.map((row) => [ + row.pid, + snapshot.capturedAtMsByPid?.[String(row.pid)] ?? snapshot.capturedAtMs + ]) + ) + const recordIdentityObservation = ( + capture: ProcessTableCapture, + live: readonly ProcessTableRow[] + ): void => { + for (const row of snapshot.descendants) { + const lastSeen = lastSeenAtMs.get(row.pid) ?? Infinity + if (live.some((current) => current.pid === row.pid)) { + missingObservations.set(row.pid, 0) + // A read that started earlier but resolved later must not move the sighting back. + lastSeenAtMs.set(row.pid, Math.max(lastSeen, capture.capturedAtMs)) + } else if (capture.capturedAtMs > lastSeen) { + missingObservations.set(row.pid, (missingObservations.get(row.pid) ?? 0) + 1) + } + } + } + const provenAbsent = (): boolean => + snapshot.descendants.every((row) => (missingObservations.get(row.pid) ?? 0) >= 2) if (!deps.requireIdentityBeforeSignal) { for (const row of snapshot.descendants) { sendSignal(row.pid, 'SIGTERM') @@ -148,30 +173,14 @@ export async function terminateDescendantSnapshotWithVerdict( } const live = matchingSnapshotRows(snapshot, capture.rows, deps.requireIdentityBeforeSignal) if (deps.requireIdentityBeforeSignal) { - const rowsByPid = new Set(capture.rows.map((row) => row.pid)) - for (const row of snapshot.descendants) { - if (rowsByPid.has(row.pid)) { - observedPids.add(row.pid) - } - if (live.some((current) => current.pid === row.pid)) { - missingObservations.set(row.pid, 0) - } else { - missingObservations.set(row.pid, (missingObservations.get(row.pid) ?? 0) + 1) - } - } + recordIdentityObservation(capture, live) } if (live.length === 0) { // Before a signal has been sent, an empty identity match means the // snapshotted descendants already exited or were replaced. Signalling - // those old numeric pids would be unsafe. Partial reads are not proof - // that a never-observed target exited, so every identity needs two - // bounded absences after it has appeared in a table. - if ( - deps.requireIdentityBeforeSignal && - !snapshot.descendants.every( - (row) => observedPids.has(row.pid) && (missingObservations.get(row.pid) ?? 0) >= 2 - ) - ) { + // those old numeric pids would be unsafe. Every identity needs two + // absences from reads that started after it was last seen. + if (deps.requireIdentityBeforeSignal && !provenAbsent()) { await waitForDelay(50, deps.keepAlive) continue } @@ -235,10 +244,8 @@ export async function terminateDescendantSnapshotWithVerdict( return 'live' } if (deps.requireIdentityBeforeSignal) { - const everyTargetAbsent = snapshot.descendants.every( - (row) => observedPids.has(row.pid) && (missingObservations.get(row.pid) ?? 0) >= 2 - ) - return everyTargetAbsent ? 'exited' : 'unverifiable' + recordIdentityObservation(finalCapture, finalLive) + return provenAbsent() ? 'exited' : 'unverifiable' } return 'exited' } diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index f6b8e066983..6261ec92fc2 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -453,7 +453,8 @@ describe('terminateDescendantSnapshotAndWait', () => { snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]), { sendSignal, - readTable: vi.fn().mockResolvedValue(tableCapture([recycled])), + // Reads begin after the walk that produced the snapshot, as every fresh scan does. + readTable: vi.fn().mockResolvedValue(tableCapture([recycled], CAPTURED_AT_MS + 1_000)), requireIdentityBeforeSignal: true, verifyMs: 100 } From 7d2c399329ffc60dd67c30ca2021ec4fdc8c307f Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:01:30 +0200 Subject: [PATCH 003/387] fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID (#22516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID Browsers hide crypto.randomUUID outside secure contexts, so Remote Web over http:// threw while importing the store and never painted. createAgentStatusAuthorityId now takes its UUID source (renderer passes createBrowserUuid, main passes node:crypto randomUUID), and the other unguarded renderer calls go through createBrowserUuid. * refactor(renderer): route remaining randomUUID fallbacks through createBrowserUuid Replaces five hand-rolled crypto?.randomUUID?.() fallbacks (including a copy of the browser-uuid fallback in mint-stable-pane-id) with createBrowserUuid, and adds an oxlint no-restricted-properties rule so renderer code cannot call randomUUID directly again. * refactor(shared): move the non-secure-context UUID generator into src/shared The white screen came from src/shared, so the fix belongs there. src/shared had three hand-rolled copies of the same randomUUID-then-getRandomValues-then-Math.random ladder (nested-repo-telemetry, project-groups, setup-agent-sequencing) because there was nothing in that layer to import; createBrowserUuid lived one directory over in the renderer. createNonSecureContextUuid() now holds the single implementation, @/lib/browser-uuid re-exports it under the renderer's existing name so no renderer import site changes, and the three duplicates call it. That also lets createAgentStatusAuthorityId go back to one argument. The injected randomUuid source was justified as keeping browser APIs out of shared code, but this generator is runtime-agnostic — it works unchanged in Node. Injecting it bought no layering and made the safe choice a parameter every future caller had to get right, unguarded: a caller could pass () => globalThis.crypto.randomUUID() and restore the white screen with lint and tests green. * fix(lint): ban crypto.randomUUID in src/shared and scope the escape hatch vite.web.config.ts compiles src/shared straight into the web bundle, but the new randomUUID ban only covered src/renderer/src — so the exact module that white-screened the app sat outside the guard it shipped with, and the regression could come back with a green lint. The override now covers src/shared/**/*.ts too; it costs zero diagnostics because the duplicates it would have flagged are gone. `import { randomUUID } from 'node:crypto'` is untouched, so main-only shared modules keep working. Both blanket "off" overrides are gone. no-restricted-properties is keyed by property name, so the moment a second property joins the renderer block those overrides would have silently exempted it — in the one file that is the escape hatch, and in every test in the repo. Tests are where people copy patterns from, so they stay covered; the four real uses carry line-scoped disables with a reason. * fix(terminal): keep render-desync capture ids inside main's 120-char cap createCaptureId builds `${Date.now()}-${panePart}-${nonce}`. A real paneKey is `${tabId}:${leafId}` — two UUIDs, 73 chars after sanitizing — so with a 36-char UUID nonce the id is 124 chars and main rejects it with 'Invalid render-desync capture id'. persistHealedReference swallows that into console.error, so it shows up as diagnostics that silently never appear. This was already broken on the desktop app, where randomUUID is available; routing the non-secure path through the same generator would have made it unconditional, including on the plain-HTTP web client this branch exists to repair. Bound the pane part rather than the nonce: keep the trailing 40 chars, which is the whole leaf id (the identifying half, unique on its own) and drop the tab-id prefix, so ids stay unique and traceable at 91 chars. The 120-char contract now lives in src/shared next to the IPC args, imported by both sides, so the renderer cannot mint an id main will reject without the test noticing. * test(web): cover the whole store graph and the Vault token without randomUUID The reported stack was the store chunk, not two named modules, so the repro test now evaluates the store root under the stubbed non-secure crypto. Any new import-time secure-context call anywhere in that graph fails here, not just the one this branch removed. Also ports the request-token regression from #20465, the one piece of coverage the competing branches for this bug contributed that this one lacked. Both cases fail with "randomUUID is not a function" when their production change is reverted. * test(web): restore the real crypto.randomUUID after the non-secure Vault case randomUUID lives on Crypto.prototype, so stubbing it as an own property of globalThis.crypto left the restore branch with an undefined descriptor and a leaked own `randomUUID: undefined`. Swap the whole crypto own property instead, through one shared stub the repro suite already needed. --------- Co-authored-by: Neil --- .oxlintrc.json | 21 +++++++++- .../ipc/terminal-render-desync-evidence.ts | 10 ++--- .../automation-orca-save-operations.ts | 3 +- .../use-emulator-video-stream.ts | 3 +- ...structured-agent-session-outbox-storage.ts | 3 +- ...e-structured-agent-session-outbox.test.tsx | 4 ++ .../use-structured-agent-session-outbox.ts | 3 +- .../ai-vault-session-refresh.test.ts | 26 +++++++++++++ .../right-sidebar/ai-vault-session-refresh.ts | 3 +- ...GlobalWorktreeVisibilitySourcesSetting.tsx | 3 +- .../components/settings/OpenInMenuSetting.tsx | 5 +-- .../sidebar/WorktreeVisibilityDialog.tsx | 3 +- .../sidebar/worktree-snapshot-prune-batch.ts | 4 +- .../skills/SkillBundleInstallFlow.tsx | 3 +- .../components/skills/SkillInstallDialog.tsx | 3 +- .../skills/SkillInstallManagementDialog.tsx | 3 +- .../skills/use-skill-delete-flow.ts | 3 +- ...render-desync-evidence-persistence.test.ts | 27 +++++++++++++ ...inal-render-desync-evidence-persistence.ts | 10 ++++- .../use-workspace-cleanup-git-evidence.ts | 3 +- .../workspace-cleanup-snapshot-prune-batch.ts | 3 +- src/renderer/src/lib/browser-uuid.ts | 31 ++------------- .../lib/launch-structured-agent-session.ts | 5 ++- .../src/lib/non-secure-context-crypto-stub.ts | 29 ++++++++++++++ .../non-secure-context-crypto.repro.test.ts | 35 +++++++++++++---- .../lib/pane-manager/mint-stable-pane-id.ts | 24 ++---------- ...structured-agent-session-launch-options.ts | 3 +- .../structured-agent-session-launch-prompt.ts | 3 +- .../slices/agent-status-authority-actions.ts | 3 +- src/renderer/src/store/slices/settings.ts | 10 +---- .../workspace-cleanup-removal-targets.ts | 3 +- .../workspace-cleanup-scan-lifecycle.ts | 3 +- .../src/store/slices/workspace-cleanup.ts | 3 +- .../removed-worktree-snapshot-prune.ts | 3 +- src/shared/agent-status-observation.ts | 4 +- src/shared/nested-repo-telemetry.ts | 22 ++--------- src/shared/non-secure-context-uuid.ts | 38 +++++++++++++++++++ src/shared/project-groups.ts | 11 +----- src/shared/setup-agent-sequencing.ts | 7 +--- src/shared/terminal-render-desync-evidence.ts | 4 ++ 40 files changed, 255 insertions(+), 132 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts create mode 100644 src/renderer/src/lib/non-secure-context-crypto-stub.ts create mode 100644 src/shared/non-secure-context-uuid.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 7de99d4461e..7a0753c06e2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -143,7 +143,26 @@ { "files": ["src/renderer/src/**/*.{ts,tsx}"], "rules": { - "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error" + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error", + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + } + ] + } + }, + { + "files": ["src/shared/**/*.ts"], + "rules": { + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code." + } + ] } }, { diff --git a/src/main/ipc/terminal-render-desync-evidence.ts b/src/main/ipc/terminal-render-desync-evidence.ts index 9ea82246539..dc536faf1f8 100644 --- a/src/main/ipc/terminal-render-desync-evidence.ts +++ b/src/main/ipc/terminal-render-desync-evidence.ts @@ -1,9 +1,10 @@ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { app, ipcMain } from 'electron' -import type { - WriteTerminalRenderDesyncEvidenceArgs, - WriteTerminalRenderDesyncEvidenceResult +import { + TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN, + type WriteTerminalRenderDesyncEvidenceArgs, + type WriteTerminalRenderDesyncEvidenceResult } from '../../shared/terminal-render-desync-evidence' import { isTrustedUIRenderer } from './ui' @@ -12,7 +13,6 @@ const MAX_PNG_DATA_URL_BYTES = 40 * 1024 * 1024 const MAX_METADATA_BYTES = 1024 * 1024 const MAX_CAPTURE_DIRECTORIES = 4 const MAX_EVIDENCE_BYTES = 96 * 1024 * 1024 -const CAPTURE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,120}$/ const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' let evidenceWriteQueue = Promise.resolve() @@ -41,7 +41,7 @@ export async function writeTerminalRenderDesyncEvidence( userDataPath: string, args: WriteTerminalRenderDesyncEvidenceArgs ): Promise { - if (!CAPTURE_ID_PATTERN.test(args.captureId)) { + if (!TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN.test(args.captureId)) { throw new Error('Invalid render-desync capture id') } if (args.phase !== 'corrupt' && args.phase !== 'healed') { diff --git a/src/renderer/src/components/automations/automation-orca-save-operations.ts b/src/renderer/src/components/automations/automation-orca-save-operations.ts index b12874d0036..914a6b47843 100644 --- a/src/renderer/src/components/automations/automation-orca-save-operations.ts +++ b/src/renderer/src/components/automations/automation-orca-save-operations.ts @@ -33,6 +33,7 @@ import type { AutomationHostTarget } from './automation-host-client' import type { AutomationAuthorityChangeReason } from './automation-host-invalidation' import type { AutomationSaveContext } from './automation-save-context' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { createBrowserUuid } from '@/lib/browser-uuid' export type AutomationMoveOperationContext = { automationDispatchContext: AutomationDispatchContext @@ -165,7 +166,7 @@ export async function moveAutomationToDestination( } const operationKey = `${source.id}:${target.entry.stableKey}` - const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID() + const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? createBrowserUuid() context.moveCreationKeysRef.current.set(operationKey, creationKey) const created = toDispatchResult( await createAutomationAtDestination( diff --git a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts index 35857aa2b7d..1dc9f18593d 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { createBrowserUuid } from '@/lib/browser-uuid' // Decodes the Android H.264 stream (scrcpy access units forwarded over the // emulator:videoStream* IPC) with WebCodecs and paints it to a . The @@ -32,7 +33,7 @@ const H264_CODEC = 'avc1.640028' type StreamSize = { width: number; height: number } function newVideoStreamId(): string { - return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` + return createBrowserUuid() } export function useEmulatorVideoStream( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index b96fb55ea60..8e9bb342638 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -4,6 +4,7 @@ import { type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' +import { createBrowserUuid } from '@/lib/browser-uuid' const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' @@ -103,7 +104,7 @@ export function enqueueStructuredAgentSessionLaunchPrompt( ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + clientMessageId: createStructuredAgentSessionOperationId(createBrowserUuid), sessionId, text, attachments: [], diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 613a599c2a6..662ed752582 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -233,6 +233,7 @@ describe('useStructuredAgentSessionOutbox', () => { it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)( 'rotates a send operation after %s', async (code) => { + // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test vi.mocked(globalThis.crypto.randomUUID) .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') .mockReturnValueOnce('22222222-2222-4222-8222-222222222222') @@ -591,6 +592,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) it('retries an unknown head and advances a queued tail', async () => { + // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test vi.mocked(globalThis.crypto.randomUUID) .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') @@ -653,6 +655,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) it('rotates a history-rejected unknown head so the queued tail can advance', async () => { + // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test vi.mocked(globalThis.crypto.randomUUID) .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') @@ -713,6 +716,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) it('rotates the id after a refused write and delivers the message exactly once', async () => { + // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test vi.mocked(globalThis.crypto.randomUUID) .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index e38709b9fed..17a9e2769f3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -16,9 +16,10 @@ import { readMountedStructuredAgentSessionOutbox } from './structured-agent-session-outbox-dispatch' import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-session-launch-prompt' +import { createBrowserUuid } from '@/lib/browser-uuid' export function structuredSessionOperationId(): string { - return createStructuredAgentSessionOperationId(() => crypto.randomUUID()) + return createStructuredAgentSessionOperationId(createBrowserUuid) } const UNCONFIRMED_PROBE_BASE_DELAY_MS = 1_000 diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 19f0d47ce82..567e080360a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -13,6 +13,7 @@ import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit' +import { withNonSecureContextCrypto } from '@/lib/non-secure-context-crypto-stub' const EMPTY_RESULT: AiVaultListResult = { sessions: [], @@ -801,3 +802,28 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => { expect(listSessionsMock.mock.calls.length).toBe(callsWhileHealthy) }) }) + +// Regression for #18096: over plain HTTP the browser hides crypto.randomUUID, so minting +// the request token with a raw call threw during render and the panel showed "The right +// sidebar hit an error". The fallback must still be a well-formed v4 UUID. +describe('useAiVaultSessionRefresh in a non-secure context', () => { + it('mints a request token when crypto.randomUUID is unavailable', async () => { + await withNonSecureContextCrypto(async () => { + await renderHook() + await flushMicrotasks() + + expect(lastCallArgs()).toMatchObject({ + requestToken: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + }) + }) + }) + + // Guards the stub itself: an own-property stub of randomUUID is unrestorable, so a + // leaky teardown would silently strip the real method from every later test in the file. + it('leaves the real crypto.randomUUID in place afterwards', () => { + // oxlint-disable-next-line no-restricted-properties -- asserting the restore this case exists for + expect(typeof globalThis.crypto.randomUUID).toBe('function') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index b8787af49f9..9ace8fbd399 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -21,6 +21,7 @@ import { readCachedAiVaultSessionResult, resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' +import { createBrowserUuid } from '@/lib/browser-uuid' // In-app session creation bypasses the cache so the new session appears promptly. // Keep the budget at module scope so tab remounts cannot amplify full scans. @@ -94,7 +95,7 @@ export function useAiVaultSessionRefresh( const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const requestTokenRef = useRef(undefined!) - requestTokenRef.current ??= crypto.randomUUID() + requestTokenRef.current ??= createBrowserUuid() const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) diff --git a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx index 1cf9a026cd9..b660b25a3f3 100644 --- a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx +++ b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx @@ -17,6 +17,7 @@ import WorktreeVisibilitySourceList, { type WorktreeVisibilitySourceRow } from '../sidebar/WorktreeVisibilitySourceList' import { translate } from '@/i18n/i18n' +import { createBrowserUuid } from '@/lib/browser-uuid' type Props = { settings: GlobalSettings @@ -105,7 +106,7 @@ export function GlobalWorktreeVisibilitySourcesSetting({ if (customSources.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { return 'limit' } - const id = crypto.randomUUID().replaceAll('-', '') + const id = createBrowserUuid().replaceAll('-', '') const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0] if (!candidate) { return 'invalid-path' diff --git a/src/renderer/src/components/settings/OpenInMenuSetting.tsx b/src/renderer/src/components/settings/OpenInMenuSetting.tsx index aa18ec951f1..33db6a1b4bc 100644 --- a/src/renderer/src/components/settings/OpenInMenuSetting.tsx +++ b/src/renderer/src/components/settings/OpenInMenuSetting.tsx @@ -23,6 +23,7 @@ import { type OpenInAppPreset } from '@/lib/open-in-app-catalog' import { translate } from '@/i18n/i18n' +import { createBrowserUuid } from '@/lib/browser-uuid' type OpenInMenuSettingProps = { applications: OpenInApplication[] | undefined @@ -36,9 +37,7 @@ type OpenInApplicationsDraftState = { function createOpenInApplication(): OpenInApplication { return { - id: - globalThis.crypto?.randomUUID?.() ?? - `open-in-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + id: createBrowserUuid(), label: '', command: '' } diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 3ce9a85d275..3eadb6ca0d4 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -56,6 +56,7 @@ import { shouldUseGlobalWorktreeVisibility } from './worktree-visibility-use-global' import { createWorktreeVisibilitySourceMutation } from './worktree-visibility-source-mutation' +import { createBrowserUuid } from '@/lib/browser-uuid' export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const activeModal = useAppStore((s) => s.activeModal) @@ -277,7 +278,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { if ((existing?.length ?? 0) >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { return 'limit' } - const id = crypto.randomUUID().replaceAll('-', '') + const id = createBrowserUuid().replaceAll('-', '') const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0] if (!candidate) { return 'invalid-path' diff --git a/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts index 91e970291fd..f8cbd049cf8 100644 --- a/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts +++ b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts @@ -1,3 +1,5 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' + export type WorktreeSnapshotPruneBatch = { batchId: string finish: () => Promise @@ -14,7 +16,7 @@ export function beginWorktreeSnapshotPruneBatch(): Promise ({ batchId, finish: () => finish({ batchId }) })) .catch((error: unknown) => { diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx index 3663ac4e81a..e4115f5ba13 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -24,6 +24,7 @@ import { translate } from '@/i18n/i18n' import { checklistItemsFromVersion } from './skill-package-checklist-items' import { summarizeSkillInstallRisk } from './skill-package-install-risk' import { retryableSkillIds } from './skill-bundle-retry-selection' +import { createBrowserUuid } from '@/lib/browser-uuid' type BundleVersion = SkillCloudVersion & { manifest: Extract @@ -169,7 +170,7 @@ export function SkillBundleInstallFlow(props: { return } } - const operationId = crypto.randomUUID() + const operationId = createBrowserUuid() installProgress.begin(operationId) const operation = await window.api.skills.installBundleShare({ shareId: props.shareId, diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx index 6ad6edcbedf..8f150708ee9 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -26,6 +26,7 @@ import { translate } from '@/i18n/i18n' import { resolveSkillShareForInstall } from './skill-warning-preview-gate' import { useSkillInstallRisk } from './use-skill-install-risk' import { SkillInstallDialogFooter } from './SkillInstallDialogFooter' +import { createBrowserUuid } from '@/lib/browser-uuid' export function SkillInstallDialog({ open, @@ -193,7 +194,7 @@ export function SkillInstallDialog({ return } } - const operationId = crypto.randomUUID() + const operationId = createBrowserUuid() installProgress.begin(operationId) const operation = await window.api.skills.installShare({ shareId: preview.shareId, diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx index d1a6016655f..7dabac57dfe 100644 --- a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx @@ -17,6 +17,7 @@ import { } from './skill-managed-install-groups' import { translate } from '@/i18n/i18n' import { SkillInstallManagementDialogContent } from './SkillInstallManagementDialogContent' +import { createBrowserUuid } from '@/lib/browser-uuid' export function SkillInstallManagementDialog({ open, @@ -158,7 +159,7 @@ export function SkillInstallManagementDialog({ setBusy(true) setError(null) setNotice(null) - const operationId = crypto.randomUUID() + const operationId = createBrowserUuid() installProgress.begin(operationId) try { const version = details?.versions.find((candidate) => candidate.versionId === versionId) diff --git a/src/renderer/src/components/skills/use-skill-delete-flow.ts b/src/renderer/src/components/skills/use-skill-delete-flow.ts index 654cefedc6d..1ca6f6035de 100644 --- a/src/renderer/src/components/skills/use-skill-delete-flow.ts +++ b/src/renderer/src/components/skills/use-skill-delete-flow.ts @@ -22,6 +22,7 @@ import { skillDeletePlacementSummary, skillDeleteRetainedSourceLines } from './skill-delete-copy' +import { createBrowserUuid } from '@/lib/browser-uuid' export type SkillDeleteFlow = { /** False while the target is unresolved or the host predates the capability. */ @@ -38,7 +39,7 @@ export type SkillDeleteFlow = { function toRequest(skills: readonly DiscoveredSkill[]): SkillDeleteRequest { return { - operationId: crypto.randomUUID(), + operationId: createBrowserUuid(), skills: skills.map((skill) => ({ id: skill.id, directoryPath: skill.directoryPath, diff --git a/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts new file mode 100644 index 00000000000..2bfe39c2971 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN } from '../../../../shared/terminal-render-desync-evidence' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { createCaptureId } from './terminal-render-desync-evidence-persistence' + +describe('createCaptureId', () => { + // A real paneKey is two UUIDs joined by ':'. Unbounded, the id ran 124 chars and main + // rejected every capture with 'Invalid render-desync capture id'. + it('stays inside the id main will accept for a realistic paneKey', () => { + const paneKey = makePaneKey(createBrowserUuid(), createBrowserUuid()) + + expect(createCaptureId(paneKey)).toMatch(TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN) + }) + + it('keeps the leaf id so a capture is still traceable to its pane', () => { + const leafId = createBrowserUuid() + + expect(createCaptureId(makePaneKey(createBrowserUuid(), leafId))).toContain(leafId) + }) + + it('does not collide for repeated captures of the same pane', () => { + const paneKey = makePaneKey(createBrowserUuid(), createBrowserUuid()) + + expect(createCaptureId(paneKey)).not.toBe(createCaptureId(paneKey)) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts index 036f677a02b..ee3be4f4eb6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts +++ b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts @@ -1,4 +1,5 @@ import type { SentinelEvidence } from './terminal-render-desync-sentinel' +import { createBrowserUuid } from '@/lib/browser-uuid' /** * Durable persistence for render-desync captures, split from the sentinel so @@ -59,8 +60,13 @@ export async function persistHealedReference( } } +/** Why: a real paneKey is `${tabId}:${leafId}` — two UUIDs, 73 chars — which pushes the full + * id past main's 120-char cap, so every capture was rejected. The trailing leaf id is the + * identifying half, and the UUID nonce already guarantees uniqueness. */ +const MAX_CAPTURE_ID_PANE_PART_LENGTH = 40 + export function createCaptureId(paneKey: string): string { - const panePart = paneKey.replace(/[^a-zA-Z0-9_-]/g, '-') - const nonce = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + const panePart = paneKey.replace(/[^a-zA-Z0-9_-]/g, '-').slice(-MAX_CAPTURE_ID_PANE_PART_LENGTH) + const nonce = createBrowserUuid() return `${Date.now()}-${panePart}-${nonce}` } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts index ffd41665523..5e14cd962e2 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -6,6 +6,7 @@ import { selectWorkspaceCleanupGitEvidenceTargets, WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS } from './workspace-cleanup-git-evidence' +import { createBrowserUuid } from '@/lib/browser-uuid' export type WorkspaceCleanupGitEvidenceState = { /** Focused re-scan results, keyed by host-qualified identity. */ @@ -79,7 +80,7 @@ export function useWorkspaceCleanupGitEvidence({ // would silently drop the overflow ids while marking them attempted. const worktreeIds = queueRef.current.slice(0, WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS) queueRef.current = queueRef.current.slice(worktreeIds.length) - const scanId = crypto.randomUUID() + const scanId = createBrowserUuid() activeScanIdRef.current = scanId for (const worktreeId of worktreeIds) { queuedRef.current.delete(worktreeId) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts index b633f610d93..9081604e714 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts @@ -1,4 +1,5 @@ import type { WorkspaceCleanupBackgroundRemovalArgs } from './workspace-cleanup-background-removal' +import { createBrowserUuid } from '@/lib/browser-uuid' export function createWorkspaceCleanupSnapshotPruneBatch(): | WorkspaceCleanupBackgroundRemovalArgs['snapshotPruneBatch'] @@ -9,7 +10,7 @@ export function createWorkspaceCleanupSnapshotPruneBatch(): if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') { return undefined } - const batchId = crypto.randomUUID() + const batchId = createBrowserUuid() return { batchId, begin: () => begin({ batchId }), diff --git a/src/renderer/src/lib/browser-uuid.ts b/src/renderer/src/lib/browser-uuid.ts index 177d05f7ddb..017bfd13f74 100644 --- a/src/renderer/src/lib/browser-uuid.ts +++ b/src/renderer/src/lib/browser-uuid.ts @@ -1,28 +1,3 @@ -export function createBrowserUuid(): string { - const cryptoApi = globalThis.crypto - if (typeof cryptoApi?.randomUUID === 'function') { - return cryptoApi.randomUUID() - } - - const bytes = new Uint8Array(16) - if (typeof cryptoApi?.getRandomValues === 'function') { - cryptoApi.getRandomValues(bytes) - } else { - // Why: LAN web clients can run in non-secure browser contexts where - // randomUUID is hidden. These are local UI IDs, not auth credentials. - for (let index = 0; index < bytes.length; index += 1) { - bytes[index] = Math.floor(Math.random() * 256) - } - } - - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - return bytesToUuid(bytes) -} - -function bytesToUuid(bytes: Uint8Array): string { - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) - return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex - .slice(6, 8) - .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` -} +// Renderer-facing name for the shared generator. A re-export, so renderer code keeps one +// obvious import and src/shared keeps the single implementation. +export { createNonSecureContextUuid as createBrowserUuid } from '../../../shared/non-secure-context-uuid' diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 73d274a7bdc..759f5c7d614 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -21,6 +21,7 @@ import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-owner' +import { createBrowserUuid } from '@/lib/browser-uuid' export type StructuredAgentSessionLaunchIntent = { sessionId: string @@ -105,7 +106,7 @@ export function createStructuredAgentSessionLaunchIntent( agent: AgentSessionHandleProvider, resumeFrom?: StructuredAgentSessionResumeSource ): StructuredAgentSessionLaunchIntent { - const sessionId = createStructuredAgentSessionId(agent, () => crypto.randomUUID()) + const sessionId = createStructuredAgentSessionId(agent, createBrowserUuid) return buildStructuredAgentSessionLaunchIntent(worktreeId, agent, sessionId, resumeFrom) } @@ -132,7 +133,7 @@ function buildStructuredAgentSessionLaunchIntent( worktree: toRuntimeWorktreeSelector(worktreeId), agent, ...(resumeFrom ? { resumeFrom } : {}), - randomUuid: () => crypto.randomUUID() + randomUuid: createBrowserUuid }), ...launchSeedOptions(state, agent) } diff --git a/src/renderer/src/lib/non-secure-context-crypto-stub.ts b/src/renderer/src/lib/non-secure-context-crypto-stub.ts new file mode 100644 index 00000000000..0413be294b8 --- /dev/null +++ b/src/renderer/src/lib/non-secure-context-crypto-stub.ts @@ -0,0 +1,29 @@ +/** + * Test-only shape of `globalThis.crypto` on a plain-HTTP origin: getRandomValues + * survives, the secure-context-only members do not. + * + * Swapping the whole `crypto` own property is the only reversible way to do this. + * `randomUUID` lives on `Crypto.prototype`, so stubbing it as an own property of + * `globalThis.crypto` leaves nothing to restore and leaks into the rest of the file. + */ + +/** The crypto object a browser exposes on a non-secure origin. */ +export function createNonSecureContextCrypto(secureCrypto: Crypto = globalThis.crypto): { + getRandomValues: Crypto['getRandomValues'] +} { + return { getRandomValues: secureCrypto.getRandomValues.bind(secureCrypto) } +} + +/** Runs `body` with the non-secure crypto shape installed, restoring the real one after. */ +export async function withNonSecureContextCrypto(body: () => Promise | T): Promise { + const secureCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: createNonSecureContextCrypto(secureCrypto) + }) + try { + return await body() + } finally { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: secureCrypto }) + } +} diff --git a/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts b/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts index f2f1a24c558..ce16d669f16 100644 --- a/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts +++ b/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts @@ -3,18 +3,15 @@ * hides crypto.randomUUID and crypto.subtle (secure-context-only). This test * recreates that exact global shape and drives the real call sites. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createNonSecureContextCrypto } from './non-secure-context-crypto-stub' const realCrypto = globalThis.crypto beforeEach(() => { - // Match a non-secure browser context: getRandomValues stays, the - // secure-context-only members are undefined. Object.defineProperty(globalThis, 'crypto', { configurable: true, - value: { - getRandomValues: realCrypto.getRandomValues.bind(realCrypto) - } + value: createNonSecureContextCrypto(realCrypto) }) }) @@ -24,7 +21,9 @@ afterEach(() => { describe('non-secure context (plain HTTP LAN web client)', () => { it('crypto.randomUUID is undefined, like the browser reports', () => { + // oxlint-disable-next-line no-restricted-properties -- asserting the absence this suite exists for expect((globalThis.crypto as Crypto).randomUUID).toBeUndefined() + // oxlint-disable-next-line no-restricted-properties -- asserting the absence this suite exists for expect(() => (globalThis.crypto as Crypto).randomUUID()).toThrow() }) @@ -45,11 +44,33 @@ describe('non-secure context (plain HTTP LAN web client)', () => { })() Object.defineProperty(globalThis, 'crypto', { configurable: true, - value: { getRandomValues: realCrypto.getRandomValues.bind(realCrypto) } + value: createNonSecureContextCrypto(realCrypto) }) expect(await hashOrcaHookScript('echo hi')).toBe(secureHash) }) + // Regression for #19667: the store builds this sequencer at module load, so a throw here + // white-screened the whole Remote Web client before anything painted. + it('loads the renderer agent-status authority and its store slice', async () => { + vi.resetModules() + const { rendererAgentStatusObservations } = await import('./renderer-agent-status-observations') + const { createAgentStatusAuthorityActions } = + await import('../store/slices/agent-status-authority-actions') + expect(typeof createAgentStatusAuthorityActions).toBe('function') + expect(rendererAgentStatusObservations.getAuthorityId()).toMatch( + /^renderer:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + }) + + // Naming two modules only pins today's crash. The reported stack was the whole store + // chunk, so evaluate the store root: any new import-time secure-context call anywhere in + // that graph fails here. + it('evaluates the whole store graph', async () => { + vi.resetModules() + const { useAppStore } = await import('@/store') + expect(typeof useAppStore.getState).toBe('function') + }) + it('createBrowserUuid does not throw when randomUUID is missing', async () => { const { createBrowserUuid } = await import('./browser-uuid') expect(createBrowserUuid()).toMatch( diff --git a/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts index 10f332f49ac..a6ca7cc2d64 100644 --- a/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts +++ b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts @@ -1,25 +1,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import { createBrowserUuid } from '../browser-uuid' -// Why: Electron/test runtimes can lack crypto.randomUUID. The fallback still -// produces a UUID-shaped v4 id so pane-key validation remains deterministic. export function mintStablePaneId(): TerminalLeafId { - const cryptoApi = globalThis.crypto as Crypto | undefined - if (cryptoApi?.randomUUID) { - return cryptoApi.randomUUID() as TerminalLeafId - } - const bytes = new Uint8Array(16) - if (cryptoApi?.getRandomValues) { - cryptoApi.getRandomValues(bytes) - } else { - for (let i = 0; i < bytes.length; i += 1) { - bytes[i] = Math.floor(Math.random() * 256) - } - } - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( - 16, - 20 - )}-${hex.slice(20)}` as TerminalLeafId + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createBrowserUuid always returns a lowercase v4 UUID, the TerminalLeafId shape. + return createBrowserUuid() as TerminalLeafId } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index ea1f1cb18cb..c7d98e848fc 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -9,6 +9,7 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { createBrowserUuid } from '@/lib/browser-uuid' import { StructuredAgentSessionLaunchCancelledError, type StructuredAgentLaunchReceipt @@ -87,7 +88,7 @@ async function setLaunchOption( >({ kind: 'local' }, 'agentSession.setOption', { envelope: { sessionId, - clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + clientOperationId: createStructuredAgentSessionOperationId(createBrowserUuid), expectedRuntimeFence: fence, payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: 'agentSession.setOption', diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index de7f4733644..18cd3f01cf2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -13,6 +13,7 @@ import { type StructuredAgentSessionLaunchPromptMutation } from '@/components/native-chat/structured-agent-session-outbox-storage' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { createBrowserUuid } from '@/lib/browser-uuid' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -112,7 +113,7 @@ async function dispatchStructuredLaunchPrompt( requeueStructuredAgentSessionSendRefusal( current, result.refusal.code, - () => createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + () => createStructuredAgentSessionOperationId(createBrowserUuid), entry.lastAttemptAt !== null ) ) diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index 7382cc020f2..2001f658752 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -16,6 +16,7 @@ import { getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab } from './agent-status-pane-key-tab-binding' +import { createBrowserUuid } from '@/lib/browser-uuid' export function createAgentStatusAuthorityActions( runtime: AgentStatusRuntime @@ -31,7 +32,7 @@ export function createAgentStatusAuthorityActions( scheduleAgentStatusFreshness: () => freshness.schedule(), retireAgentPaneAuthority: (paneKey, options) => { - const retirementId = crypto.randomUUID() + const retirementId = createBrowserUuid() const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) const previousRetirement = get().recentlyRetiredAgentStatusPaneKeys[ownerPaneKey] const retiredPaneKeys = [ diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 8189ec5e5f9..55821c10667 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -35,6 +35,7 @@ import { import * as ownerHydration from './settings-owner-hydration-publication' import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' +import { createBrowserUuid } from '@/lib/browser-uuid' export type SettingsSlice = SettingsSearchState & { settings: GlobalSettings | null @@ -57,13 +58,6 @@ function normalizeRuntimeEnvironmentId(value: string | null | undefined): string return trimmed ? trimmed : null } -function createOpenInApplicationId(): string { - return ( - globalThis.crypto?.randomUUID?.() ?? - `open-in-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` - ) -} - function normalizeSettingsUpdates( updates: Partial, currentSettings: GlobalSettings | null @@ -97,7 +91,7 @@ function normalizeSettingsUpdates( } if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications, { - createId: createOpenInApplicationId + createId: createBrowserUuid }) } if ('disabledTuiAgents' in updates) { diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts index 49f8814ca62..efdd165910d 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts @@ -34,6 +34,7 @@ import { hasValidWorkspaceCleanupUnverifiedConsent, hasWorkspaceCleanupRiskEscalated } from './workspace-cleanup-preflight-failures' +import { createBrowserUuid } from '@/lib/browser-uuid' /** Distinct from every ExecutionHostId, so a hostless row cannot alias one. */ const UNQUALIFIED_HOST_BUCKET = Symbol('unqualified-cleanup-host') @@ -190,7 +191,7 @@ export async function preflightWorkspaceCleanupCandidates( const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) const scan = await window.api.workspaceCleanup.scan({ worktreeIds: [...chunk], - scanId: crypto.randomUUID(), + scanId: createBrowserUuid(), refreshActivity: true }) const enriched = await enrich(scan.candidates, getState()) diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts index 72438524614..d8e21f94cf5 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts @@ -22,6 +22,7 @@ import { finalizeWorkspaceCleanupScan, isLatestWorkspaceCleanupScan } from './workspace-cleanup-scan-progress' +import { createBrowserUuid } from '@/lib/browser-uuid' type SetState = ( partial: Partial | ((state: AppState) => Partial), @@ -54,7 +55,7 @@ export async function scanWorkspaceCleanup( ], // Broad scan identity belongs to this store request; caller-provided IDs // are reserved for focused scans and can collide across refresh variants. - scanId: crypto.randomUUID() + scanId: createBrowserUuid() } const scanKey = getWorkspaceCleanupScanKey(scanArgs) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 00c58e81b4b..23bff42e763 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -27,6 +27,7 @@ import { type WorkspaceCleanupRemoveOptions, type WorkspaceCleanupRemoveResult } from './workspace-cleanup-removal' +import { createBrowserUuid } from '@/lib/browser-uuid' export type { WorkspaceCleanupFailure, WorkspaceCleanupRemoveOptions, WorkspaceCleanupRemoveResult } export { enrichWorkspaceCleanupCandidates, WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY } @@ -150,7 +151,7 @@ export const createWorkspaceCleanupSlice: StateCreator byte.toString(16).padStart(2, '0')) - return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` + // Why a UUID: the attempt id must not derive from any stable repo input. + return createNonSecureContextUuid() } export function buildNestedRepoScanTelemetry(args: { diff --git a/src/shared/non-secure-context-uuid.ts b/src/shared/non-secure-context-uuid.ts new file mode 100644 index 00000000000..d9fe33e0459 --- /dev/null +++ b/src/shared/non-secure-context-uuid.ts @@ -0,0 +1,38 @@ +/** + * The one v4 UUID generator that is safe everywhere Orca's code runs. + * + * Why: browsers hide `crypto.randomUUID` outside a secure context, so a renderer served + * over plain HTTP (Remote Web on a LAN/Tailscale address) throws on any direct call — at + * module scope that white-screens the app before it paints. `getRandomValues` stays + * available there, and Node/Electron main satisfy the first branch, so this is + * runtime-agnostic rather than browser-specific. + */ +export function createNonSecureContextUuid(): string { + const cryptoApi = globalThis.crypto + // oxlint-disable-next-line no-restricted-properties -- the sanctioned escape hatch: the one guarded call every other site routes through. + if (typeof cryptoApi?.randomUUID === 'function') { + // oxlint-disable-next-line no-restricted-properties -- the sanctioned escape hatch (see above). + return cryptoApi.randomUUID() + } + + const bytes = new Uint8Array(16) + if (typeof cryptoApi?.getRandomValues === 'function') { + cryptoApi.getRandomValues(bytes) + } else { + // Why: these are local UI and correlation ids, not auth credentials. + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256) + } + } + + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return bytesToUuid(bytes) +} + +function bytesToUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex + .slice(6, 8) + .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` +} diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 5b37aadce8c..4cfa87080e2 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -1,17 +1,10 @@ import { normalizeExecutionHostId } from './execution-host' import type { ProjectGroup, ProjectGroupCreatedFrom } from './project-group-types' import type { Repo } from './repo-types' +import { createNonSecureContextUuid } from './non-secure-context-uuid' export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped' -function createProjectGroupId(): string { - const randomUUID = globalThis.crypto?.randomUUID - if (randomUUID) { - return randomUUID.call(globalThis.crypto) - } - return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}` -} - export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string { const trimmed = name.trim() return trimmed.length > 0 ? trimmed : fallback @@ -28,7 +21,7 @@ export function createProjectGroup(input: { }): ProjectGroup { const now = input.now ?? Date.now() return { - id: createProjectGroupId(), + id: createNonSecureContextUuid(), name: normalizeProjectGroupName(input.name), parentPath: input.parentPath ?? null, connectionId: input.connectionId ?? null, diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 367be99c212..43f22c9bdcd 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -6,6 +6,7 @@ import { type SetupRunnerCommandShell, type SetupRunnerShell } from './setup-runner-command' +import { createNonSecureContextUuid } from './non-secure-context-uuid' const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60 // Exported so the gate and its tests share one definition. @@ -28,11 +29,7 @@ export function resolveSetupAgentSequenceLaunchCommand( } export function createSetupAgentSequenceNonce(): string { - const cryptoApi = globalThis.crypto - if (typeof cryptoApi?.randomUUID === 'function') { - return cryptoApi.randomUUID() - } - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + return createNonSecureContextUuid() } export function createSequencedSetupAgentCommands(args: { diff --git a/src/shared/terminal-render-desync-evidence.ts b/src/shared/terminal-render-desync-evidence.ts index 1621626e187..cc0e292983a 100644 --- a/src/shared/terminal-render-desync-evidence.ts +++ b/src/shared/terminal-render-desync-evidence.ts @@ -1,3 +1,7 @@ +/** The capture id becomes a directory name under userData, so main validates it before + * use. Both sides import this so the renderer cannot mint an id main will reject. */ +export const TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,120}$/ + export type TerminalRenderDesyncEvidencePhase = 'corrupt' | 'healed' export type WriteTerminalRenderDesyncEvidenceArgs = { From 8c1379313a13a6d5fd027a760ec1e51948b7eeaa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:14:01 +0000 Subject: [PATCH 004/387] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 42f7829a90a..96d18da47ff 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 77m + + downloads: 78m @@ -15,7 +15,7 @@ downloads downloads - 77m - 77m + 78m + 78m From add99c908b410017cb040a9782f4613fbb86d24f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:54:46 -0700 Subject: [PATCH 005/387] fix(native-chat): send typed question answers as structured answers, not an option id (#22793) * fix(native-chat): send typed question answers as structured answers, not an option id A typed "Other" answer was packed into the `optionId` of agentSession.respondToQuestion, a field capped at 1024 characters, so a long answer failed with "Invalid option id" and never reached the agent. respondToQuestion now carries per-question `answers` in their own field, bounded like a typed answer, and a host advertises agent-session.question-answers.v1 when it takes them. Clients fall back to the packed option id for older hosts. The host reads either form once into a typed response, records the structured answers on the resolution (and keeps the packed form older clients read), and the Claude and Codex adapters build their reply from the typed answers before the journal commits, so an answer the agent cannot take is refused rather than recorded unanswered. Co-Authored-By: Claude * fix(native-chat): hold one answer per single-select question card Typing an answer deselects a picked option, and picking an option leaves the typed text in the field without sending it, so the card never shows two answers while sending one. Multi-select still sends picked options and typed text together. * fix(native-chat): keep keyboard tabbing from re-choosing a typed answer; accept untrimmed question ids Clicking or typing in the answer field chooses the typed answer; focus alone no longer does, so tabbing to Submit keeps the option the user picked. A question id is matched exactly by the host, so the wire no longer rejects agent-written ids with edge spaces, which older builds accepted. * fix(native-chat): choose the typed answer on click so a disabled or scrolled field cannot * test(native-chat): cover pointer events on a disabled answer field * refactor(native-chat): record the typed answer as a choice in the question card Choosing the typed answer is now an entry in the question's selection, set by typing or clicking the field and replaced by picking an option, instead of being inferred from an empty selection. Unpicking an option no longer silently chooses kept text. --------- Co-authored-by: Claude --- src/main/claude/claude-prompt-journaling.ts | 2 +- src/main/claude/claude-prompt-registry.ts | 22 +-- .../claude-structured-control-actions.test.ts | 8 +- .../claude-structured-control-actions.ts | 12 +- ...de-structured-journal-prompt-retry.test.ts | 1 - .../claude-structured-journal-prompts.ts | 2 +- ...ude-structured-journal-translation.test.ts | 1 - .../claude-structured-journal-translation.ts | 5 +- .../claude-structured-prompt-items.test.ts | 61 ++++--- ...claude-structured-prompt-ownership.test.ts | 15 +- .../claude-structured-prompt-ownership.ts | 20 ++- .../claude-structured-prompt-replies.ts | 81 +++------ .../claude-structured-session-adapter.test.ts | 74 +++++--- .../claude-structured-session-adapter.ts | 8 +- .../codex-structured-prompt-ownership.test.ts | 18 +- .../codex-structured-prompt-ownership.ts | 24 ++- .../codex-structured-prompt-replies.test.ts | 59 +++++-- .../codex/codex-structured-prompt-replies.ts | 73 +++++--- ...ructured-session-adapter-lifecycle.test.ts | 2 +- .../codex-structured-session-adapter.test.ts | 27 +-- .../structured-agent-session-adapter.ts | 16 +- ...tured-agent-session-grouped-prompt.test.ts | 65 ++++++- ...structured-agent-session-host-mutations.ts | 9 +- ...structured-agent-session-mutation-plans.ts | 14 +- .../structured-agent-session-turns-prompt.ts | 100 +++++++---- ...tured-agent-session-respond-params.test.ts | 106 +++++++++++ .../structured-agent-session-schemas.ts | 1 + .../methods/structured-agent-session.test.ts | 35 ---- .../rpc/methods/structured-agent-session.ts | 3 +- .../NativeChatQuestionCard.test.tsx | 134 ++++++++++++++ .../native-chat/NativeChatQuestionCard.tsx | 70 +++++++- .../NativeChatResolutionReceipt.test.tsx | 18 ++ .../NativeChatStructuredSession.test.tsx | 30 +++- .../NativeChatStructuredSession.tsx | 62 ++----- .../native-chat-resolution-receipt.ts | 33 ++-- ...ed-agent-session-question-answers.test.tsx | 164 ++++++++++++++++++ .../use-structured-agent-session.ts | 35 +++- .../structured-agent-session-client.ts | 10 ++ src/shared/agent-session-journal-schemas.ts | 9 + src/shared/agent-session-journal-types.ts | 6 +- src/shared/agent-session-question-answer.ts | 98 ++++++++++- src/shared/protocol-version.ts | 5 + .../rpc-params-catalog.generated.ts | 3 +- .../structured-agent-session-params.ts | 49 ++++++ ...ss-version-agent-session-wire.unit.test.ts | 37 ++++ 45 files changed, 1222 insertions(+), 405 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx diff --git a/src/main/claude/claude-prompt-journaling.ts b/src/main/claude/claude-prompt-journaling.ts index 99b163fe197..6d3bfe148cf 100644 --- a/src/main/claude/claude-prompt-journaling.ts +++ b/src/main/claude/claude-prompt-journaling.ts @@ -13,7 +13,7 @@ import { export type ClaudePromptJournalDeps = { sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + bindPromptItemId?: (journalItemId: string, promptKey: string) => void /** Prompt key → the rows it wrote, owned by the translator so a cancel can sweep them. */ promptItems: Map } diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts index 3437dba93dc..5cd2f6b1a7b 100644 --- a/src/main/claude/claude-prompt-registry.ts +++ b/src/main/claude/claude-prompt-registry.ts @@ -26,7 +26,6 @@ export type ClaudePendingPrompt = ClaudePromptPresentation & { input: Record suggestions: PermissionUpdate[] questionIds: readonly string[] - answers: Map settle: ClaudePromptSettle turnId?: string | null } @@ -43,13 +42,12 @@ export type ClaudePromptRegistration = ClaudePromptPresentation & { type PromptBinding = { address: string - questionId?: string turnId: string | null } export type ClaudePromptClaim = { readonly itemId: string - readonly found: { prompt: ClaudePendingPrompt; questionId?: string } + readonly found: { prompt: ClaudePendingPrompt } } type ClaudePromptCancellationObservation = { @@ -111,7 +109,6 @@ export class ClaudePromptRegistry { ...(registration.matchedAskRule ? { matchedAskRule: registration.matchedAskRule } : {}), ...(registration.subject ? { subject: registration.subject } : {}), questionIds: questions.map(questionId), - answers: new Map(), settle: registration.settle, turnId: registration.turnId ?? null } @@ -130,26 +127,18 @@ export class ClaudePromptRegistry { return true } - bindJournalItemId( - journalItemId: string, - promptKey: string, - questionIdForItem?: string, - turnId: string | null = null - ): void { + bindJournalItemId(journalItemId: string, promptKey: string, turnId: string | null = null): void { const prompt = this.prompts.get(promptKey) this.journalBindings.set(journalItemId, { address: promptKey, - ...(questionIdForItem ? { questionId: questionIdForItem } : {}), turnId: turnId ?? prompt?.turnId ?? null }) } - find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + find(itemId: string): { prompt: ClaudePendingPrompt } | null { const binding = this.journalBindings.get(itemId) const prompt = this.prompts.get(binding?.address ?? itemId) - return prompt - ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } - : null + return prompt ? { prompt } : null } claim(itemId: string, kind?: 'approval' | 'question'): ClaudePromptClaim | null { @@ -168,8 +157,7 @@ export class ClaudePromptRegistry { if (!binding || !prompt || binding.turnId !== turnId || this.claims.has(prompt)) { return null } - const found = { prompt, ...(binding.questionId ? { questionId: binding.questionId } : {}) } - const claim = { itemId, found } + const claim = { itemId, found: { prompt } } this.claims.set(prompt, claim) return claim } diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index f92a04a8003..a04273ac126 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -6,7 +6,7 @@ import { } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { buildClaudePromptReply, ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' @@ -214,7 +214,11 @@ describe('answerClaudePrompt', () => { if (!claim) { throw new Error('expected prompt claim') } - await answerClaudePrompt(session, claim, 'allow') + await answerClaudePrompt( + session, + claim, + buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allow' }) + ) expect(settle).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 961b9aee7ba..5e5906c4216 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,4 +1,5 @@ -import { applyClaudePromptAnswer, type ClaudePromptClaim } from './claude-structured-prompt-replies' +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { settleCancelledClaudeDispatchWaiters, @@ -83,17 +84,12 @@ export async function stopClaudeBackgroundTasks( export async function answerClaudePrompt( session: ClaudeSession, claim: ClaudePromptClaim, - optionId: string + reply: PermissionResult ): Promise { if (!session.prompts.ownsClaim(claim)) { throw new Error(`claude is no longer waiting on ${claim.itemId}`) } - const response = applyClaudePromptAnswer(claim.found, optionId) - if (response === null) { - session.prompts.releaseClaim(claim) - return - } session.prompts.forget(claim.found.prompt) - claim.found.prompt.settle(response) + claim.found.prompt.settle(reply) session.translator?.journalPrompts.resolve(claim.found.prompt.promptKey) } diff --git a/src/main/claude/claude-structured-journal-prompt-retry.test.ts b/src/main/claude/claude-structured-journal-prompt-retry.test.ts index 491152668ed..d22362467e9 100644 --- a/src/main/claude/claude-structured-journal-prompt-retry.test.ts +++ b/src/main/claude/claude-structured-journal-prompt-retry.test.ts @@ -18,7 +18,6 @@ function approval(promptKey: string): ClaudePendingPrompt { input: { command: 'git status' }, suggestions: [], questionIds: [], - answers: new Map(), settle: () => {} } } diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 0d2509723f6..73dd35071ba 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -54,7 +54,7 @@ export class ClaudeJournalPrompts { constructor( private readonly deps: { sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + bindPromptItemId?: (journalItemId: string, promptKey: string) => void questionItems?: (input: { sessionId: string prompt: Extract['prompt'] diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 574547ee731..d868b3816c4 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -869,7 +869,6 @@ function prompt( return { ...input, suggestions: [], - answers: new Map(), settle: () => {} } } diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index c18392e1aa9..16c054945a1 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -38,7 +38,7 @@ export type { ClaudeJournalTranslator } from './claude-journal-translator-contra export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + bindPromptItemId?: (journalItemId: string, promptKey: string) => void coalesceMs?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] fallbackIdPrefix?: string @@ -56,8 +56,7 @@ export function createClaudeSessionJournalTranslator( sink, fallbackIdPrefix, ...(onBackgroundTaskJournalFailure ? { onBackgroundTaskJournalFailure } : {}), - bindPromptItemId: (itemId, promptKey, questionId) => - prompts.bindJournalItemId(itemId, promptKey, questionId) + bindPromptItemId: (itemId, promptKey) => prompts.bindJournalItemId(itemId, promptKey) }) : null } diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 297bf405435..f032d4bcf8d 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema' import { MAX_TOOL_DETAIL_LENGTH } from '../../shared/native-chat-tool-summary' import { claudeApprovalItem, claudeQuestionItems } from './claude-structured-prompt-items' import { - applyClaudePromptAnswer, - encodeClaudeQuestionOptionId, + buildClaudePromptReply, type ClaudePendingPrompt } from './claude-structured-prompt-replies' @@ -24,7 +22,6 @@ function approvalPrompt( input, suggestions: [], questionIds: [], - answers: new Map(), settle: () => {}, ...presentation } @@ -121,19 +118,21 @@ describe('Claude structured approval presentation', () => { } ) - expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({ + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'deny' })).toEqual({ behavior: 'deny', message: 'User denied this action.', toolUseID: 'tool-approval' }) - expect(applyClaudePromptAnswer({ prompt }, 'allowForSession')).toEqual({ - behavior: 'allow', - updatedInput: { command: 'rm output.txt' }, - updatedPermissions: [ - { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } - ], - toolUseID: 'tool-approval' - }) + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( + { + behavior: 'allow', + updatedInput: { command: 'rm output.txt' }, + updatedPermissions: [ + { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } + ], + toolUseID: 'tool-approval' + } + ) }) it('asks Claude to revise a rejected plan while accepting legacy session replies', () => { @@ -145,16 +144,18 @@ describe('Claude structured approval presentation', () => { } ) - expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({ + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'deny' })).toEqual({ behavior: 'deny', message: 'The user asked you to keep planning. Revise the plan and call ExitPlanMode again.', toolUseID: 'tool-approval' }) - expect(applyClaudePromptAnswer({ prompt }, 'allowForSession')).toEqual({ - behavior: 'allow', - updatedInput: { plan: '# Release' }, - toolUseID: 'tool-approval' - }) + expect(buildClaudePromptReply(prompt, { kind: 'option', optionId: 'allowForSession' })).toEqual( + { + behavior: 'allow', + updatedInput: { plan: '# Release' }, + toolUseID: 'tool-approval' + } + ) }) }) @@ -178,7 +179,6 @@ describe('Claude structured question addressing', () => { input: { questions }, suggestions: [], questionIds: questions.map((question) => question.question), - answers: new Map(), settle: () => {} } @@ -211,7 +211,6 @@ describe('Claude structured question addressing', () => { input: { questions: [{ question: questionId, options: [{ label }] }] }, suggestions: [], questionIds: [questionId], - answers: new Map(), settle: () => {} } @@ -219,7 +218,12 @@ describe('Claude structured question addressing', () => { expect(agentJournalItemKey(item.identity).length).toBeLessThan(512) expect(item.body.options[0]!.id.length).toBeLessThan(512) expect(item.body.freeTextQuestionId).toBe('q1') - expect(applyClaudePromptAnswer({ prompt }, item.body.options[0]!.id)).toMatchObject({ + expect( + buildClaudePromptReply(prompt, { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [item.body.options[0]!.id] }] + }) + ).toMatchObject({ updatedInput: { answers: { [questionId]: label } } }) }) @@ -235,13 +239,15 @@ describe('Claude structured question addressing', () => { input: { questions: [{ question: questionId }] }, suggestions: [], questionIds: [questionId], - answers: new Map(), settle: () => {} } const answer = 'https://example.test:8443/path' expect( - applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer)) + buildClaudePromptReply(prompt, { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [], other: answer }] + }) ).toMatchObject({ updatedInput: { answers: { [questionId]: answer } } }) @@ -273,21 +279,20 @@ describe('Claude structured question addressing', () => { }, suggestions: [], questionIds: [multiQuestion, singleQuestion, otherQuestion], - answers: new Map(), settle: () => {} } const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! const questions = item.body.questions! - const encoded = encodeAgentSessionQuestionAnswers([ + const answers = [ { questionId: 'q1', optionIds: [questions[0]!.options[0]!.id, questions[0]!.options[1]!.id] }, { questionId: 'q2', optionIds: [questions[1]!.options[1]!.id] }, { questionId: 'q3', optionIds: [], other: 'remote host' } - ]) + ] - expect(applyClaudePromptAnswer({ prompt }, encoded)).toMatchObject({ + expect(buildClaudePromptReply(prompt, { kind: 'answers', answers })).toMatchObject({ updatedInput: { answers: { [multiQuestion]: ['frontend', 'backend'], diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 7a06a58e881..678c6a4f1e2 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -120,7 +120,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit: async () => { expect(answered.settled()).toBe(false) @@ -185,7 +185,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit }) @@ -204,7 +204,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit }) @@ -356,7 +356,6 @@ describe('Claude live prompt ownership', () => { input: { command: 'git status' }, suggestions: [], questionIds: [], - answers: new Map(), settle: vi.fn() } }) @@ -398,7 +397,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit: async () => undefined }) @@ -557,7 +556,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit }) @@ -610,7 +609,7 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 8, commit }) @@ -689,7 +688,6 @@ describe('Claude live prompt ownership', () => { }, suggestions: [], questionIds: ['First?', 'Second?'], - answers: new Map(), settle: vi.fn() } prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) @@ -728,7 +726,6 @@ describe('Claude live prompt ownership', () => { input: { command: 'git status' }, suggestions: [], questionIds: [], - answers: new Map(), settle: vi.fn() } prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index e4c9de982d0..885ea2dfa74 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -1,4 +1,5 @@ import { + AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -10,7 +11,10 @@ import { supportsClaudeQueuedInterruptCancellation } from './claude-structured-control-actions' import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' +import { buildClaudePromptReply } from './claude-structured-prompt-replies' import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudePendingPrompt } from './claude-prompt-registry' +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import { claudeStartupHoldsWrites, rejectClaudeStartupWrites @@ -202,6 +206,19 @@ export async function cancelClaudeStructuredTurn(input: { } } +function prepareClaudePromptReply( + prompt: ClaudePendingPrompt, + response: AnswerInput['response'] +): PermissionResult { + try { + return buildClaudePromptReply(prompt, response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } +} + export async function answerClaudeStructuredPrompt(input: { request: AnswerInput sessions: Map @@ -217,6 +234,7 @@ export async function answerClaudeStructuredPrompt(input: { throw new AgentSessionPromptUnavailableError(request.itemId) } try { + const reply = prepareClaudePromptReply(claim.found.prompt, request.response) await request.commit() if ( sessions.get(request.sessionId) !== session || @@ -226,7 +244,7 @@ export async function answerClaudeStructuredPrompt(input: { ) { throw new AgentSessionPromptUnavailableError(request.itemId) } - await answerClaudePrompt(session, claim, request.optionId) + await answerClaudePrompt(session, claim, reply) } catch (error) { session.prompts.releaseClaim(claim) throw error diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 96bc83d0876..3bb99e3638a 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -1,5 +1,8 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' -import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import type { + AgentSessionPromptResponse, + AgentSessionQuestionAnswer +} from '../../shared/agent-session-question-answer' import { claudePromptQuestions, isClaudePromptRecord, @@ -22,12 +25,6 @@ function isClaudeApprovalDecision(optionId: string): optionId is ClaudeApprovalD return CLAUDE_APPROVAL_DECISIONS.some((decision) => decision === optionId) } -function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { - const match = /^q([1-9]\d*)$/.exec(address) - const index = match ? Number(match[1]) - 1 : -1 - return index >= 0 ? (prompt.questionIds[index] ?? null) : null -} - function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string { const decoded = decodeClaudeQuestionOptionId(optionId) if (!decoded) { @@ -111,49 +108,8 @@ function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Permis function questionResponse( prompt: ClaudePendingPrompt, - optionId: string, - boundQuestionId?: string -): PermissionResult | null { - const decoded = decodeClaudeQuestionOptionId(optionId) - const decodedQuestionId = decoded - ? (questionIdFromAddress(prompt, decoded.questionId) ?? - (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) - : null - const selectedQuestionId = - boundQuestionId ?? - decodedQuestionId ?? - (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) - if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { - throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) - } - const answer = questionAnswer(prompt, selectedQuestionId, optionId) - prompt.answers.set(selectedQuestionId, answer) - if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { - return null - } - const answers: Record = {} - for (const id of prompt.questionIds) { - const answer = prompt.answers.get(id) - if (answer === undefined) { - return null - } - answers[id] = answer - } - return { - behavior: 'allow', - updatedInput: { ...prompt.input, answers }, - toolUseID: prompt.toolUseId - } -} - -function groupedQuestionResponse( - prompt: ClaudePendingPrompt, - optionId: string -): PermissionResult | null { - const grouped = decodeAgentSessionQuestionAnswers(optionId) - if (!grouped) { - return null - } + grouped: readonly AgentSessionQuestionAnswer[] +): PermissionResult { const questions = claudePromptQuestions(prompt.input) if (grouped.length !== prompt.questionIds.length) { throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) @@ -191,15 +147,20 @@ function groupedQuestionResponse( } } -export function applyClaudePromptAnswer( - found: { prompt: ClaudePendingPrompt; questionId?: string }, - optionId: string -): PermissionResult | null { - if (found.prompt.kind === 'approval') { - return approvalResponse(found.prompt, optionId) +/** Builds Claude's reply without touching the prompt, so a reply that cannot be built refuses the + * answer before anything is recorded. */ +export function buildClaudePromptReply( + prompt: ClaudePendingPrompt, + response: AgentSessionPromptResponse +): PermissionResult { + if (prompt.kind === 'approval') { + if (response.kind !== 'option') { + throw new Error(`Claude prompt ${prompt.promptKey} takes a decision, not answers`) + } + return approvalResponse(prompt, response.optionId) } - return ( - groupedQuestionResponse(found.prompt, optionId) ?? - questionResponse(found.prompt, optionId, found.questionId) - ) + if (response.kind !== 'answers') { + throw new Error(`Claude prompt ${prompt.promptKey} takes answers, not a decision`) + } + return questionResponse(prompt, response.answers) } diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index a879878436a..04bc6d5ffff 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -3,12 +3,12 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRootExitObservedError + AgentSessionAcquisitionRootExitObservedError, + AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' -import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -780,7 +780,7 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'journal-approval', kind: 'approval', - optionId: 'allowForSession', + response: { kind: 'option', optionId: 'allowForSession' }, fence: 7, commit: async () => undefined }) @@ -793,7 +793,7 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { }) }) - it('collects every AskUserQuestion card before settling the one callback', async () => { + it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { const claude = fakeClaude() const adapter = await acquired(claude) const answered = invokeCanUseTool( @@ -810,34 +810,68 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { } } ) - adapter.bindPromptItemId('session-1', 'journal-q1', 'question-1', 'Library?') - adapter.bindPromptItemId('session-1', 'journal-q2', 'question-1', 'Ship now?') + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const typed = 'Wait for the capture to finish first. '.repeat(60) await adapter.answerPrompt({ sessionId: 'session-1', - itemId: 'journal-q1', + itemId: 'journal-question', kind: 'question', - optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), - fence: 7, - commit: async () => undefined - }) - await tick() - expect(answered.settled()).toBe(false) - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-q2', - kind: 'question', - optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), + response: { + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: typed } + ] + }, fence: 7, commit: async () => undefined }) await expect(answered.promise).resolves.toMatchObject({ behavior: 'allow', - updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': 'Yes' } }, + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, toolUseID: 'tool-question' }) }) + it('refuses answers Claude cannot take before the journal commits them', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } + ) + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const commit = vi.fn(async () => undefined) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'option', optionId: 'allow' }, + fence: 7, + commit + }) + ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) + expect(commit).not.toHaveBeenCalled() + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, + fence: 7, + commit + }) + await expect(answered.promise).resolves.toMatchObject({ + updatedInput: { answers: { 'Library?': 'Luxon' } } + }) + }) + it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] @@ -859,7 +893,7 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'journal-9', kind: 'approval', - optionId: 'allow', + response: { kind: 'option', optionId: 'allow' }, fence: 7, commit: async () => undefined }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 835757dc730..e9e233e4934 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -169,17 +169,11 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } } - bindPromptItemId( - sessionId: string, - journalItemId: string, - promptKey: string, - questionId?: string - ): void { + bindPromptItemId(sessionId: string, journalItemId: string, promptKey: string): void { const session = this.sessions.get(sessionId) session?.prompts.bindJournalItemId( journalItemId, promptKey, - questionId, session.translator?.currentTurnId ?? null ) } diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index cfb77e628a7..ecdc4c9a4be 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -159,7 +159,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => { expect(codex.connections[0]?.replies).toEqual([]) @@ -210,7 +210,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -224,7 +224,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -237,7 +237,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 8, commit }) @@ -266,7 +266,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: 'journal-prompt', kind: 'approval', - optionId: 'decline', + response: { kind: 'option', optionId: 'decline' }, fence: 7, commit: async () => undefined }) @@ -382,7 +382,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: siblingItemId, kind: 'question', - optionId: 'no', + response: { kind: 'answers', answers: [{ questionId: 'q2', optionIds: [], other: 'no' }] }, fence: 7, commit: async () => undefined }) @@ -497,7 +497,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -545,7 +545,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) @@ -587,7 +587,7 @@ describe('Codex live prompt ownership', () => { sessionId: 'session-1', itemId: promptItemId, kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 28d060d955f..d58f08e731d 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -1,9 +1,15 @@ import { + AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { + answerCodexPrompt, + prepareCodexPromptAnswer, + type CodexPendingPrompt, + type CodexPreparedAnswer +} from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' @@ -69,6 +75,19 @@ export async function cancelCodexStructuredTurn(input: { } } +function prepareCodexAnswer( + prompt: CodexPendingPrompt, + response: AnswerInput['response'] +): CodexPreparedAnswer { + try { + return prepareCodexPromptAnswer(prompt, response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } +} + export async function answerCodexStructuredPrompt(input: { request: AnswerInput sessions: Map @@ -84,6 +103,7 @@ export async function answerCodexStructuredPrompt(input: { throw new AgentSessionPromptUnavailableError(request.itemId) } try { + const prepared = prepareCodexAnswer(claim.prompt, request.response) await request.commit() if ( sessions.get(request.sessionId) !== session || @@ -95,7 +115,7 @@ export async function answerCodexStructuredPrompt(input: { throw new AgentSessionPromptUnavailableError(request.itemId) } session.translator?.resolvePrompt(request.itemId) - answerCodexPrompt(session.prompts, session.connection, claim, request.optionId) + answerCodexPrompt(session.prompts, session.connection, claim, prepared) } catch (error) { session.prompts.releaseClaim(claim) throw error diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index e575f27632b..9f17a3efb4c 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import { applyCodexPromptAnswer, CodexPromptRegistry, + prepareCodexPromptAnswer, + type CodexPendingPrompt, MAX_CODEX_PROMPT_REGISTRY_BYTES, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, codexJournalPromptIdPart, @@ -11,6 +14,29 @@ import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' +function picked(optionId: string): AgentSessionPromptResponse { + return { kind: 'answers', answers: [{ questionId: 'q1', optionIds: [optionId] }] } +} + +function typed(questionId: string, other: string): AgentSessionPromptResponse { + return { kind: 'answers', answers: [{ questionId, optionIds: [], other }] } +} + +function registered(prompt: CodexPendingPrompt | null): CodexPendingPrompt { + if (!prompt) { + throw new Error('expected the request to register') + } + return prompt +} + +function answer( + prompt: CodexPendingPrompt | null, + response: AgentSessionPromptResponse +): Record | null { + const live = registered(prompt) + return applyCodexPromptAnswer(live, prepareCodexPromptAnswer(live, response)) +} + function userInputRequest(questionIds: string[]): { id: number method: string @@ -59,7 +85,7 @@ describe('codex question option ids', () => { expect(Buffer.byteLength(optionId, 'utf8')).toBeLessThan(1024) expect(codexJournalPromptIdPart(longQuestionId)).not.toBe(longQuestionId) - expect(applyCodexPromptAnswer(prompt as NonNullable, optionId)).toEqual({ + expect(answer(prompt, picked(optionId))).toEqual({ answers: { [longQuestionId]: { answers: [longAnswer] } } }) }) @@ -219,11 +245,11 @@ describe('CodexPromptRegistry', () => { }) describe('applyCodexPromptAnswer', () => { - it('accepts a bare answer only when the request has one question', () => { + it('answers the lone question of a single-question request with typed text', () => { const registry = new CodexPromptRegistry() const single = registry.register(userInputRequest(['q1'])) - expect(applyCodexPromptAnswer(single as NonNullable, 'sure')).toEqual({ + expect(answer(single, typed('q1', 'sure'))).toEqual({ answers: { q1: { answers: ['sure'] } } }) }) @@ -232,29 +258,32 @@ describe('applyCodexPromptAnswer', () => { const registry = new CodexPromptRegistry() const many = registry.register(userInputRequest(['q1', 'q2'])) - expect(() => applyCodexPromptAnswer(many as NonNullable, 'sure')).toThrow( - 'does not name a question' - ) - expect(() => - applyCodexPromptAnswer( - many as NonNullable, - encodeCodexQuestionOptionId('q3', 'sure') - ) - ).toThrow('does not name a question') + expect(() => answer(many, picked('sure'))).toThrow('does not name a question') + expect(() => answer(many, typed('q3', 'sure'))).toThrow('does not name a question') }) it('keeps the last answer when a question is answered twice', () => { const registry = new CodexPromptRegistry() const single = registry.register(userInputRequest(['q1'])) - const prompt = single as NonNullable - applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'first')) + answer(single, typed('q1', 'first')) - expect(applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'second'))).toEqual({ + expect(answer(single, typed('q1', 'second'))).toEqual({ answers: { q1: { answers: ['second'] } } }) }) + it('refuses an answer over the registry bound before recording anything', () => { + const registry = new CodexPromptRegistry() + const single = registry.register(userInputRequest(['q1'])) + const live = registered(single) + + expect(() => prepareCodexPromptAnswer(live, typed('q1', 'x'.repeat(64 * 1024 + 1)))).toThrow( + 'exceeds bounded registry state' + ) + expect(live.answers.size).toBe(0) + }) + it('refuses question and option collections that exceed bounded live state', () => { const registry = new CodexPromptRegistry() const tooManyQuestions = registry.register( diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index 6e742bf827c..d4301d8f27c 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -1,3 +1,4 @@ +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CODEX_PROMPT_MAX_ANSWER_BYTES } from './codex-prompt-registry-bounds' import { @@ -55,35 +56,61 @@ export function decodeCodexQuestionOptionId( } } +/** One answer, checked against the prompt but not yet recorded on it. */ +export type CodexPreparedAnswer = + | { kind: 'decision'; decision: CodexApprovalDecision } + | { kind: 'answer'; questionId: string; answer: string } + +/** Validates a client's choice against the prompt without recording it, so an answer Codex + * cannot take is refused before the journal commits it. */ +export function prepareCodexPromptAnswer( + prompt: CodexPendingPrompt, + response: AgentSessionPromptResponse +): CodexPreparedAnswer { + if (prompt.method !== CODEX_USER_INPUT_METHOD) { + if (response.kind !== 'option' || !isCodexApprovalDecision(response.optionId)) { + throw new Error(`Codex item ${prompt.codexItemId} takes an approval decision`) + } + return { kind: 'decision', decision: response.optionId } + } + // Each Codex question is its own journal item, so an answer names exactly one question. + const entry = + response.kind === 'answers' && response.answers.length === 1 ? response.answers[0] : null + if (!entry) { + throw new Error(`Codex item ${prompt.codexItemId} takes one question answer`) + } + const optionId = entry.optionIds[0] + const decoded = + optionId === undefined + ? { questionId: entry.questionId, answer: entry.other?.trim() ?? '' } + : (prompt.optionAnswers.get(optionId) ?? decodeCodexQuestionOptionId(optionId)) + const questionId = + (decoded?.questionId + ? (prompt.questionIdAliases.get(decoded.questionId) ?? decoded.questionId) + : null) ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + const answer = decoded?.answer ?? optionId ?? '' + if (!questionId || !prompt.questionIds.includes(questionId)) { + throw new Error(`The answer does not name a question on Codex item ${prompt.codexItemId}`) + } + if (Buffer.byteLength(answer, 'utf8') > CODEX_PROMPT_MAX_ANSWER_BYTES) { + throw new Error('codex prompt answer exceeds bounded registry state') + } + return { kind: 'answer', questionId, answer } +} + /** - * Records one answer and returns the reply payload once the request is fully + * Records one prepared answer and returns the reply payload once the request is fully * answered. A multi-question user-input request stays pending until every * question has an answer, because Codex takes one reply for all of them. */ export function applyCodexPromptAnswer( prompt: CodexPendingPrompt, - optionId: string + prepared: CodexPreparedAnswer ): Record | null { - if (prompt.method !== CODEX_USER_INPUT_METHOD) { - if (!isCodexApprovalDecision(optionId)) { - throw new Error(`${optionId} is not a Codex approval decision`) - } - return { decision: optionId } + if (prepared.kind === 'decision') { + return { decision: prepared.decision } } - const mapped = prompt.optionAnswers.get(optionId) - const decoded = mapped ?? decodeCodexQuestionOptionId(optionId) - const questionId = - (decoded?.questionId - ? (prompt.questionIdAliases.get(decoded.questionId) ?? decoded.questionId) - : null) ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) - const answer = decoded?.answer ?? optionId - if (!questionId || !prompt.questionIds.includes(questionId)) { - throw new Error(`${optionId} does not name a question on Codex item ${prompt.codexItemId}`) - } - if (Buffer.byteLength(answer, 'utf8') > CODEX_PROMPT_MAX_ANSWER_BYTES) { - throw new Error('codex prompt answer exceeds bounded registry state') - } - prompt.answers.set(questionId, answer) + prompt.answers.set(prepared.questionId, prepared.answer) if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { return null } @@ -104,13 +131,13 @@ export function answerCodexPrompt( registry: CodexPromptRegistry, connection: Pick, claim: CodexPromptClaim, - optionId: string + prepared: CodexPreparedAnswer ): void { if (!registry.ownsClaim(claim)) { throw new Error(`codex app-server is no longer waiting on ${claim.itemId}`) } const prompt = claim.prompt - const reply = applyCodexPromptAnswer(prompt, optionId) + const reply = applyCodexPromptAnswer(prompt, prepared) if (reply === null) { registry.releaseClaim(claim) return diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index fc458304dc8..6389cbe70d5 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -152,7 +152,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { sessionId: 'session-2', itemId: 'codex-item-1', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 1, commit: async () => undefined }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index f17003f7e40..2dd4b8d78af 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { CodexAppServerRequestError, type openCodexAppServerConnection @@ -6,7 +7,6 @@ import { import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' -import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, @@ -174,7 +174,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { sessionId: 'session-1', itemId: 'codex-item-early', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) @@ -535,7 +535,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) @@ -548,7 +548,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', - optionId: 'decline', + response: { kind: 'option', optionId: 'decline' }, fence: 7, commit: async () => undefined }) @@ -589,7 +589,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-1', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) @@ -676,7 +676,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId, kind: 'approval', - optionId, + response: { kind: 'option', optionId }, fence: 7, commit: async () => undefined }) @@ -697,17 +697,20 @@ describe('CodexStructuredSessionAdapter prompts', () => { const adapter = await acquired(codex) askApproval(codex) + const commit = vi.fn(async () => undefined) await expect( adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-1', kind: 'approval', - optionId: 'yolo', + response: { kind: 'option', optionId: 'yolo' }, fence: 7, - commit: async () => undefined + commit }) - ).rejects.toThrow('is not a Codex approval decision') + ).rejects.toThrow(AgentSessionPromptAnswerRejectedError) + // Refused before the journal records an answer the agent never receives. + expect(commit).not.toHaveBeenCalled() expect(codex.connections[0].replies).toEqual([]) }) @@ -732,7 +735,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-2', kind: 'question', - optionId: encodeCodexQuestionOptionId('q1', 'yes'), + response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'yes' }] }, fence: 7, commit: async () => undefined }) @@ -742,7 +745,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-2', kind: 'question', - optionId: encodeCodexQuestionOptionId('q2', 'no'), + response: { kind: 'answers', answers: [{ questionId: 'q2', optionIds: [], other: 'no' }] }, fence: 7, commit: async () => undefined }) @@ -778,7 +781,7 @@ describe('CodexStructuredSessionAdapter prompts', () => { sessionId: 'session-1', itemId: 'codex-item-gone', kind: 'approval', - optionId: 'accept', + response: { kind: 'option', optionId: 'accept' }, fence: 7, commit: async () => undefined }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 7085700cdcb..27cd3c30240 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -31,6 +31,7 @@ import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' @@ -52,6 +53,14 @@ export class AgentSessionPromptUnavailableError extends Error { } } +/** The provider cannot take this answer. Thrown before the journal commit, so nothing is recorded. */ +export class AgentSessionPromptAnswerRejectedError extends Error { + constructor(message: string) { + super(message) + this.name = 'AgentSessionPromptAnswerRejectedError' + } +} + /** * The provider's own root process was observed to exit, but its descendant tree * could not be verified. The lease keys on the root's pid and start time, so its @@ -263,13 +272,14 @@ export type StructuredAgentSessionAdapter = { /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined - /** Claims the live callback, commits the journal CAS while that claim is held, then answers it. - * A prompt cancel claims the same callback, so only one operation can commit. */ + /** Claims the live callback, builds the provider reply, commits the journal CAS while that claim is + * held, then answers it. A reply that cannot be built throws `AgentSessionPromptAnswerRejectedError` + * before the commit. A prompt cancel claims the same callback, so only one operation can commit. */ answerPrompt(input: { sessionId: string itemId: string kind: 'approval' | 'question' - optionId: string + response: AgentSessionPromptResponse fence: number commit: () => Promise }): Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index bbc1ec49d4c..013f4191c30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -141,23 +141,78 @@ afterEach(async () => { }) describe('grouped question admission', () => { - it('admits renderer question-group payloads with child ids and multi-select answers', async () => { + it('reads the packed answer an older client sends into structured answers', async () => { const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() - const optionId = encodeAgentSessionQuestionAnswers([ + const answers = [ { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) + ] + const optionId = encodeAgentSessionQuestionAnswers(answers) const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:question', fields), kind: 'question', ...fields }) - expect(result).toMatchObject({ ok: true, value: { resolution: { state: 'resolved' } } }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } + }) expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ itemId: prompt.itemId, optionId }) + expect.objectContaining({ itemId: prompt.itemId, response: { kind: 'answers', answers } }) ) }) + + it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { + const attached = await host.attach(CALLER, attachParams()) + expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() + const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) + const answers = [ + { questionId: 'q1', optionIds: ['target-web'] }, + { questionId: 'q2', optionIds: [], other: typed } + ] + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + + expect(typed.length).toBeGreaterThan(1024) + expect(result).toMatchObject({ + ok: true, + value: { + resolution: { + state: 'resolved', + selectedOptionId: encodeAgentSessionQuestionAnswers(answers), + answers + } + } + }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ response: { kind: 'answers', answers } }) + ) + }) + + it('refuses answers that do not match the questions without reaching the provider', async () => { + const attached = await host.attach(CALLER, attachParams()) + expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() + const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(answerPrompt).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 364f1e7c0dd..f6a5770cfac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -22,6 +22,7 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { admitAndRunAgentSessionMutation, @@ -136,13 +137,7 @@ export function cancelStructuredAgentSessionTurn( export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { - envelope: AgentSessionMutationEnvelope - kind: 'approval' | 'question' - itemId: string - expectedRevision: number - optionId: string - } + params: AgentSessionPromptRequest & { envelope: AgentSessionMutationEnvelope } ): Promise> { return mutate(context, caller, params.envelope, promptPlan(params)) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index d8d2876d272..226881d38bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -23,6 +23,7 @@ import { type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' +import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' export type MutationPlan = { method: string @@ -120,18 +121,17 @@ export function cancelPlan(params: { } } -export function promptPlan(params: { - kind: 'approval' | 'question' - itemId: string - expectedRevision: number - optionId: string -}): MutationPlan { +export function promptPlan( + params: AgentSessionPromptRequest +): MutationPlan { return { method: `agentSession.respondTo:${params.kind}`, + // The client hashes exactly what it sent; the absent one of these two drops out of the digest. fields: { itemId: params.itemId, expectedRevision: params.expectedRevision, - optionId: params.optionId + optionId: params.optionId, + answers: params.answers }, run: (ctx) => performPrompt(ctx, params), replay: (ctx) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 6ac29cab0ab..4cacf96ba89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -1,51 +1,84 @@ import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { - decodeAgentSessionQuestionAnswers, - isValidAgentSessionQuestionAnswers + agentSessionPromptQuestions, + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers, + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse, + type AgentSessionQuestionAnswer } from '../../../shared/agent-session-question-answer' -import type { AgentJournalResolution } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalApprovalItem, + AgentJournalQuestionItem, + AgentJournalResolution +} from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' -import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' -import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from './structured-agent-session-adapter' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +export type AgentSessionPromptRequest = { + itemId: string + expectedRevision: number + kind: 'approval' | 'question' + /** A decision id; or, from a client that predates `answers`, a question answer packed into one id. */ + optionId?: string + answers?: AgentSessionQuestionAnswer[] +} + function invalid(message: string): TurnOutcome { return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } } +/** The one place a client's choice is read; an answer an older client packed into `optionId` is unpacked here, once. */ +function readPromptChoice( + prompt: AgentJournalApprovalItem | AgentJournalQuestionItem, + input: AgentSessionPromptRequest +): { response: AgentSessionPromptResponse; selectedOptionId: string } | null { + if (prompt.kind === 'approval') { + const optionId = input.optionId + return optionId !== undefined && prompt.options.some((option) => option.id === optionId) + ? { response: { kind: 'option', optionId }, selectedOptionId: optionId } + : null + } + const answers = + input.answers ?? + (input.optionId === undefined + ? null + : legacyAgentSessionQuestionAnswers(prompt, input.optionId)) + if ( + !answers || + !isValidAgentSessionQuestionAnswers(agentSessionPromptQuestions(prompt), answers) + ) { + return null + } + const selectedOptionId = legacyAgentSessionSelectedOptionId(prompt, answers) + return selectedOptionId === null + ? null + : { response: { kind: 'answers', answers }, selectedOptionId } +} + export async function performPrompt( ctx: AgentSessionTurnContext, - input: { - itemId: string - expectedRevision: number - optionId: string - kind: 'approval' | 'question' - } + input: AgentSessionPromptRequest ): Promise> { const validated = validatePendingPrompt(ctx, input) if (!validated.ok) { return validated } const { prompt } = validated - const question = prompt.kind === 'question' ? prompt : null - const freeText = decodeCodexQuestionOptionId(input.optionId) - const acceptsFreeText = - question?.freeTextQuestionId !== undefined && - freeText?.questionId === question.freeTextQuestionId && - freeText.answer.trim().length > 0 - const grouped = question?.questions ? decodeAgentSessionQuestionAnswers(input.optionId) : null - const acceptsGrouped = - grouped !== null && - question?.questions !== undefined && - isValidAgentSessionQuestionAnswers(question.questions, grouped) - if ( - !acceptsFreeText && - !acceptsGrouped && - !prompt.options.some((option) => option.id === input.optionId) - ) { - return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + const choice = readPromptChoice(prompt, input) + if (!choice) { + return invalid( + input.optionId !== undefined + ? `Option ${input.optionId} is not offered by item ${input.itemId}.` + : `The answers do not match the questions on item ${input.itemId}.` + ) } + const { response } = choice const identity = parseAgentJournalItemKey(input.itemId) if (!identity) { return invalid(`Item id ${input.itemId} is not a well-formed item key.`) @@ -53,7 +86,8 @@ export async function performPrompt( const resolution: AgentJournalResolution = { state: 'resolved', - selectedOptionId: input.optionId, + selectedOptionId: choice.selectedOptionId, + ...(response.kind === 'answers' ? { answers: response.answers } : {}), resolvedBy: ctx.resolvedBy, resolvedAt: ctx.now() } @@ -63,7 +97,7 @@ export async function performPrompt( sessionId: ctx.sessionId, itemId: input.itemId, kind: input.kind, - optionId: input.optionId, + response, fence: ctx.fence, commit: async () => { committed.item = await ctx.journal.appendItem( @@ -77,7 +111,11 @@ export async function performPrompt( } }) } catch (error) { - if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { + if ( + !committed.item && + (error instanceof AgentSessionPromptUnavailableError || + error instanceof AgentSessionPromptAnswerRejectedError) + ) { return invalid(error.message) } if (!committed.item) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts new file mode 100644 index 00000000000..3eb7ad3db61 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-respond-params.test.ts @@ -0,0 +1,106 @@ +// Wire bounds for prompt responses: a decision id for an approval, structured answers for a question. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + call, + clearStructuredHostStub, + envelope, + hostCalls, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) + +afterEach(() => { + clearStructuredHostStub() +}) + +describe('prompt response parameters', () => { + const rejects = async (method: string, params: unknown): Promise => { + const response = await call(method, params, STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + + it('accepts the maximum fully encoded Claude choice group and retains a finite bound', async () => { + const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({ + questionId: `q${questionIndex + 1}`, + optionIds: Array.from( + { length: 4 }, + (_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}` + ) + })) + const optionId = `question-group:${encodeURIComponent(JSON.stringify(maximumSelections))}` + expect(optionId.length).toBe(610) + + const response = await call( + 'agentSession.respondToQuestion', + { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ optionId }) + ) + + await rejects('agentSession.respondToQuestion', { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'x'.repeat(1025) + }) + }) + + it('takes a long typed answer as structured answers and bounds each field', async () => { + const answers = [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: 'Proceed with the replacement. '.repeat(100) } + ] + const response = await call( + 'agentSession.respondToQuestion', + { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, answers }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ kind: 'question', answers }) + ) + + const base = { envelope: envelope(), itemId: 'item-1', expectedRevision: 1 } + await rejects('agentSession.respondToQuestion', base) + await rejects('agentSession.respondToQuestion', { ...base, optionId: 'q1:choice-1', answers }) + await rejects('agentSession.respondToQuestion', { + ...base, + answers: [{ questionId: 'q1', optionIds: [], other: 'x'.repeat(64 * 1024 + 1) }] + }) + await rejects('agentSession.respondToQuestion', { + ...base, + answers: [{ questionId: 'q1', optionIds: [], other: 'é'.repeat(40 * 1024) }] + }) + await rejects('agentSession.respondToApproval', { ...base, answers }) + await rejects('agentSession.respondToApproval', { ...base, optionId: 'x'.repeat(1025) }) + }) + + it('takes a question id exactly as the agent wrote it, including edge spaces', async () => { + const answers = [{ questionId: 'scope ', optionIds: [], other: 'mine' }] + const response = await call( + 'agentSession.respondToQuestion', + { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, answers }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ kind: 'question', answers }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 9daf97d55af..345fdf5bf47 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -17,6 +17,7 @@ export { MutationEnvelope, OptionsParams, RespondParams, + RespondToQuestionParams, RestartResumableParams, RestartResumeParams, RewindParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 2d2760832ad..38b55fd70eb 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -783,41 +783,6 @@ describe('parameter validation', () => { }) }) - it('accepts the maximum fully encoded Claude choice group and retains a finite bound', async () => { - const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({ - questionId: `q${questionIndex + 1}`, - optionIds: Array.from( - { length: 4 }, - (_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}` - ) - })) - const optionId = `question-group:${encodeURIComponent(JSON.stringify(maximumSelections))}` - expect(optionId.length).toBe(610) - - const response = await call( - 'agentSession.respondToQuestion', - { - envelope: envelope(), - itemId: 'item-1', - expectedRevision: 1, - optionId - }, - STRUCTURED_CLIENT - ) - expect(response).toMatchObject({ ok: true }) - expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ optionId }) - ) - - await rejects('agentSession.respondToQuestion', { - envelope: envelope(), - itemId: 'item-1', - expectedRevision: 1, - optionId: 'x'.repeat(1025) - }) - }) - it('bounds a history page and validates its cursor', async () => { await rejects('agentSession.history', { sessionId: SESSION, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 66d44d4b782..ebff420cb00 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -56,6 +56,7 @@ import { HandoffStatusParams, OptionsParams, RespondParams, + RespondToQuestionParams, RewindParams, SendParams, SetOptionParams, @@ -222,7 +223,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }), defineMethod({ name: 'agentSession.respondToQuestion', - params: RespondParams, + params: RespondToQuestionParams, handler: async (params, ctx) => requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) }), diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index 9b1a2967682..189815f2478 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -69,6 +69,21 @@ function clickAction(text: string): void { click(button, text) } +function typeAnswer(value: string): void { + const input = container.querySelector('input')! + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setter.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +function optionPressed(label: string): string | null | undefined { + return [...container.querySelectorAll('button[aria-pressed]')] + .find((b) => b.textContent?.includes(label)) + ?.getAttribute('aria-pressed') +} + const tabsOrSpaces: AskPrompt = { questions: [ { @@ -222,4 +237,123 @@ describe('NativeChatQuestionCard', () => { { indices: [], other: 'SSH host' } ]) }) + + it('replaces a picked option with a typed answer on a single-select question', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + clickOption('Spaces') + typeAnswer('two spaces') + expect(optionPressed('Spaces')).toBe('false') + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'two spaces' }]) + }) + + it('keeps typed text in the field but sends a later-picked option', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + typeAnswer('two spaces') + clickOption('Tabs') + expect(container.querySelector('input')!.value).toBe('two spaces') + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [0], other: '' }]) + }) + + it('chooses the kept typed text again when its field is clicked', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + typeAnswer('two spaces') + clickOption('Tabs') + act(() => { + container.querySelector('input')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(optionPressed('Tabs')).toBe('false') + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'two spaces' }]) + }) + + it('ignores a click on the answer field while the answer is sending', () => { + const renderCard = (isSubmitting: boolean): void => { + act(() => { + root.render( + {}} + isSubmitting={isSubmitting} + /> + ) + }) + } + renderCard(false) + typeAnswer('two spaces') + clickOption('Tabs') + renderCard(true) + // Chromium still delivers pointer events to a disabled input. + const input = container.querySelector('input')! + act(() => { + input.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + input.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(optionPressed('Tabs')).toBe('true') + }) + + it('keeps the picked option when keyboard focus passes through the field', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + typeAnswer('two spaces') + clickOption('Tabs') + act(() => { + container.querySelector('input')!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + }) + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [0], other: '' }]) + }) + + it('leaves nothing chosen when a picked option is unpicked over kept text', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + + typeAnswer('two spaces') + clickOption('Tabs') + clickOption('Tabs') + + expect(optionPressed('Tabs')).toBe('false') + expect( + [...container.querySelectorAll('button')].some((b) => b.textContent?.trim() === 'Skip') + ).toBe(true) + expect(onAnswer).not.toHaveBeenCalled() + }) + + it('sends picked options and typed text together on a multi-select question', () => { + const onAnswer = vi.fn() + render( + { + questions: [ + { + question: 'Which targets?', + multiSelect: true, + options: [{ label: 'Web' }, { label: 'Mobile' }] + } + ] + }, + onAnswer + ) + + clickOption('Mobile') + typeAnswer('Desktop') + typeAnswer('') + typeAnswer('Desktop app') + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: 'Desktop app' }]) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 0167fbb61f3..e26131d96f0 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -18,11 +18,16 @@ export type NativeChatQuestionCardProps = { answerInputRef?: RefObject } +// Selection entry for the typed answer (never a real option index), so a single-select +// question holds exactly one choice: an option or the typed answer. +const TYPED_ANSWER = -1 + /** * Native renderer for an agent's AskUserQuestion prompt: a numbered pick-list * (mobile/Claude-Code parity) with a header + close, a hover-highlighted row per * option, and an optional free-text row for a custom answer. Single-select - * commits on click; multi-select toggles and confirms via the trailing action. + * holds one answer (an option or the typed text, whichever was chosen last); + * multi-select toggles options, adds any typed text, and confirms via the trailing action. * Multi-question prompts step through tabs across the top. Neutral shadcn tokens. */ export function NativeChatQuestionCard({ @@ -44,29 +49,55 @@ export function NativeChatQuestionCard({ const q = prompt.questions[index]! const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther + // Picking an option replaces a chosen typed answer on single-select; the text stays in + // the field, unsent, until the user types or clicks there again. + const typedAnswerChosen = (qi: number, sel = selections, oth = otherText): boolean => + (sel[qi] ?? []).includes(TYPED_ANSWER) && (oth[qi] ?? '').trim().length > 0 + + const chooseTypedAnswer = (qi: number): void => { + setSelections((prev) => { + const cur = prev[qi] ?? [] + if (cur.includes(TYPED_ANSWER)) { + return prev + } + const chosen = prompt.questions[qi]?.multiSelect ? [...cur, TYPED_ANSWER] : [TYPED_ANSWER] + return prev.map((s, i) => (i === qi ? chosen : s)) + }) + } + + const pickedOptions = (qi: number, sel = selections): number[] => + (sel[qi] ?? []).filter((choice) => choice !== TYPED_ANSWER) + const setOther = (qi: number, value: string): void => { setOtherText((prev) => { const next = [...prev] next[qi] = value return next }) + if (value.trim().length > 0) { + chooseTypedAnswer(qi) + } } - // The resolved answer for a question: picked labels plus any typed free-text. + // The resolved answer for a question: picked labels plus the typed answer when chosen. const answerFor = (qi: number, sel = selections, oth = otherText): string => { const question = prompt.questions[qi] - const picked = (sel[qi] ?? []) + const picked = pickedOptions(qi, sel) .map((optionIndex) => question?.options[optionIndex]?.label ?? '') .filter((label) => label.length > 0) - const other = (oth[qi] ?? '').trim() + const other = typedAnswerChosen(qi, sel, oth) ? (oth[qi] ?? '').trim() : '' return [...picked, ...(other ? [other] : [])].join(', ') } const currentAnswered = answerFor(index).length > 0 + const currentTypedAnswerChosen = typedAnswerChosen(index) const submitAll = (sel: number[][], oth: string[]): void => { const resolved: AskAnswerSelection[] = prompt.questions.map((_, i) => { - return { indices: [...(sel[i] ?? [])], other: (oth[i] ?? '').trim() } + return { + indices: pickedOptions(i, sel), + other: typedAnswerChosen(i, sel, oth) ? (oth[i] ?? '').trim() : '' + } }) const anyAnswered = resolved.some((s) => s.indices.length > 0 || (s.other ?? '').length > 0) if (anyAnswered) { @@ -192,8 +223,19 @@ export function NativeChatQuestionCard({
{questionAllowsOther ? ( <> - - + + {currentTypedAnswerChosen ? ( + + ) : ( + + )} {/* No `/` or `@` picker here — that autocomplete belongs to the composer, which this card replaces. What you type is delivered verbatim as the @@ -205,6 +247,13 @@ export function NativeChatQuestionCard({ disabled={isSubmitting} value={otherText[index]} onChange={(e) => setOther(index, e.target.value)} + // Click, not focus: tabbing through the field toward Submit must not + // replace the option the user just picked. + onClick={() => { + if ((otherText[index] ?? '').trim().length > 0) { + chooseTypedAnswer(index) + } + }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -215,7 +264,12 @@ export function NativeChatQuestionCard({ 'components.native-chat.question.otherPlaceholder', 'Type your answer' )} - className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-default disabled:opacity-50" + className={cn( + 'min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60 disabled:cursor-default disabled:opacity-50', + currentTypedAnswerChosen || !otherText[index] + ? 'text-foreground' + : 'text-muted-foreground' + )} /> ) : ( diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx index 8dc70aab15e..fcf13956b9c 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx @@ -225,4 +225,22 @@ describe('resolution receipts', () => { ).toEqual([{ question: null, answer: null }]) } }) + + it('reads the recorded structured answers before the packed form', () => { + const typed = 'Wait for the capture to finish. '.repeat(50).trim() + const body: AgentJournalQuestionItem = { + kind: 'question', + question: 'Name?', + options: [{ id: 'q1:choice-1', label: 'Default' }], + freeTextQuestionId: 'q1', + resolution: { + ...approval.resolution, + // The packed copy only older clients read; it must not win over the recorded answer. + selectedOptionId: 'q1:choice-1', + answers: [{ questionId: 'q1', optionIds: [], other: typed }] + } + } + + expect(nativeChatReceiptAnswers(body)).toEqual([{ question: null, answer: typed }]) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 286ae50a3c8..e766955b83e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -2,7 +2,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { useAppStore } from '@/store' import { @@ -293,7 +292,10 @@ describe('NativeChatStructuredSession', () => { expect(document.querySelector('[data-native-chat-background-tasks="true"]')).not.toBeNull() act(() => mocks.approvalCardProps?.onChoose('allow')) - expect(mocks.respond).toHaveBeenCalledWith(approvalItems[0], 'allow') + expect(mocks.respond).toHaveBeenCalledWith(approvalItems[0], { + kind: 'option', + optionId: 'allow' + }) expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) act(() => mocks.approvalCardProps?.onCancel?.()) @@ -535,14 +537,16 @@ describe('NativeChatStructuredSession', () => { { indices: [0, 1], other: '' }, { indices: [], other: 'SSH host' } ]) - const encoded = mocks.respond.mock.calls[0]?.[1] - expect(decodeAgentSessionQuestionAnswers(encoded)).toEqual([ - { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) + expect(mocks.respond).toHaveBeenCalledWith(mocks.promptItems[0], { + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ] + }) }) - it('keeps legacy single-question option ids and free text behavior', () => { + it('answers a single-question item as its one question', () => { mocks.promptItems = legacySingleQuestionPromptItems render( @@ -568,6 +572,14 @@ describe('NativeChatStructuredSession', () => { } ]) card.onAnswer([{ indices: [1], other: '' }]) - expect(mocks.respond).toHaveBeenCalledWith(mocks.promptItems[0], 'q1:choice-2') + expect(mocks.respond).toHaveBeenLastCalledWith(mocks.promptItems[0], { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['q1:choice-2'] }] + }) + card.onAnswer([{ indices: [], other: ' Svelte ' }]) + expect(mocks.respond).toHaveBeenLastCalledWith(mocks.promptItems[0], { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [], other: 'Svelte' }] + }) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 4e18f0bf9c8..d4fbbbf62ba 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react' -import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' +import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { NativeChatLiveSession } from './use-native-chat-live-session' @@ -27,10 +27,6 @@ import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSe import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' -function encodeQuestionAnswer(questionId: string, answer: string): string { - return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` -} - export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { @@ -146,21 +142,7 @@ export function NativeChatStructuredSession( composerReady: prompt === null }) const questionBody = prompt?.body.kind === 'question' ? prompt.body : null - const questions = - questionBody?.questions ?? - (questionBody - ? [ - { - id: questionBody.freeTextQuestionId ?? 'q1', - question: questionBody.question, - options: questionBody.options, - multiSelect: false, - ...(questionBody.freeTextQuestionId - ? { freeTextQuestionId: questionBody.freeTextQuestionId } - : {}) - } - ] - : []) + const questions = questionBody ? agentSessionPromptQuestions(questionBody) : [] const structuredTransport = useMemo(() => { const threadGoal = controller.threadGoal const setThreadGoalObjective = threadGoal @@ -295,7 +277,7 @@ export function NativeChatStructuredSession( void controller.respond(prompt, optionId)} + onChoose={(optionId) => void controller.respond(prompt, { kind: 'option', optionId })} onCancel={cancelPrompt} shouldFocus={props.isVisible && props.isFocusedGroup} onLinkClick={onLinkClick} @@ -318,35 +300,17 @@ export function NativeChatStructuredSession( }} allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} onAnswer={(answers) => { - if (questionBody.questions) { - const grouped = questions.map((question, questionIndex) => { - const answer = answers[questionIndex] - const other = answer?.other?.trim() - const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { - const optionId = question.options[optionIndex]?.id - return optionId ? [optionId] : [] - }) - return { - questionId: question.id, - optionIds: question.multiSelect || !other ? optionIds : [], - ...(other ? { other } : {}) - } + const chosen = questions.map((question, questionIndex) => { + const answer = answers[questionIndex] + const other = answer?.other?.trim() + const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { + const optionId = question.options[optionIndex]?.id + return optionId ? [optionId] : [] }) - if (grouped.every((answer) => answer.optionIds.length > 0 || answer.other)) { - void controller.respond(prompt, encodeAgentSessionQuestionAnswers(grouped)) - } - return - } - const index = answers[0]?.indices[0] - const other = answers[0]?.other?.trim() - const optionId = - typeof index === 'number' - ? questionBody.options[index]?.id - : questionBody.freeTextQuestionId && other - ? encodeQuestionAnswer(questionBody.freeTextQuestionId, other) - : undefined - if (optionId) { - void controller.respond(prompt, optionId) + return { questionId: question.id, optionIds, ...(other ? { other } : {}) } + }) + if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { + void controller.respond(prompt, { kind: 'answers', answers: chosen }) } }} onCancel={cancelPrompt} diff --git a/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts b/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts index f9a0c64437d..475966a1b03 100644 --- a/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts +++ b/src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts @@ -2,7 +2,10 @@ import type { AgentJournalApprovalItem, AgentJournalQuestionItem } from '../../../../shared/agent-session-journal-types' -import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' +import { + agentSessionPromptQuestions, + legacyAgentSessionQuestionAnswers +} from '../../../../shared/agent-session-question-answer' export type NativeChatResolvedPrompt = AgentJournalApprovalItem | AgentJournalQuestionItem export type NativeChatReceiptAnswer = { question: string | null; answer: string | null } @@ -14,16 +17,19 @@ export function nativeChatReceiptAnswers( return [] } const selected = body.resolution.selectedOptionId - if (body.kind === 'question' && body.questions) { - const answers = selected ? decodeAgentSessionQuestionAnswers(selected) : null - return body.questions.map((question) => { + if (body.kind === 'question') { + // Rows written before hosts recorded structured answers carry only the packed form. + const answers = + body.resolution.answers ?? + (selected ? legacyAgentSessionQuestionAnswers(body, selected) : null) + return agentSessionPromptQuestions(body).map((question) => { const answer = answers?.find((entry) => entry.questionId === question.id) const labels = answer?.optionIds.map( (id) => question.options.find((option) => option.id === id)?.label ) const valid = labels?.every((label) => label !== undefined) return { - question: question.question, + question: body.questions ? question.question : null, answer: valid ? [...(labels ?? []), ...(answer?.other ? [answer.other] : [])].join(' · ') || null : null @@ -31,20 +37,5 @@ export function nativeChatReceiptAnswers( }) } const option = body.options.find((option) => option.id === selected) - if (option) { - return [{ question: null, answer: option.label }] - } - if (body.kind === 'question' && body.freeTextQuestionId && selected) { - const prefix = `${encodeURIComponent(body.freeTextQuestionId)}:` - if (selected.startsWith(prefix)) { - try { - return [ - { question: null, answer: decodeURIComponent(selected.slice(prefix.length)) || null } - ] - } catch { - // Malformed persisted answers remain readable as an unavailable selection. - } - } - } - return [{ question: null, answer: null }] + return [{ question: null, answer: option?.label ?? null }] } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx new file mode 100644 index 00000000000..337b22b4f5e --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + promptCancelSupported: vi.fn(), + questionAnswersSupported: vi.fn(), + operationId: vi.fn(() => 'operation-1') +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported, + supportsStructuredAgentSessionQuestionAnswers: mocks.questionAnswersSupported +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: mocks.operationId, + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' +import { + useStructuredAgentSession, + type StructuredPromptItem +} from './use-structured-agent-session' + +let items: AgentJournalRenderItem[] = [] +const target = { kind: 'local' } as const + +const question: StructuredPromptItem = { + itemId: 'question-1', + revision: 2, + sequence: 2, + observedAt: 2, + body: { + kind: 'question', + question: '1 grouped question from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Which option?', + multiSelect: false, + options: [{ id: 'q1:choice-1', label: 'Alpha' }], + freeTextQuestionId: 'q1' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } +} + +const approval: StructuredPromptItem = { + itemId: 'approval-1', + revision: 3, + sequence: 3, + observedAt: 3, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } +} + +const answers = [{ questionId: 'q1', optionIds: [], other: 'Wait for the capture. '.repeat(80) }] + +function respondCall(): Record | undefined { + return mocks.call.mock.calls.find(([, method]) => + String(method).startsWith('agentSession.respondTo') + )?.[2] +} + +describe('desktop structured prompt answers', () => { + beforeEach(() => { + vi.clearAllMocks() + items = [question, approval] + mocks.call.mockResolvedValue({ ok: true, value: { itemId: 'question-1', revision: 3 } }) + }) + + it('sends structured answers to a host that takes them', async () => { + mocks.questionAnswersSupported.mockResolvedValue(true) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await result.current.respond(question, { kind: 'answers', answers }) + }) + + expect(mocks.questionAnswersSupported).toHaveBeenCalledWith(target) + expect(respondCall()).toMatchObject({ itemId: 'question-1', expectedRevision: 2, answers }) + expect(respondCall()).not.toHaveProperty('optionId') + }) + + it('packs the answer into an option id for a host that predates structured answers', async () => { + mocks.questionAnswersSupported.mockResolvedValue(false) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await result.current.respond(question, { kind: 'answers', answers }) + }) + + expect(respondCall()).toMatchObject({ + itemId: 'question-1', + optionId: encodeAgentSessionQuestionAnswers(answers) + }) + expect(respondCall()).not.toHaveProperty('answers') + }) + + it('sends an approval decision without probing the host', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await result.current.respond(approval, { kind: 'option', optionId: 'allow' }) + }) + + expect(mocks.questionAnswersSupported).not.toHaveBeenCalled() + expect(respondCall()).toMatchObject({ itemId: 'approval-1', optionId: 'allow' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 640b3b2a7c4..9db1e6ff075 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -8,7 +8,14 @@ import type { } from '../../../../shared/agent-session-conversation-command' import type { AgentType } from '../../../../shared/agent-status-types' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client' +import { + supportsStructuredAgentSessionPromptCancel, + supportsStructuredAgentSessionQuestionAnswers +} from '@/runtime/structured-agent-session-client' +import { + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse +} from '../../../../shared/agent-session-question-answer' import { pendingStructuredSessionPrompts, type StructuredPromptItem @@ -174,14 +181,32 @@ export function useStructuredAgentSession(args: { scope: 'background-tasks', ...(taskId ? { taskId } : {}) }), - respond: (item: StructuredPromptItem, optionId: string) => - mutate( + respond: async (item: StructuredPromptItem, response: AgentSessionPromptResponse) => { + const promptTarget = { itemId: item.itemId, expectedRevision: item.revision } + let fields: Record + if (response.kind === 'option') { + fields = { ...promptTarget, optionId: response.optionId } + } else if (await supportsStructuredAgentSessionQuestionAnswers(target)) { + // Negotiated before mutate fingerprints the call: older hosts reject the strict field. + fields = { ...promptTarget, answers: response.answers } + } else { + const optionId = + item.body.kind === 'question' + ? legacyAgentSessionSelectedOptionId(item.body, response.answers) + : null + if (optionId === null) { + return null + } + fields = { ...promptTarget, optionId } + } + return mutate( item.body.kind === 'approval' ? 'agentSession.respondToApproval' : 'agentSession.respondToQuestion', `agentSession.respondTo:${item.body.kind}`, - { itemId: item.itemId, expectedRevision: item.revision, optionId } - ), + fields + ) + }, optionSnapshot, optionSurface, sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined, diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 446f419d1f9..d50fed2b4c3 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -9,6 +9,7 @@ import type { AgentSessionConversationOutline } from '../../../shared/agent-sess import { AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' @@ -46,6 +47,15 @@ export function supportsStructuredAgentSessionPromptCancel( return structuredAgentSessionHostSupports(target, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) } +export function supportsStructuredAgentSessionQuestionAnswers( + target: RuntimeClientTarget +): Promise { + return structuredAgentSessionHostSupports( + target, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY + ) +} + /** Null when the host predates the outline, without calling it. A failed read * rejects, so the caller can retry it; the rail maps loaded messages meanwhile. */ export async function readStructuredAgentSessionConversationOutline( diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 29271faa2b1..b7a80634bf8 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -147,6 +147,15 @@ const Question = z const Resolution = z.object({ state: z.string().min(1), selectedOptionId: z.string().nullable(), + answers: z + .array( + z.object({ + questionId: z.string(), + optionIds: z.array(z.string()), + other: z.string().optional() + }) + ) + .optional(), resolvedBy: z.string().nullable(), resolvedAt: z.number().nullable() }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a13f3b1f676..f5bd250c0c9 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -8,6 +8,7 @@ // journal rather than skipping or compacting past it. import type { AgentType } from './agent-status-types' +import type { AgentSessionQuestionAnswer } from './agent-session-question-answer' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { AgentSessionContextUsage } from './agent-session-context-usage' @@ -128,8 +129,11 @@ export type AgentJournalResolutionState = (typeof AGENT_JOURNAL_RESOLUTION_STATE * invoking the provider callback twice. */ export type AgentJournalResolution = { state: AgentJournalResolutionState - /** Option id the winner picked; null while pending or cancelled. */ + /** Option id the winner picked; null while pending or cancelled. For a question, the answer in the + * packed form older clients read; `answers` is the same answer structured. */ selectedOptionId: string | null + /** Question answers. Absent on approvals and on rows written before hosts recorded it. */ + answers?: AgentSessionQuestionAnswer[] /** Opaque client identity of the resolver, for "answered on ". */ resolvedBy: string | null resolvedAt: number | null diff --git a/src/shared/agent-session-question-answer.ts b/src/shared/agent-session-question-answer.ts index 072dfce200a..cc815acf542 100644 --- a/src/shared/agent-session-question-answer.ts +++ b/src/shared/agent-session-question-answer.ts @@ -1,13 +1,109 @@ -import type { AgentJournalQuestion } from './agent-session-journal-types' +import type { AgentJournalQuestion, AgentJournalQuestionItem } from './agent-session-journal-types' const GROUP_ANSWER_PREFIX = 'question-group:' +/** A single-question item has no question list; the client and host must agree on its id. */ +const SINGLE_QUESTION_ID = 'q1' + +/** Largest typed answer to one question, in UTF-8 bytes. */ +export const AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES = 64 * 1024 + export type AgentSessionQuestionAnswer = { questionId: string optionIds: string[] other?: string } +/** What a client chose on a prompt: a decision id for an approval, per-question answers for a question. */ +export type AgentSessionPromptResponse = + | { kind: 'option'; optionId: string } + | { kind: 'answers'; answers: AgentSessionQuestionAnswer[] } + +/** The questions an item asks: its grouped list, or the item itself as one question. */ +export function agentSessionPromptQuestions( + body: Pick +): AgentJournalQuestion[] { + if (body.questions) { + return body.questions + } + return [ + { + id: body.freeTextQuestionId ?? SINGLE_QUESTION_ID, + question: body.question, + options: body.options, + multiSelect: false, + ...(body.freeTextQuestionId ? { freeTextQuestionId: body.freeTextQuestionId } : {}) + } + ] +} + +function encodeLegacyFreeTextAnswer(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +function decodeLegacyFreeTextAnswer( + optionId: string +): { questionId: string; answer: string } | null { + const separator = optionId.indexOf(':') + if (separator <= 0) { + return null + } + try { + return { + questionId: decodeURIComponent(optionId.slice(0, separator)), + answer: decodeURIComponent(optionId.slice(separator + 1)) + } + } catch { + return null + } +} + +/** + * The answer packed into one option-id string, the only form older peers read: hosts that + * predate structured answers take it as `optionId`, and older clients render receipts from it. + */ +export function legacyAgentSessionSelectedOptionId( + body: Pick, + answers: readonly AgentSessionQuestionAnswer[] +): string | null { + if (body.questions) { + return encodeAgentSessionQuestionAnswers(answers) + } + const [answer] = answers + if (!answer || answers.length !== 1) { + return null + } + const other = answer.other?.trim() + return ( + answer.optionIds[0] ?? (other ? encodeLegacyFreeTextAnswer(answer.questionId, other) : null) + ) +} + +/** Reads an answer an older client packed into `optionId`. */ +export function legacyAgentSessionQuestionAnswers( + body: Pick, + optionId: string +): AgentSessionQuestionAnswer[] | null { + const grouped = body.questions ? decodeAgentSessionQuestionAnswers(optionId) : null + if (grouped) { + return grouped + } + const questions = agentSessionPromptQuestions(body) + const questionId = questions.length === 1 ? questions[0]!.id : null + if (!questionId) { + return null + } + if (body.options.some((option) => option.id === optionId)) { + return [{ questionId, optionIds: [optionId] }] + } + const freeText = decodeLegacyFreeTextAnswer(optionId) + return body.freeTextQuestionId && + freeText?.questionId === body.freeTextQuestionId && + freeText.answer.trim().length > 0 + ? [{ questionId, optionIds: [], other: freeText.answer }] + : null +} + export function encodeAgentSessionQuestionAnswers( answers: readonly AgentSessionQuestionAnswer[] ): string { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index dac7fa084bd..db9d7f1b544 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -211,6 +211,10 @@ export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = // older host that would reject the whole cancellation instead of falling back to turn stop. export const AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY = 'agent-session.prompt-cancel.v1' as const +// Why: agentSession.respondToQuestion has a strict schema, so clients must not send structured +// `answers` to an older host; they fall back to the answer packed into `optionId`. +export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = + 'agent-session.question-answers.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given, @@ -377,6 +381,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 57d90d2218a..df05a234a88 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -474,6 +474,7 @@ import { ModelCatalogParams, OptionsParams, RespondParams, + RespondToQuestionParams, RestartResumableParams, RestartResumeParams, RewindParams, @@ -579,7 +580,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.options': OptionsParams, 'agentSession.release': HoldParams, 'agentSession.respondToApproval': RespondParams, - 'agentSession.respondToQuestion': RespondParams, + 'agentSession.respondToQuestion': RespondToQuestionParams, 'agentSession.restartContinue': RestartResumeParams, 'agentSession.restartResumable': RestartResumableParams, 'agentSession.restartResumableDismiss': RestartResumableParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 7f41bd2c4b8..bd9ce3db82a 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' +import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer' import { AGENT_SESSION_ID_MAX_LENGTH, AGENT_SESSION_HISTORY_DIRECTIONS, @@ -16,6 +17,10 @@ export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 export const MAX_PROMPT_BYTES = 256 * 1024 +/** Matches the journal's bounds on one grouped prompt. */ +const MAX_QUESTION_ANSWER_QUESTIONS = 4 +const MAX_QUESTION_ANSWER_OPTIONS = 64 + export const MAX_BLOCKS = 64 export const MAX_OPTION_LABEL = 512 @@ -208,6 +213,50 @@ export const RespondParams = z }) .strict() +const QuestionAnswer = z + .object({ + // Codex question ids are model-written and untrimmed; the host matches them exactly. + questionId: z + .string() + .min(1, 'Invalid question id') + .max(MAX_RESPONSE_OPTION_ID_LENGTH, 'Invalid question id'), + optionIds: z + .array(Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH)) + .max(MAX_QUESTION_ANSWER_OPTIONS), + // Hashed verbatim by both peers, so no trim or transform here. + other: z + .string() + .max(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, + 'Answer is too large' + ) + .optional() + }) + .strict() + +export const RespondToQuestionParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + expectedRevision: z.number().int().positive(), + /** An answer packed into one id, from clients that predate `answers`. */ + optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH).optional(), + answers: z.array(QuestionAnswer).min(1).max(MAX_QUESTION_ANSWER_QUESTIONS).optional() + }) + .strict() + .superRefine((value, ctx) => { + if ((value.optionId === undefined) === (value.answers === undefined)) { + ctx.addIssue({ code: 'custom', message: 'Send exactly one of an option id or answers' }) + } + if ( + value.answers !== undefined && + Buffer.byteLength(JSON.stringify(value.answers), 'utf8') > MAX_PROMPT_BYTES + ) { + ctx.addIssue({ code: 'custom', message: 'Answer is too large' }) + } + }) + export const SetOptionParams = z .object({ envelope: MutationEnvelope, diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index d244fba1ba6..0e3a6ae06c9 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -23,6 +23,7 @@ import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-s import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, @@ -42,6 +43,7 @@ import { resetOperationIds, REWIND_METHOD, CONVERSATION_OUTLINE_METHOD, + envelope, STATUS_FEED_METHOD, sendParams, SESSION, @@ -313,6 +315,41 @@ describe('cross-version structured agent sessions', () => { } }) + it('takes structured question answers exactly where the host advertises them', async () => { + // A client sends `answers` only on this capability, so the two must never disagree: + // a strict older schema refuses the field and the answer is lost rather than degraded. + const method = 'agentSession.respondToQuestion' + const fields = { + itemId: 'item-1', + expectedRevision: 1, + answers: [{ questionId: 'q1', optionIds: [], other: 'Wait for the capture. '.repeat(80) }] + } + const params = { envelope: envelope({ method, fields, fence: 1 }), ...fields } + expect(current.capabilities).toContain(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY) + for (const build of [current, baseline]) { + const advertised = build.capabilities.includes( + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY + ) + if (!build.methodNames.includes(method)) { + expect(advertised, `${build.label} advertises answers without the method`).toBe(false) + continue + } + const hostCalls = structuredHostStub(SESSION, WORKSPACE) + await build.installStructuredHost(installableHost(hostCalls)) + try { + const replies = await callBuild(build, method, params, { + clientKind: 'runtime', + clientCapabilities: current.capabilities + }) + expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]?.ok, `${build.label}: ${JSON.stringify(replies[0])}`).toBe(advertised) + expect(hostCalls.respondToPrompt).toHaveBeenCalledTimes(advertised ? 1 : 0) + } finally { + await build.installStructuredHost(null) + } + } + }) + it( 'executes every method a release-shaped checkout registers', async () => { From 570f070f6c90bd2ab41b2e334eed877b021baba3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:24:54 -0400 Subject: [PATCH 006/387] fix(mobile): the page's Live input on Android echoes each letter as typed (OTA phase C follow-up) (#22958) * test(mobile): the page's live input sends a composed word's letters as they are typed The reported shape, in a browser: `/tui` on an Android keyboard, where the `/` reached the terminal and `tui` did not until Enter. Android keyboards hold a composing region over the Latin word being typed, so on the page every input event mid-word carries `isComposing: true`, and the preedit mirror holds reported preedit with no settle timer. Native Android reports no range, so there each ASCII keystroke is sent as it is typed. Driven through Chromium's own IME path with an Android WebView user agent: the letters after the slash must arrive one by one, and a correction on commit must still erase and retype. The iPhone case is the guard that a composition off Android stays held. The hook case says the same thing without a browser. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page on Android reports no composing range, as native Android does `handleLiveInputChange` passed `nativeEvent.isComposing` straight to the preedit mirror. On the page that event is the DOM's, and an Android WebView marks the keyboard's composing region, which Samsung and other Latin keyboards keep over every word. The mirror treats a reported range as preedit that is not text yet, holds all of it with no settle timer, and Chromium fires no input event after compositionend, so the word sat in the field until the next keystroke or Enter. Native Android reports no range at all, and its fallback holds only a trailing non-ASCII run. The page on Android now reports the same: undefined. ASCII echoes on each key, a Hangul or kana run still settles on the timer, and a correction on commit still erases and retypes. iOS, where the range is the text system's marked text, is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the Android page among the platforms that report no composing range The mirror's fallback note listed only React Native Android; the page on Android now reports no range too. The reader's docblock becomes the one line it needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...eb-app-terminal-live-input-render.test.mjs | 105 +++++++++++++++++- .../terminal/terminal-live-preedit-mirror.ts | 7 +- .../use-terminal-live-input-commit.test.ts | 36 ++++++ .../use-terminal-live-input-commit.ts | 12 +- 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs b/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs index 210e828af8f..b1821fd0683 100644 --- a/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs @@ -112,8 +112,11 @@ afterAll(async () => { * Settled is "the probe registered" or "the page reported a fault", because those are the two * outcomes and waiting only for the first turns the defect into a 60s timeout that names nothing. */ -async function openProbe() { - const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) +async function openProbe({ userAgent } = {}) { + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, + ...(userAgent ? { userAgent } : {}) + }) await page.addInitScript(installShellDouble, { version: bridgeVersion, sessionId: 'live-input-session', @@ -322,6 +325,104 @@ describeRender( await page.close() }, 300_000) + describe('under an Android keyboard, which composes every word it types', () => { + // The OTA shell's WebView. Its keyboards hold a composing region over the Latin word being + // typed, so every input event mid-word says `isComposing: true`; native Android reports no + // range at all, and there each ASCII keystroke reaches the terminal as it is typed. + const ANDROID_WEBVIEW_USER_AGENT = + 'Mozilla/5.0 (Linux; Android 16; Pixel 9 Pro Build/BP2A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/140.0.0.0 Mobile Safari/537.36' + const sent = (page) => page.evaluate(() => globalThis.__orcaLiveInputProbe.sent()) + + /** One composition step through the browser's own IME path, which fires the DOM composition events. */ + async function compose(input, text) { + await input.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length + }) + } + + async function waitForField(page, value) { + await page.waitForFunction( + ([id, expected]) => document.getElementById(id)?.value === expected, + [LIVE_INPUT_FIELD_ID, value], + { timeout: 30_000, polling: 50 } + ) + } + + it('sends each letter of a composed word after a slash as it is typed', async () => { + // The reported shape: `/tui` in a Codex terminal, where the `/` arrived and `tui` did not + // until Enter. The keyboard commits `/` outright and opens a composition for the letters. + const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT }) + await page.focus(`#${LIVE_INPUT_FIELD_ID}`) + const input = await page.context().newCDPSession(page) + await page.keyboard.type('/') + await waitForField(page, '/') + + const afterEachStep = [] + for (const text of ['t', 'tu', 'tui']) { + await compose(input, text) + await waitForField(page, `/${text}`) + afterEachStep.push(await sent(page)) + } + await input.send('Input.insertText', { text: 'tui' }) + await waitForField(page, '/tui') + + expect(afterEachStep).toEqual([ + ['/', 't'], + ['/', 't', 'u'], + ['/', 't', 'u', 'i'] + ]) + expect(await sent(page)).toEqual(['/', 't', 'u', 'i']) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('erases and retypes a word the keyboard corrects when it commits', async () => { + const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT }) + await page.focus(`#${LIVE_INPUT_FIELD_ID}`) + const input = await page.context().newCDPSession(page) + for (const text of ['t', 'te', 'teh']) { + await compose(input, text) + await waitForField(page, text) + } + + await input.send('Input.insertText', { text: 'the' }) + await waitForField(page, 'the') + + await page.waitForFunction( + () => globalThis.__orcaLiveInputProbe.sent().length === 4, + undefined, + { + timeout: 30_000, + polling: 50 + } + ) + expect(await sent(page)).toEqual(['t', 'e', 'h', '\u007f\u007fhe']) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it("still holds a composition off Android, where it is the text system's marked text", async () => { + // The guard: an iOS WebView composes only what native iOS marks, pinyin before conversion + // among it, and that is not text yet on either side of the bridge. + const { errors, page } = await openProbe({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' + }) + await page.focus(`#${LIVE_INPUT_FIELD_ID}`) + const input = await page.context().newCDPSession(page) + for (const text of ['n', 'ni']) { + await compose(input, text) + await waitForField(page, text) + } + + expect(await sent(page)).toEqual([]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + describe('in buffered mode, where the field holds the draft until Enter', () => { const bufferedValue = (page) => page.evaluate((id) => document.getElementById(id)?.value ?? null, BUFFERED_FIELD_ID) diff --git a/mobile/src/terminal/terminal-live-preedit-mirror.ts b/mobile/src/terminal/terminal-live-preedit-mirror.ts index 29c903f4366..2bc165a625c 100644 --- a/mobile/src/terminal/terminal-live-preedit-mirror.ts +++ b/mobile/src/terminal/terminal-live-preedit-mirror.ts @@ -21,9 +21,10 @@ export type TerminalLiveMirrorStep = { * ever classified. That is what makes this hold for input methods nobody tested. * * `composing` is undefined only where the platform reports no range at all — - * today React Native Android. The fallback holds the trailing non-ASCII run: a - * conversion IME always leaves one and ASCII keeps its zero-latency echo. It - * enumerates nothing, and it cannot see an ASCII preedit — only a report can. + * today React Native Android and the page on Android. The fallback holds the + * trailing non-ASCII run: a conversion IME always leaves one and ASCII keeps its + * zero-latency echo. It enumerates nothing, and it cannot see an ASCII preedit — + * only a report can. */ function heldPreeditLength( fieldCodePoints: readonly string[], diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 87ad2b64292..df693be45be 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -121,6 +121,7 @@ function createTerminalLiveInputCommitHarness({ describe('terminal live input commit hook', () => { afterEach(() => { vi.useRealTimers() + vi.unstubAllGlobals() }) it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => { @@ -168,6 +169,41 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual([]) }) + it('Given an Android WebView composing each word When letters follow a slash Then each reaches the terminal as typed', async () => { + // Given: the page's field event is the DOM's, and an Android keyboard composes Latin words + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + changeLiveInput(handlers, '/', false) + await vi.waitFor(() => expect(sent).toHaveLength(1)) + for (const fieldText of ['/t', '/tu', '/tui']) { + changeLiveInput(handlers, fieldText, true) + // Each keystroke on its own, so a held letter cannot hide inside a later batch. + await vi.waitFor(() => expect(sent).toHaveLength(fieldText.length)) + } + + // Then: what native Android sends for the same keys, which report no range there + await vi.waitFor(() => expect(sent).toEqual(['/', 't', 'u', 'i'])) + }) + + it('Given an Android WebView composing Hangul When steps arrive Then the non-ASCII run still settles on the timer', async () => { + // Given + vi.useFakeTimers() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + for (const fieldText of ['ㅎ', '하', '한']) { + changeLiveInput(handlers, fieldText, true) + await vi.advanceTimersByTimeAsync(50) + } + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => { // Given vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 55bc23c3dcc..237a78ca12a 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -27,6 +27,16 @@ type TerminalLiveInputChangeEvent = { } } +/** Android keyboards compose every Latin word; report no range, as native Android does. */ +function reportedLiveInputComposing( + nativeEvent: TerminalLiveInputChangeEvent['nativeEvent'] +): boolean | undefined { + if (globalThis.navigator?.userAgent?.includes('Android')) { + return undefined + } + return nativeEvent.isComposing +} + type TerminalLiveInputCommitOptions = { readonly activeHandle: string | null readonly activeHandleRef: RefObject @@ -148,7 +158,7 @@ export function useTerminalLiveInputCommit({ void applyLiveInputMirror( activeHandle, normalizeTerminalTextInput(nativeEvent.text), - nativeEvent.isComposing + reportedLiveInputComposing(nativeEvent) ) }, [ From 6627c6503c2ca822618edfa38f67bf37fb7349b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:30:27 -0700 Subject: [PATCH 007/387] refactor(agent-session): keep which conversation each chat tab shows in one host table (#22709) * refactor(agent-session): keep which conversation each chat tab shows in one host table The host now persists one table in the agent-session store, from chat tab id to the conversation that tab currently shows. It replaces both the visible-session list and the per-record surfaceTabId, so there is one answer to "which chats have a tab, and under what id". - /clear moves the tab's entry from the old conversation to its replacement in the same transaction that commits the clear. No record carries a copied tab id, so a tab id names one conversation by construction, and the lookup from a conversation to its tab returns at most one id. - A create that reserved a tab id claims it in its reservation transaction; uniqueness is a key check on the table. A create that fails releases its claim, and hiding a chat frees its id. - Showing a chat with no entry gives it today's id, structured-agent-session-, unless a cleared chat's tab kept that id; then it gets a fresh one. - A close that does not land puts the tab back under the id it had. - Stores written by older builds are seeded on read from the visible list and, for chats that have one, the record's surfaceTabId. That field is no longer part of the record type, is never written, and is read only by this seed when the file has no table. The visible list is still written, derived from the table, for older builds. Tab snapshots, status keys and worker pane keys are unchanged. Co-Authored-By: Claude * fix(agent-session): take a reserved chat tab id when its tab is published A create that reserved a tab id put it in the tab table at reservation, and table membership is visibility. A create that stopped before its tab was published (a crash, or a failure after the provider started) left an entry that the next launch either restored as a tab nobody asked to see again or kept forever with no way to close it. Reservation now only refuses an id another chat's tab holds; the id is taken when the tab is published, so there is nothing to release on failure. The create reply now reads the tab id after publishing, so an unreserved create answers with the id its tab was given, as it did before the table, and agrees with a replay of the same create. Co-Authored-By: Claude * fix(agent-session): seed a chat cleared before the upgrade under its first tab id A chat cleared on an older build shows a later conversation of its /clear chain in the tab that was opened for the first one, and clients key that tab, its read state and its status by the first conversation's id. Seeding gave it the latest conversation's derived id instead, so the stored id disagreed with the one clients hold and with what a /clear on this build leaves behind. Seeding now follows the source records' committed clears back to the chain's first conversation and gives the chat showing the chain that conversation's id. Those chats seed first, so a cleared conversation reopened from history takes a fresh id when its own is held. A seeded chat is no longer dropped when every candidate id is taken, and the table keeps the visible list's order. Co-Authored-By: Claude * fix(agent-session): give a reopened cleared chat the same tab id at runtime and at seeding A cleared conversation reopened from history got a random tab id at runtime but a deterministic `-reopened` id when the table is seeded from an older store. One rule now serves both, so a table dropped by an older build and seeded again gives that chat the id it already had. --------- Co-authored-by: Claude --- ...-agent-session-acquisition-options.test.ts | 36 +- .../structured-agent-session-attach-flow.ts | 3 +- .../structured-agent-session-attach.ts | 11 +- .../structured-agent-session-host-tabs.ts | 13 +- .../structured-agent-session-host.ts | 5 +- ...ent-session-conversation-command-record.ts | 6 +- .../agent-session-record-store-file.ts | 48 +- ...ession-record-store-surface-tab-id.test.ts | 149 ------- ...ent-session-record-store-tab-table.test.ts | 248 +++++++++++ .../runtime/agent-session-record-store.ts | 37 +- ...ent-session-recovery-publish-fault.test.ts | 3 +- ...gent-session-reservation-admission.test.ts | 3 +- .../agent-session-reservation-admission.ts | 24 +- .../agent-session-store-serialization.ts | 5 +- .../agent-session-store-transaction-queue.ts | 26 +- src/main/runtime/agent-session-tab-table.ts | 258 +++++++++++ .../agent-session-visible-tab-index.ts | 38 -- ...tore-structured-agent-session-tabs-once.ts | 8 +- .../structured-agent-session-create.ts | 14 +- .../methods/structured-chat-tab-table.test.ts | 409 ++++++++++++++++++ .../structured-agent-session-close.test.ts | 21 +- .../runtime/structured-agent-session-close.ts | 20 +- ...ructured-session-worktree-teardown.test.ts | 8 +- .../structured-session-worktree-teardown.ts | 2 +- src/shared/agent-launch-intent.ts | 2 +- src/shared/agent-session-record.ts | 5 - src/shared/agent-session-wire.ts | 2 +- 27 files changed, 1055 insertions(+), 349 deletions(-) delete mode 100644 src/main/runtime/agent-session-record-store-surface-tab-id.test.ts create mode 100644 src/main/runtime/agent-session-record-store-tab-table.test.ts create mode 100644 src/main/runtime/agent-session-tab-table.ts delete mode 100644 src/main/runtime/agent-session-visible-tab-index.ts create mode 100644 src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index e0549e1816e..3172a04597e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -574,7 +574,7 @@ describe('structured session acquisition options', () => { }) }) -describe('the tab id a create records', () => { +describe('the tab a create reserves', () => { async function openStore() { root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) return AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) @@ -603,30 +603,18 @@ describe('the tab id a create records', () => { }) } - it('pins the id the caller reserved on the record and answers with it', async () => { + it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { const store = await openStore() - const result = await attachWith(store, 'chat-tab-1') + const created = await attachWith(store, 'chat-tab-1') + // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. + expect(created.ok && created.value.tabId).toBeUndefined() + expect(store.getSessionTabId(SESSION)).toBeNull() - expect(result).toMatchObject({ ok: true, value: { tabId: 'chat-tab-1' } }) - expect(store.getRecord(SESSION)?.surfaceTabId).toBe('chat-tab-1') - }) - - it('records the id clients derive when the caller reserved none', async () => { - const store = await openStore() - const result = await attachWith(store) - - // Every reader still keys by the derived id, so an unreserved chat must not record another. - const derived = `structured-agent-session-${SESSION}` - expect(result).toMatchObject({ ok: true, value: { tabId: derived } }) - expect(store.getRecord(SESSION)?.surfaceTabId).toBe(derived) - }) - - it('answers a retry that names another tab with the one the record holds', async () => { - const store = await openStore() - await attachWith(store, 'chat-tab-1') - const retried = await attachWith(store, 'chat-tab-2') - - expect(retried).toMatchObject({ ok: true, value: { tabId: 'chat-tab-1' } }) - expect(store.getRecord(SESSION)?.surfaceTabId).toBe('chat-tab-1') + await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') + expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ + ok: true, + value: { tabId: 'chat-tab-1' } + }) + expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index ab9c44949e0..cf37a9a6fe2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -222,6 +222,7 @@ export async function performAttach( } const fence = record.lease.runtimeFence + const tabId = store.getSessionTabId(sessionId) return { ok: true, replayed, @@ -232,7 +233,7 @@ export async function performAttach( fence, page: readAgentSessionHydrationPage(attached.journal, fence), unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds, - ...(record.surfaceTabId ? { tabId: record.surfaceTabId } : {}) + ...(tabId ? { tabId } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 2e42ffe9893..de5fa297daf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -23,7 +23,6 @@ import type { AgentSessionLaunchEnv, AgentSessionRecord } from '../../../shared/agent-session-record' -import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { AGENT_SESSION_WIRE_REFUSAL_CODES, type AgentSessionMutationEnvelope, @@ -64,8 +63,8 @@ export type AgentSessionAttachParams = { runtimeKind: 'native' /** Host-resolved defaults for a create-by-intent; remote attach schemas do not accept them. */ options?: Readonly> - /** The tab id a create reserves for this chat; absent records the id clients derive. Never on - * the attach fingerprint: which tab shows the chat is not which conversation it attaches to. */ + /** The tab id a create reserves for this chat, taken when its tab is published. Never on the + * attach fingerprint: which tab shows the chat is not which conversation it attaches to. */ surfaceTabId?: string launchArgs?: string[] /** Omitted only for create-by-intent; the adapter proves the durable handle. */ @@ -317,10 +316,8 @@ export function reserveRequestFor(input: { provider: params.provider, accountHome: params.accountHome, ...(params.options ? { options: params.options } : {}), - // Create path only: an existing record keeps its own. Unreserved, it is the id every client - // still derives, so nothing keyed by it moves until those readers copy the recorded one. - ...(params.envelope.expectedRuntimeFence === null - ? { surfaceTabId: params.surfaceTabId ?? structuredAgentSessionTabId(input.sessionId) } + ...(params.envelope.expectedRuntimeFence === null && params.surfaceTabId + ? { surfaceTabId: params.surfaceTabId } : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 3bd6e46b98e..8a18040daf3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -9,19 +9,26 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' export function setStructuredAgentSessionTabVisibility( host: { deps: { - store: { setSessionTabVisibility: (sessionId: string, visible: boolean) => Promise } + store: { + setSessionTabVisibility: ( + sessionId: string, + visible: boolean, + tabId?: string + ) => Promise + } } restartResume: { dismiss: (sessionIds: readonly string[]) => Promise } }, sessionId: string, - visible: boolean + visible: boolean, + tabId?: string ): Promise { if (!visible) { void host.restartResume.dismiss([sessionId]).catch(() => { console.warn('[structured-agent-session] forgetting recovery records on chat close failed') }) } - return host.deps.store.setSessionTabVisibility(sessionId, visible) + return host.deps.store.setSessionTabVisibility(sessionId, visible, tabId) } export type StructuredAgentSessionTab = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 4ba2e6db478..4a6c4645e45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -188,9 +188,10 @@ export class StructuredAgentSessionHost { listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() + getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) - setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => - sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible) + setSessionTabVisibility = (sessionId: string, visible: boolean, tabId?: string): Promise => + sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible, tabId) reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 2e7053e8a1b..f09e5d50fc5 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,4 +1,5 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { AgentSessionTabTable } from './agent-session-tab-table' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' export function commitConversationCommandRecord( @@ -20,8 +21,7 @@ export function commitConversationCommandRecord( if (!state.records.has(command.replacementSessionId)) { throw new Error('agent_session_identity_required') } - state.visibleSessionIds.delete(sessionId) - state.visibleSessionIds.add(command.replacementSessionId) - state.visibleSessionIdsIndexPresent = true + state.sessionTabs ??= new AgentSessionTabTable() + state.sessionTabs.move(sessionId, command.replacementSessionId) } } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index b721055e2a9..ef0f4b1ab7f 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -21,10 +21,9 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { agentSessionStoreBackupPath as backupPath } from './agent-session-record-store-write' export { saveAgentSessionStore } from './agent-session-record-store-write' -import { parseVisibleSessionIds } from './agent-session-visible-tab-index' +import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' import { serializeAgentSessionStoreState } from './agent-session-store-serialization' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -41,10 +40,8 @@ export type AgentSessionStoreState = { retiredClaimKeys: RetiredAgentSessionClaimKey[] /** Rows this build cannot validate, kept with a durable refusal reason. */ unreadableRecords: Map - /** Structured sessions that currently have a visible chat tab. */ - visibleSessionIds: Set - /** True once this store has committed the visibility index field. */ - visibleSessionIdsIndexPresent: boolean + /** Chat tab id → the conversation it shows; null until this store first records a tab. */ + sessionTabs: AgentSessionTabTable | null } export type LoadedAgentSessionStore = { @@ -72,33 +69,10 @@ function emptyState(hostId: string): AgentSessionStoreState { operations: new Map(), retiredClaimKeys: [], unreadableRecords: new Map(), - visibleSessionIds: new Set(), - visibleSessionIdsIndexPresent: false + sessionTabs: null } } -/** - * Gives every record written before the host owned a chat's tab id the string clients derived for - * it, so read state, notification ids and worker rows keyed by that id stay valid on upgrade. - * - * Runs once per open, after the disk revision is taken and before the load rewrite: it is not part - * of parsing, because a parsed state must hash to what is on disk or every transaction would read - * the file as externally changed. Returns how many records it filled. - */ -export function backfillAgentSessionSurfaceTabIds(state: AgentSessionStoreState): number { - let filled = 0 - for (const [sessionId, record] of state.records) { - if (record.surfaceTabId === undefined) { - state.records.set(sessionId, { - ...record, - surfaceTabId: structuredAgentSessionTabId(sessionId) - }) - filled += 1 - } - } - return filled -} - export function agentSessionStoreRevision(state: AgentSessionStoreState): string { return createHash('sha256') .update(String(state.schemaVersion)) @@ -131,6 +105,7 @@ function parseState( operations?: unknown retiredClaimKeys?: unknown unusableRecords?: unknown + sessionTabs?: unknown visibleSessionIds?: unknown } if ( @@ -245,16 +220,15 @@ function parseState( state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) } } - const visibleSessionIds = parseVisibleSessionIds( - file.visibleSessionIds, - schemaVersion, - AGENT_SESSION_STORE_SCHEMA_VERSION + const sessionTabs = parseAgentSessionTabTable( + file, + state.records, + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION ) - if (!visibleSessionIds.valid) { + if (!sessionTabs.valid) { return null } - state.visibleSessionIdsIndexPresent = visibleSessionIds.present - visibleSessionIds.ids.forEach((sessionId) => state.visibleSessionIds.add(sessionId)) + state.sessionTabs = sessionTabs.table return { state, needsRewrite, legacyHandoffLeasesNormalized } } diff --git a/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts b/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts deleted file mode 100644 index f9603e7a86b..00000000000 --- a/src/main/runtime/agent-session-record-store-surface-tab-id.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * The host-owned id of the tab that shows a structured chat, as the record store persists it. - * Separate from the store's main suite only because that file is at its line cap. - */ - -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const NATIVE: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} -const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } - -let directory: string -let counter = 0 - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-surface-tab-id-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -function operationId(now = NOW): string { - counter += 1 - return `${now}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { - sessionId: 'session-alpha', - location: NATIVE, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: INDETERMINATE, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, - now: NOW, - ...overrides - } -} - -async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) -} - -describe('surface tab id', () => { - const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - - it('records the reserved tab id and refuses a second record under it', async () => { - const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) - expect(store.getRecord('session-alpha')?.surfaceTabId).toBe('tab-alpha') - const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) - expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') - - await expect( - store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - surfaceTabId: 'tab-alpha', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - ).rejects.toThrow('agent_session_conflict') - expect(store.getRecord('session-beta')).toBeNull() - }) - - it('refuses a tab id that could not prefix a pane key', async () => { - const store = await open() - await expect( - store.reserveOwner(reserveRequest({ surfaceTabId: 'agent-session:session-alpha' })) - ).rejects.toThrow('agent_session_operation_invalid') - }) - - it('backfills a record written before the field existed with the id clients derived', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - delete raw.records['session-alpha'].surfaceTabId - await writeFile(filePath, JSON.stringify(raw)) - - const reopened = await open() - // In memory at once, so every reader of the record sees one. - expect(reopened.getRecord('session-alpha')?.surfaceTabId).toBe(LEGACY_TAB_ID) - // Not on disk yet: an open must not write, or another holder of the file mid-restart would - // read it as an external change. - expect( - JSON.parse(await readFile(filePath, 'utf-8')).records['session-alpha'] - ).not.toHaveProperty('surfaceTabId') - - // The first transaction carries it to disk, and a later open finds nothing left to fill. - await reopened.setSessionTabVisibility('session-alpha', true) - expect( - JSON.parse(await readFile(filePath, 'utf-8')).records['session-alpha'].surfaceTabId - ).toBe(LEGACY_TAB_ID) - const settled = await readFile(filePath, 'utf-8') - await open() - expect(await readFile(filePath, 'utf-8')).toBe(settled) - }) - - it('refills the id when another holder rewrites the file, without forcing a save', async () => { - const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - // An older build's write, which never carries the field. - delete raw.records['session-alpha'].surfaceTabId - raw.visibleSessionIds = [] - const external = JSON.stringify(raw) - await writeFile(filePath, external) - - // A transaction that changes nothing still reloads the externally changed file first. - await store.setSessionTabVisibility('session-alpha', false) - expect(store.getRecord('session-alpha')?.surfaceTabId).toBe(LEGACY_TAB_ID) - // The reload marked every lease unadjudicated; a refill must not persist that verdict. - expect(await readFile(filePath, 'utf-8')).toBe(external) - }) - - it('quarantines a persisted record whose tab id contains a colon', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' - await writeFile(filePath, JSON.stringify(raw)) - expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) - }) -}) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts new file mode 100644 index 00000000000..e0ecfec2835 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -0,0 +1,248 @@ +/** + * The persisted table from chat tab id to the conversation it shows, as the record store keeps it. + * Separate from the store's main suite only because that file is at its line cap. + */ + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const NATIVE: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +let directory: string +let counter = 0 + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-tab-table-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +function operationId(now = NOW): string { + counter += 1 + return `${now}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: NATIVE, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +async function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +describe('chat tab table', () => { + const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' + const filePath = () => agentSessionStorePath(directory) + const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) + + it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + // A create that dies before its tab is shown leaves nothing to restore or release. + expect(store.getSessionTabId('session-alpha')).toBeNull() + expect((await readFileJson()).sessionTabs).toBeUndefined() + + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + expect(store.getSessionTabId('session-alpha')).toBe('tab-alpha') + const persisted = await readFileJson() + expect(persisted.sessionTabs).toEqual([{ tabId: 'tab-alpha', sessionId: 'session-alpha' }]) + // Not copied onto the record: the table is the one place the id lives. + expect(persisted.records['session-alpha']).not.toHaveProperty('surfaceTabId') + + await expect( + store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + surfaceTabId: 'tab-alpha', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + expect(store.getRecord('session-beta')).toBeNull() + }) + + it('frees a reserved id once its chat is hidden', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await store.setSessionTabVisibility('session-alpha', false) + await store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + surfaceTabId: 'tab-alpha', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + await store.setSessionTabVisibility('session-beta', true, 'tab-alpha') + expect(store.getSessionTabId('session-beta')).toBe('tab-alpha') + }) + + it('refuses a tab id that could not prefix a pane key', async () => { + const store = await open() + await expect( + store.reserveOwner(reserveRequest({ surfaceTabId: 'agent-session:session-alpha' })) + ).rejects.toThrow('agent_session_operation_invalid') + }) + + it('gives a shown chat the id clients derive, and puts a hidden one back under its old id', async () => { + const store = await open() + await store.reserveOwner(reserveRequest()) + await store.setSessionTabVisibility('session-alpha', true) + expect(store.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) + await store.setSessionTabVisibility('session-alpha', false) + await store.setSessionTabVisibility('session-alpha', true, 'tab-restored') + expect(store.getSessionTabId('session-alpha')).toBe('tab-restored') + }) + + it('seeds the table from an older store, keeping each visible chat on the id it has today', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + await first.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + await first.reserveOwner( + reserveRequest({ + sessionId: 'session-gamma', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-3' } + }) + ) + // What a build before the table wrote: the visible list, and a tab id on some records. + const raw = await readFileJson() + delete raw.sessionTabs + raw.visibleSessionIds = ['session-alpha', 'session-beta'] + raw.records['session-alpha'].surfaceTabId = 'tab-alpha' + raw.records['session-beta'].surfaceTabId = 'structured-agent-session-session-beta' + const legacy = JSON.stringify(raw) + await writeFile(filePath(), legacy) + + const reopened = await open() + expect(reopened.getSessionTabId('session-alpha')).toBe('tab-alpha') + expect(reopened.getSessionTabId('session-beta')).toBe('structured-agent-session-session-beta') + expect(reopened.getSessionTabId('session-gamma')).toBeNull() + // Seeding is part of reading, so an open alone writes nothing. + expect(await readFile(filePath(), 'utf-8')).toBe(legacy) + + await reopened.setSessionTabVisibility('session-gamma', true) + const persisted = await readFileJson() + expect(persisted.sessionTabs).toEqual([ + { tabId: 'tab-alpha', sessionId: 'session-alpha' }, + { tabId: 'structured-agent-session-session-beta', sessionId: 'session-beta' }, + { tabId: 'structured-agent-session-session-gamma', sessionId: 'session-gamma' } + ]) + // Older builds restore from this list; the record field rides along untouched and unread. + expect(persisted.visibleSessionIds).toEqual(['session-alpha', 'session-beta', 'session-gamma']) + expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') + }) + + it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { + const first = await open() + for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { + await first.reserveOwner( + reserveRequest({ + sessionId, + operation: { + callerKey: 'client-1', + operationId: operationId(), + fingerprint: `fp-chain-${index}` + } + }) + ) + } + // An older build after two clears, with the first conversation reopened from history. + const raw = await readFileJson() + const cleared = (replacementSessionId: string) => ({ + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: operationId(), + callerKey: 'client-1', + replacementSessionId + }) + raw.records['session-alpha'].conversationCommand = cleared('clear-one') + raw.records['clear-one'].conversationCommand = cleared('clear-two') + raw.records['clear-two'].surfaceTabId = 'structured-agent-session-clear-two' + delete raw.sessionTabs + raw.visibleSessionIds = ['session-alpha', 'clear-two'] + await writeFile(filePath(), JSON.stringify(raw)) + + const reopened = await open() + expect(reopened.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) + const reopenedTab = reopened.getSessionTabId('session-alpha') + expect(reopenedTab).not.toBe(LEGACY_TAB_ID) + expect(reopenedTab).not.toContain(':') + expect(reopened.listVisibleSessionIds()).toEqual(['session-alpha', 'clear-two']) + // Seeding is part of reading, so it must give the same ids on every read of the same bytes. + expect((await open()).getSessionTabId('session-alpha')).toBe(reopenedTab) + }) + + it('reads the table, never the record field, once the table is on disk', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + await first.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + const raw = await readFileJson() + raw.records['session-alpha'].surfaceTabId = 'tab-stale' + await writeFile(filePath(), JSON.stringify(raw)) + expect((await open()).getSessionTabId('session-alpha')).toBe('tab-alpha') + }) + + it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + const raw = await readFileJson() + delete raw.sessionTabs + raw.visibleSessionIds = ['session-alpha'] + raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' + await writeFile(filePath(), JSON.stringify(raw)) + const reopened = await open() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(false) + expect(reopened.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) + }) + + it('treats a malformed table as a corrupt store rather than guessing', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + const raw = await readFileJson() + raw.sessionTabs = [ + { tabId: 'tab-alpha', sessionId: 'session-alpha' }, + { tabId: 'tab-alpha', sessionId: 'session-beta' } + ] + await writeFile(filePath(), JSON.stringify(raw)) + await expect(open()).rejects.toThrow('agent_session_store_corrupt') + }) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 58817a7e59c..445260cc47b 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,4 +1,3 @@ -import { setVisibleSessionId } from './agent-session-visible-tab-index' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' /** Durable single-writer session records and their operation ledger. */ @@ -69,9 +68,9 @@ import { import { agentSessionStoreRevision, agentSessionStorePath, - type AgentSessionStoreState, - backfillAgentSessionSurfaceTabIds + type AgentSessionStoreState } from './agent-session-record-store-file' +import { setAgentSessionTabVisibility } from './agent-session-tab-table' import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' import { AgentSessionStoreTransactionQueue, @@ -90,19 +89,14 @@ export class AgentSessionRecordStore { // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart // grants no writer on the strength of what the previous process wrote. const diskRevision = agentSessionStoreRevision(loaded.state) - // After the revision, so the file still hashes to what was read. The filled ids, like the - // normalized legacy leases, reach disk with this store's first transaction rather than a write - // here: a rewrite at open would read as an external change to any other holder of the file - // mid-restart. - const backfilled = backfillAgentSessionSurfaceTabIds(loaded.state) + // The normalized legacy leases reach disk with this store's first transaction rather than a + // write here: a rewrite at open would read as an external change to any other holder of the + // file mid-restart. markAgentSessionStoreLeasesUnreconciled(loaded.state) const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( filePath, args.hostId, - { - ...loaded, - needsRewrite: loaded.needsRewrite || backfilled > 0 || loaded.legacyHandoffLeasesNormalized - }, + { ...loaded, needsRewrite: loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized }, diskRevision ) if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { @@ -133,16 +127,25 @@ export class AgentSessionRecordStore { listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] listVisibleSessionIds = (): string[] => - [...this.state.visibleSessionIds].filter((sessionId) => this.state.records.has(sessionId)) + (this.state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => + this.state.records.has(sessionId) + ) getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ - present: this.state.visibleSessionIdsIndexPresent, + present: this.state.sessionTabs !== null, sessionIds: this.listVisibleSessionIds() }) - /** Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. */ - setSessionTabVisibility(sessionId: string, visible: boolean): Promise { - return this.transact(() => setVisibleSessionId(this.state, sessionId, visible)) + /** The id of the chat tab showing this conversation, if one does. */ + getSessionTabId = (sessionId: string): string | null => + this.state.sessionTabs?.tabIdFor(sessionId) ?? null + + /** + * Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. + * Showing keeps a tab the session already has; `tabId` puts a hidden one back under its old id. + */ + setSessionTabVisibility(sessionId: string, visible: boolean, tabId?: string): Promise { + return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) } listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts index f3f2ba91d2a..9fdbe393334 100644 --- a/src/main/runtime/agent-session-recovery-publish-fault.test.ts +++ b/src/main/runtime/agent-session-recovery-publish-fault.test.ts @@ -48,8 +48,7 @@ function state(generation: number): AgentSessionStoreState { operations: new Map(), retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], unreadableRecords: new Map(), - visibleSessionIds: new Set(), - visibleSessionIdsIndexPresent: false + sessionTabs: null } } diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 83e9c610056..1c1872c2469 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -69,8 +69,7 @@ function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionSt operations: new Map(), retiredClaimKeys: [], unreadableRecords: new Map(), - visibleSessionIds: new Set(), - visibleSessionIdsIndexPresent: true + sessionTabs: null } } diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 4bd3bf9b4db..e838bb63c56 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -56,8 +56,8 @@ export type AgentSessionReserveRequest = { launchEnv?: AgentSessionLaunchEnv /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> - /** The tab id this conversation shows under. Pinned on first reservation; a later reservation of - * an existing record keeps the record's own. Refused when another record already holds it. */ + /** The tab id a create reserved for this conversation, taken when its tab is published. An id + * another session's tab holds is refused here, before anything is spawned. */ surfaceTabId?: string /** Set only when this create adopts an existing provider conversation. Seeds the handle chain so * the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */ @@ -173,7 +173,7 @@ export function applyAgentSessionReservation( if (request.expectedFence !== null) { throw new Error('agent_session_checkpoint_stale') } - assertSurfaceTabIdUnheld(state, request) + assertReservedTabUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } } if ( @@ -194,6 +194,7 @@ export function applyAgentSessionReservation( if (request.expectedFence === null && !recreatable) { throw new Error('agent_session_conflict') } + assertReservedTabUnheld(state, request) const pinned = { ...existing, ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), @@ -242,9 +243,12 @@ function assertAdoptedConversationUnowned( } } -/** A tab id names one conversation. Two records under one id would give two chats one tab, one - * read-state key and one notification id, so the second reservation is refused as a conflict. */ -function assertSurfaceTabIdUnheld( +/** + * A tab id names one conversation, so a reserved id another session's tab holds is a conflict. + * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never + * gets that far leaves nothing in the table to restore or release. + */ +function assertReservedTabUnheld( state: AgentSessionStoreState, request: AgentSessionReserveRequest ): void { @@ -254,10 +258,9 @@ function assertSurfaceTabIdUnheld( if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { throw new Error('agent_session_operation_invalid') } - for (const record of state.records.values()) { - if (record.sessionId !== request.sessionId && record.surfaceTabId === request.surfaceTabId) { - throw new Error('agent_session_conflict') - } + const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) + if (holder !== undefined && holder !== request.sessionId) { + throw new Error('agent_session_conflict') } } @@ -276,7 +279,6 @@ function createAgentSessionRecord( accountHome: request.accountHome, ...(request.options ? { options: { ...request.options } } : {}), ...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), - ...(request.surfaceTabId ? { surfaceTabId: request.surfaceTabId } : {}), createdAt: request.now, updatedAt: request.now, lease: { diff --git a/src/main/runtime/agent-session-store-serialization.ts b/src/main/runtime/agent-session-store-serialization.ts index cda3e44d7ff..adc93f85c01 100644 --- a/src/main/runtime/agent-session-store-serialization.ts +++ b/src/main/runtime/agent-session-store-serialization.ts @@ -1,4 +1,5 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { serializeAgentSessionTabTable } from './agent-session-tab-table' export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string { const records: Record = Object.create(null) @@ -13,8 +14,8 @@ export function serializeAgentSessionStoreState(state: AgentSessionStoreState): retiredClaimKeys: state.retiredClaimKeys, unusableRecords: Object.fromEntries(state.unreadableRecords) } - if (state.visibleSessionIdsIndexPresent) { - serialized.visibleSessionIds = [...state.visibleSessionIds] + if (state.sessionTabs) { + Object.assign(serialized, serializeAgentSessionTabTable(state.sessionTabs)) } return JSON.stringify(serialized) } diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts index 08d04996d81..38b65ef6249 100644 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -7,8 +7,7 @@ import { loadAgentSessionStore, saveAgentSessionStore, type AgentSessionStoreState, - type LoadedAgentSessionStore, - backfillAgentSessionSurfaceTabIds + type LoadedAgentSessionStore } from './agent-session-record-store-file' import { withFileTransactionLock } from '../file-transaction-lock' @@ -39,16 +38,15 @@ function agentSessionStoreStateChanged( operations: ReadonlyMap, retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], unreadableRecords: AgentSessionStoreState['unreadableRecords'], - visibleSessionIds: AgentSessionStoreState['visibleSessionIds'], - visibleSessionIdsIndexPresent: AgentSessionStoreState['visibleSessionIdsIndexPresent'] + sessionTabs: AgentSessionStoreState['sessionTabs'] ): boolean { return ( !mapEntriesMatch(state.records, records) || !mapEntriesMatch(state.operations, operations) || !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || - state.visibleSessionIdsIndexPresent !== visibleSessionIdsIndexPresent || - state.visibleSessionIds.size !== visibleSessionIds.size || - [...state.visibleSessionIds].some((id) => !visibleSessionIds.has(id)) || + (state.sessionTabs && sessionTabs + ? !state.sessionTabs.equals(sessionTabs) + : state.sessionTabs !== sessionTabs) || state.retiredClaimKeys.length !== retiredClaimKeys.length || state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) ) @@ -100,8 +98,7 @@ export class AgentSessionStoreTransactionQueue { const operations = new Map(this.state.operations) const retiredClaimKeys = [...this.state.retiredClaimKeys] const unreadableRecords = new Map(this.state.unreadableRecords) - const visibleSessionIds = new Set(this.state.visibleSessionIds) - const visibleSessionIdsIndexPresent = this.state.visibleSessionIdsIndexPresent + const sessionTabs = this.state.sessionTabs?.clone() ?? null try { // The lost commit may have granted a higher fence than the backup records show. Rather // than refuse forever, raise every recovered fence clear of anything that commit could @@ -120,8 +117,7 @@ export class AgentSessionStoreTransactionQueue { operations, retiredClaimKeys, unreadableRecords, - visibleSessionIds, - visibleSessionIdsIndexPresent + sessionTabs ) ) { return result @@ -140,8 +136,7 @@ export class AgentSessionStoreTransactionQueue { this.state.operations = operations this.state.retiredClaimKeys = retiredClaimKeys this.state.unreadableRecords = unreadableRecords - this.state.visibleSessionIds = visibleSessionIds - this.state.visibleSessionIdsIndexPresent = visibleSessionIdsIndexPresent + this.state.sessionTabs = sessionTabs throw error } }) @@ -170,11 +165,6 @@ export class AgentSessionStoreTransactionQueue { throw new Error('agent_session_legacy_required') } markLoadedLeasesUnreconciled(loaded.state) - // Why: a reload replaces the state wholesale, so the ids filled at open would vanish from - // memory until the next open; refilling keeps every in-memory record carrying one. It does - // not force a save: a reload marks every lease unadjudicated, and this instance must not - // persist that verdict on the strength of a refill. - backfillAgentSessionSurfaceTabIds(loaded.state) this.state = loaded.state this.diskRevision = diskRevision this.needsRewrite = loaded.needsRewrite diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts new file mode 100644 index 00000000000..3855d431989 --- /dev/null +++ b/src/main/runtime/agent-session-tab-table.ts @@ -0,0 +1,258 @@ +import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +/** + * Which conversation each structured chat tab shows, keyed by the host tab id. + * + * Membership is visibility: a session with an entry has a chat tab, and the key is that tab's id. + * A /clear moves the tab to the replacement conversation rather than copying its id, so an id names + * one conversation by construction and a session maps back to at most one tab. + */ +export class AgentSessionTabTable { + private readonly sessionByTab = new Map() + private readonly tabBySession = new Map() + + constructor(entries: Iterable = []) { + for (const [tabId, sessionId] of entries) { + this.put(tabId, sessionId) + } + } + + tabIdFor(sessionId: string): string | undefined { + return this.tabBySession.get(sessionId) + } + + sessionIdFor(tabId: string): string | undefined { + return this.sessionByTab.get(tabId) + } + + /** Sessions that have a tab, in the order their tabs were given out. */ + sessionIds(): string[] { + return [...this.sessionByTab.values()] + } + + entries(): [tabId: string, sessionId: string][] { + return [...this.sessionByTab.entries()] + } + + /** + * Gives a session a tab unless it already has one. Without a reserved id it gets the id clients + * derive for it, unless a cleared conversation's tab kept that id. + */ + show(sessionId: string, tabId?: string): void { + if (this.tabBySession.has(sessionId)) { + return + } + const derived = structuredAgentSessionTabId(sessionId) + const held = (candidate: string): boolean => this.sessionByTab.has(candidate) + this.put(tabId ?? (held(derived) ? reopenedTabId(sessionId, held) : derived), sessionId) + } + + /** Returns the id the session's tab had, if it had one. */ + hide(sessionId: string): string | undefined { + const tabId = this.tabBySession.get(sessionId) + if (tabId !== undefined) { + this.tabBySession.delete(sessionId) + this.sessionByTab.delete(tabId) + } + return tabId + } + + /** A /clear: the tab that showed `fromSessionId` shows `toSessionId`, keeping its id and place. */ + move(fromSessionId: string, toSessionId: string): void { + const tabId = this.tabBySession.get(fromSessionId) + this.hide(toSessionId) + if (tabId === undefined) { + this.show(toSessionId) + return + } + this.tabBySession.delete(fromSessionId) + this.sessionByTab.set(tabId, toSessionId) + this.tabBySession.set(toSessionId, tabId) + } + + clone(): AgentSessionTabTable { + return new AgentSessionTabTable(this.sessionByTab) + } + + equals(other: AgentSessionTabTable): boolean { + const left = this.entries() + const right = other.entries() + return ( + left.length === right.length && + left.every(([tabId, sessionId], index) => { + const [otherTabId, otherSessionId] = right[index] + return tabId === otherTabId && sessionId === otherSessionId + }) + ) + } + + private put(tabId: string, sessionId: string): void { + const owner = this.sessionByTab.get(tabId) + if (owner !== undefined && owner !== sessionId) { + throw new Error('agent_session_conflict') + } + this.hide(sessionId) + this.sessionByTab.set(tabId, sessionId) + this.tabBySession.set(sessionId, tabId) + } +} + +/** + * The id a cleared conversation reopened from history takes while the tab now showing its + * replacement holds its own. Deterministic, so a table seeded again gives it the same id. + */ +function reopenedTabId(sessionId: string, held: (tabId: string) => boolean): string { + const base = `${structuredAgentSessionTabId(sessionId)}-reopened` + let tabId = base + for (let suffix = 2; held(tabId); suffix++) { + tabId = `${base}-${suffix}` + } + return tabId +} + +export function setAgentSessionTabVisibility( + state: AgentSessionStoreState, + sessionId: string, + visible: boolean, + tabId?: string +): void { + if (visible && !state.records.has(sessionId)) { + throw new Error('agent_session_identity_required') + } + state.sessionTabs ??= new AgentSessionTabTable() + if (visible) { + state.sessionTabs.show(sessionId, tabId) + } else { + state.sessionTabs.hide(sessionId) + } +} + +export type PersistedAgentSessionTab = { tabId: string; sessionId: string } + +export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { + sessionTabs: PersistedAgentSessionTab[] + visibleSessionIds: string[] +} { + return { + sessionTabs: table.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })), + // Written for older builds, which restore tabs from this list; never read beside the table. + visibleSessionIds: table.sessionIds() + } +} + +/** + * Reads the persisted table, or seeds it from what older builds wrote: the visible session list and, + * for a chat created by a build that recorded one, the tab id on its record. That record field is + * read here and nowhere else, and only when the file carries no table. + * + * Deterministic on purpose: a parsed store must hash the same on every read of the same bytes. + */ +export function parseAgentSessionTabTable( + file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, + records: ReadonlyMap, + strict: boolean +): { valid: boolean; table: AgentSessionTabTable | null } { + if (file.sessionTabs !== undefined) { + return parsePersistedTabs(file.sessionTabs, strict) + } + if (file.visibleSessionIds === undefined) { + return { valid: true, table: null } + } + if (!Array.isArray(file.visibleSessionIds)) { + return { valid: !strict, table: null } + } + return { valid: true, table: seedFromVisibleSessions(file.visibleSessionIds, records) } +} + +/** + * A cleared chat's tab was opened for the first conversation of its /clear chain and kept that id + * through every clear, so the chat now showing the chain's latest conversation seeds under the + * first one's id, as a /clear on this build would have left it. Those chats seed first: a cleared + * conversation reopened from history is the later tab, and takes a fresh id if its own is held. + */ +function seedFromVisibleSessions( + visible: readonly unknown[], + records: ReadonlyMap +): AgentSessionTabTable { + const sessionIds = [...new Set(visible.filter(isAgentSessionId))] + const clearedFrom = new Map() + for (const record of records.values()) { + const command = record.conversationCommand + if ( + command?.command === 'clear' && + command.phase === 'committed' && + command.replacementSessionId && + !clearedFrom.has(command.replacementSessionId) + ) { + clearedFrom.set(command.replacementSessionId, record.sessionId) + } + } + const clearedTo = new Set(clearedFrom.values()) + const chainRoot = (sessionId: string): string => { + const seen = new Set([sessionId]) + let current = sessionId + let prior = clearedFrom.get(current) + while (prior !== undefined && !seen.has(prior)) { + seen.add(prior) + current = prior + prior = clearedFrom.get(current) + } + return current + } + const recordedOrDerived = (sessionId: string): string[] => { + const record = records.get(sessionId) + const recorded = record && 'surfaceTabId' in record ? record.surfaceTabId : undefined + return [recorded, structuredAgentSessionTabId(sessionId)].filter(isAgentSessionSurfaceTabId) + } + const tabIds = new Map() + const taken = new Set() + const held = (tabId: string): boolean => taken.has(tabId) + const assign = (sessionId: string, candidates: readonly string[]): void => { + const tabId = candidates.find((candidate) => !held(candidate)) ?? reopenedTabId(sessionId, held) + taken.add(tabId) + tabIds.set(sessionId, tabId) + } + const holdsChainTab = (sessionId: string): boolean => + !clearedTo.has(sessionId) && chainRoot(sessionId) !== sessionId + for (const sessionId of sessionIds.filter(holdsChainTab)) { + assign(sessionId, [...recordedOrDerived(chainRoot(sessionId)), ...recordedOrDerived(sessionId)]) + } + for (const sessionId of sessionIds.filter((sessionId) => !holdsChainTab(sessionId))) { + assign(sessionId, recordedOrDerived(sessionId)) + } + // In the visible list's order, which is the order older builds restored tabs in. + return new AgentSessionTabTable( + sessionIds.flatMap((sessionId) => { + const tabId = tabIds.get(sessionId) + return tabId === undefined ? [] : [[tabId, sessionId] as const] + }) + ) +} + +function parsePersistedTabs( + raw: unknown, + strict: boolean +): { valid: boolean; table: AgentSessionTabTable | null } { + if (!Array.isArray(raw)) { + return { valid: !strict, table: null } + } + const table = new AgentSessionTabTable() + for (const entry of raw) { + const tabId: unknown = entry?.tabId + const sessionId: unknown = entry?.sessionId + const wellFormed = + isAgentSessionSurfaceTabId(tabId) && + isAgentSessionId(sessionId) && + table.sessionIdFor(tabId) === undefined && + table.tabIdFor(sessionId) === undefined + if (wellFormed) { + table.show(sessionId, tabId) + } else if (strict) { + return { valid: false, table: null } + } + } + return { valid: true, table } +} diff --git a/src/main/runtime/agent-session-visible-tab-index.ts b/src/main/runtime/agent-session-visible-tab-index.ts deleted file mode 100644 index e0f7b9b4c5c..00000000000 --- a/src/main/runtime/agent-session-visible-tab-index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentSessionStoreState } from './agent-session-record-store-file' -export function parseVisibleSessionIds( - raw: unknown, - schemaVersion: number, - currentSchemaVersion: number -): { ids: string[]; present: boolean; valid: boolean } { - if (raw === undefined) { - return { ids: [], present: false, valid: true } - } - if (!Array.isArray(raw)) { - return { ids: [], present: false, valid: schemaVersion !== currentSchemaVersion } - } - const ids: string[] = [] - for (const value of raw) { - if (typeof value === 'string' && value.length > 0) { - ids.push(value) - } else if (schemaVersion === currentSchemaVersion) { - return { ids: [], present: true, valid: false } - } - } - return { ids, present: true, valid: true } -} - -export function setVisibleSessionId( - state: AgentSessionStoreState, - sessionId: string, - visible: boolean -): void { - if (visible) { - if (!state.records.has(sessionId)) { - throw new Error('agent_session_identity_required') - } - state.visibleSessionIds.add(sessionId) - } else { - state.visibleSessionIds.delete(sessionId) - } - state.visibleSessionIdsIndexPresent = true -} diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index d53b465d97d..f22a09bac5e 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -95,10 +95,16 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu activate: boolean notify?: boolean replacesSessionId?: string + /** The host tab id a create reserved; a session that already has a tab keeps its own. */ + tabId?: string }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(input.sessionId, true) + await host.setSessionTabVisibility( + input.sessionId, + true, + ...(input.tabId ? [input.tabId] : []) + ) } const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index ab52c2aa855..1a385922d2a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -43,7 +43,7 @@ export type PreparedStructuredAgentSessionCreate = { * intent, not a detail of it: without it a retry of "adopt this conversation" would replay as, or * conflict with, a blank create. `tabId` is covered so the declared digest spans the payload, but * replay keys on the attach fingerprint, so a retry naming another tab is answered with the one the - * record holds. The canonicalizer drops `undefined`, so plain creates keep the digest they had. + * chat's tab holds. The canonicalizer drops `undefined`, so plain creates keep the digest they had. */ export function structuredAgentSessionCreateIntentFingerprint(params: { envelope: AgentSessionMutationEnvelope @@ -79,8 +79,8 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { * `--model`/`--effort` the dispatch asked for; a chat the user opened passes nothing and keeps * the saved selection. Narrowed by the caller, so `{}` never reaches the reservation. */ options?: Readonly> - /** The tab id the caller reserved for this chat, so its placement is recorded before the reply; - * absent records the id clients derive. Beside `options`, after the fingerprint, likewise. */ + /** The tab id the caller reserved for this chat, taken when its tab is published; absent, the tab + * gets the id clients derive. Beside `options`, after the fingerprint, likewise. */ tabId?: string }): Promise { // Adoption replay may need the record loaded from disk before source discovery can be skipped. @@ -131,12 +131,14 @@ export async function commitStructuredAgentSessionCreate(args: { if (!result.ok || !prepared.tab) { return result } + const surfaceTabId = prepared.attachParams.surfaceTabId try { await args.runtime.publishStructuredAgentSessionTab({ workspaceId: prepared.tab.workspaceId, sessionId: result.value.sessionId, agent: prepared.tab.agent, - activate: args.activate + activate: args.activate, + ...(surfaceTabId ? { tabId: surfaceTabId } : {}) }) } catch (error) { console.warn('[agent-session] create committed before tab publication failed', error) @@ -148,7 +150,9 @@ export async function commitStructuredAgentSessionCreate(args: { } } } - return result + // Read after publishing, which is what gives the chat its tab. + const tabId = prepared.host.getSessionTabId?.(result.value.sessionId) + return tabId ? { ...result, value: { ...result.value, tabId } } : result } export async function createStructuredAgentSessionForWorktree(args: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts new file mode 100644 index 00000000000..f76b3ae6cb7 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -0,0 +1,409 @@ +/** + * A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk, + * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. + */ + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW, + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { agentSessionStorePath } from '../../agent-session-record-store-file' +import { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' +import { SESSION_TAB_METHODS } from './session-tabs' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' +import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' + +const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` +const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` +const caller = { callerKey: 'trusted-local:runtime' } + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let runtime: OrcaRuntimeService +let dispatcher: RpcDispatcher +let acquisitions = 0 +let acquireFails = false +let closeSession: ReturnType Promise>> + +function providerAdapter(): StructuredAgentSessionAdapter { + return { + supportsLocation: (location) => + location.executionHostId === 'local' && location.wslDistro === null, + acquire: vi.fn(async (input) => { + if (acquireFails) { + throw new Error('provider failed to start') + } + acquisitions++ + return { + process: { + hostId: 'local', + pid: 4000 + acquisitions, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + link: { + linkId: `link-${acquisitions}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: 'created' as const, + handle: { + provider: 'codex' as const, + threadId: `00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}` + } + } + } + }), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: async () => {}, + setOption: async () => {}, + releaseAcquisition: async () => true, + closeSession, + readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }) + } +} + +async function openHost(): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = new StructuredAgentSessionHost({ + store, + adapter: providerAdapter(), + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + mintSpawnToken: () => `spawn-${acquisitions}` + }) + setStructuredAgentSessionHost(host) +} + +type CallResponse = { + ok: boolean + result?: { ok?: boolean; value?: { replacementSessionId?: string } } +} + +async function call( + method: string, + params: unknown, + context: RpcDispatchStreamingOptions = {} +): Promise { + const response = await dispatcher.dispatch( + { id: 'request', authToken: 'token', method, params }, + context + ) + return JSON.parse(JSON.stringify(response)) +} + +async function createChat(sessionId: string, tabId?: string) { + return commitStructuredAgentSessionCreate({ + runtime, + caller, + activate: true, + prepared: { + host, + attachParams: hostTestAttachParams(null, { + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + ...(tabId ? { surfaceTabId: tabId } : {}) + }), + tab: { workspaceId: HOST_TEST_LOCATION.workspaceId, agent: 'codex' } + } + }) +} + +function envelopeFor(method: string, sessionId: string, fields: Record) { + return { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) + } +} + +async function clear(sessionId: string): Promise { + const response = await call('agentSession.conversationCommand', { + command: 'clear', + envelope: envelopeFor('agentSession.conversationCommand', sessionId, { command: 'clear' }) + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + const replacement = response.result?.value?.replacementSessionId + expect(replacement).toBeDefined() + return replacement! +} + +async function send(sessionId: string, text: string) { + const body = hostTestMessage(text) + return call('agentSession.send', { + body, + envelope: envelopeFor('agentSession.send', sessionId, { body }) + }) +} + +async function snapshot() { + return runtime.listMobileSessionTabs(WORKTREE) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquisitions = 0 + acquireFails = false + closeSession = vi.fn(async () => true) + directory = await mkdtemp(join(tmpdir(), 'orca-chat-tab-table-')) + runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockReturnValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths. + { experimentalStructuredNativeChat: true } as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ + runtime, + methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS] + }) + await openHost() +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + setStructuredAgentSessionHost(null) + await rm(directory, { recursive: true, force: true }) +}) + +describe('a chat tab across /clear', () => { + it('keeps sending through a second and third /clear, the tab following each replacement', async () => { + expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) + let current = HOST_TEST_SESSION + // A pending send blocks /clear by design, so the clears run back to back and the chat sends after. + for (let round = 0; round < 3; round++) { + const replacement = await clear(current) + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect(store.getSessionTabId(current)).toBeNull() + current = replacement + } + expect(await send(current, 'after three clears')).toMatchObject({ + ok: true, + result: { ok: true } + }) + const tabs = (await snapshot()).tabs + expect(tabs).toHaveLength(1) + expect(tabs[0]).toMatchObject({ type: 'agent-session', sessionId: current }) + expect(store.listVisibleSessionIds()).toEqual([current]) + }) + + it('reveals a cleared conversation in its own tab without activating the current chat', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true + }) + + const revealedTabId = store.getSessionTabId(HOST_TEST_SESSION) + expect(revealedTabId).not.toBeNull() + expect(revealedTabId).not.toBe(SOURCE_TAB) + expect(revealedTabId).not.toContain(':') + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + const published = await snapshot() + expect(published.tabs.map((tab) => tab.id)).toEqual([ + `agent-session:${replacement}`, + `agent-session:${HOST_TEST_SESSION}` + ]) + expect(published.activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + }) + + it('closes the cleared conversation and leaves the current chat and its tab', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + + expect( + await call('session.tabs.close', { + worktree: WORKTREE, + tabId: `agent-session:${HOST_TEST_SESSION}`, + reason: 'user' + }) + ).toMatchObject({ ok: true }) + + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`]) + expect(await send(replacement, 'still here')).toMatchObject({ ok: true, result: { ok: true } }) + }) + + it('puts a cleared chat back under the tab id it had when its close does not land', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + closeSession.mockResolvedValue(false) + + const outcome = await closeStructuredAgentSessionChild(replacement) + expect(outcome).toMatchObject({ stopped: false }) + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + closeSession.mockResolvedValue(true) + }) + + it('keeps the tab id and its pointer across a restart', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(await clear(HOST_TEST_SESSION)) + await host.flushAllStreamedEvents() + + await openHost() + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) + expect(host.getPersistedVisibleSessionTabIndex()).toEqual({ + present: true, + sessionIds: [replacement] + }) + }) + + it('gives a reopened cleared conversation the same id when an older build drops the table', async () => { + await createChat(HOST_TEST_SESSION) + const first = await clear(HOST_TEST_SESSION) + await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) + const current = await clear(first) + const reopenedTab = store.getSessionTabId(HOST_TEST_SESSION) + expect(reopenedTab).not.toBeNull() + await host.flushAllStreamedEvents() + + // An older build rewrites the file from what it read, which drops the table. + const file = agentSessionStorePath(join(directory, 'store')) + const raw = JSON.parse(await readFile(file, 'utf-8')) + expect(raw.sessionTabs).toHaveLength(2) + delete raw.sessionTabs + await writeFile(file, JSON.stringify(raw)) + + await openHost() + expect(store.getSessionTabId(current)).toBe(SOURCE_TAB) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(reopenedTab) + }) +}) + +describe('session tab mutations from other clients, unchanged by the table', () => { + it('reorders a group holding a chat for a paired client', async () => { + await createChat(HOST_TEST_SESSION) + await createChat('session-bravo') + const ids = (await snapshot()).tabs.map((tab) => tab.id) + const group = (await snapshot()).tabGroups![0]! + + expect( + await call( + 'session.tabs.move', + { + worktree: WORKTREE, + tabId: ids[1], + targetGroupId: group.id, + kind: 'reorder', + tabOrder: ids.toReversed() + }, + { + clientKind: 'runtime', + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + } + ) + ).toMatchObject({ ok: true }) + expect((await snapshot()).tabGroups![0]!.tabOrder).toEqual(ids.toReversed()) + }) + + it('refuses an old mobile client closing a chat it may only view', async () => { + await createChat(HOST_TEST_SESSION) + + const response = await call( + 'session.tabs.close', + { worktree: WORKTREE, tabId: `agent-session:${HOST_TEST_SESSION}`, reason: 'user' }, + { clientKind: 'mobile', clientCapabilities: [] } + ) + + expect(response.ok).toBe(false) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB) + expect((await snapshot()).tabs).toHaveLength(1) + }) +}) + +describe('a create that reserves its tab', () => { + it('answers with the reserved id and refuses a second chat under it', async () => { + expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ + ok: true, + value: { tabId: 'reserved-tab' } + }) + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict' } + }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe('reserved-tab') + expect(store.getRecord('session-bravo')).toBeNull() + }) + + it('answers a create that reserved nothing with the id its tab was given', async () => { + expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ + ok: true, + value: { tabId: SOURCE_TAB } + }) + }) + + it('restores no tab for a reserved create that stopped before its tab was published', async () => { + const attached = await host.attach( + caller, + hostTestAttachParams(null, { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + surfaceTabId: 'reserved-tab' + }) + ) + expect(attached).toMatchObject({ ok: true }) + await host.flushAllStreamedEvents() + + await openHost() + expect(host.getPersistedVisibleSessionTabIndex().sessionIds).toEqual([]) + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + ok: true, + value: { tabId: 'reserved-tab' } + }) + }) + + it('leaves no tab behind when the create fails, so nothing is restored and the id is free', async () => { + acquireFails = true + expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: false }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + expect(store.listVisibleSessionIds()).toEqual([]) + + acquireFails = false + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + ok: true, + value: { tabId: 'reserved-tab' } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts index 531ca0aa129..948bb01cce2 100644 --- a/src/main/runtime/structured-agent-session-close.test.ts +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -19,6 +19,7 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') const SESSION = 'session-1' +const TAB_ID = 'tab-of-session-1' function record(sessionId: string): AgentSessionRecord { return { @@ -85,14 +86,18 @@ function installHost(options: HostOptions = {}) { } }) hostRef.current = { - deps: { store: { getRecord: (id: string) => (id === SESSION ? entry : null) } }, - hasSession: (sessionId: string) => held.has(sessionId), - getPersistedVisibleSessionTabIndex: () => { - if (options.indexThrows) { - throw new Error('visible tab index unreadable') + deps: { + store: { + getRecord: (id: string) => (id === SESSION ? entry : null), + getSessionTabId: (id: string) => { + if (options.indexThrows) { + throw new Error('visible tab index unreadable') + } + return visible.has(id) ? TAB_ID : null + } } - return { present: true, sessionIds: [...visible] } }, + hasSession: (sessionId: string) => held.has(sessionId), setSessionTabVisibility, close } @@ -133,7 +138,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ [SESSION, false], - [SESSION, true] + [SESSION, true, TAB_ID] ]) }) @@ -147,7 +152,7 @@ describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { expect(host.visible.has(SESSION)).toBe(true) expect(host.setSessionTabVisibility.mock.calls).toEqual([ [SESSION, false], - [SESSION, true] + [SESSION, true, TAB_ID] ]) }) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index f65d1204db9..9afb2723a88 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -60,11 +60,11 @@ export async function closeStructuredAgentSessionChild( reason: 'The structured agent-session host is not installed; no session was closed.' } } - // Read BEFORE the hide, so a rollback puts the tab back exactly as it was. Restoring - // unconditionally would publish a tab for a session that was already hidden — a worker started - // without a chat tab, or one the user had closed — which is a new side effect, not an undo. + // Read BEFORE the hide, so a rollback puts the tab back exactly as it was, under the same id. + // Restoring unconditionally would publish a tab for a session that was already hidden — a worker + // started without a chat tab, or one the user had closed — which is a new side effect, not an undo. const restoreTabIfCloseFails = - options.restoreTabOnUnprovenClose !== false && readPersistedTabVisibility(host, sessionId) + options.restoreTabOnUnprovenClose !== false ? readPersistedTabId(host, sessionId) : null // Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a // running child, and a receipt that still said `closed_agent_terminal` for it would be the // close-that-never-happened this flag exists to rule out. @@ -101,13 +101,13 @@ export async function closeStructuredAgentSessionChild( return { stopped: true, closeAttempted: true } } -function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: string): boolean { +function readPersistedTabId(host: StructuredAgentSessionHost, sessionId: string): string | null { try { - return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + return host.deps.store.getSessionTabId(sessionId) } catch { // Unreadable index: claim nothing. A rollback that cannot prove the tab was visible must not // publish one, for the same reason the read exists at all. - return false + return null } } @@ -135,13 +135,13 @@ function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: async function restorePersistedTabVisibility( host: StructuredAgentSessionHost, sessionId: string, - restoreTab: boolean + tabId: string | null ): Promise { - if (!restoreTab || observeStructuredWorker({ sessionId }).status === 'exited') { + if (tabId === null || observeStructuredWorker({ sessionId }).status === 'exited') { return } try { - await host.setSessionTabVisibility?.(sessionId, true) + await host.setSessionTabVisibility?.(sessionId, true, tabId) } catch (error) { console.warn( `[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`, diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 915fbd52986..602aaa3d3aa 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -89,7 +89,13 @@ function installHost(options: { } } hostRef.current = { - deps: { store: { listRecords: () => options.records, getRecord: () => null } }, + deps: { + store: { + listRecords: () => options.records, + getRecord: () => null, + getSessionTabId: (sessionId: string) => (visible.has(sessionId) ? `tab-${sessionId}` : null) + } + }, hasSession: (sessionId: string) => held.has(sessionId), getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }), setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => { diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 46265a16111..f79d5d12812 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -307,7 +307,7 @@ export async function closeStructuredSessionsForWorktree( * The close path already does this for a session it CLOSED. This is the complement: the members * with no attached child, which the close list never contained and nothing else will hide. Their * durable reference survives every purge a removal already performs: the renderer drops `unifiedTabsByWorktree` and the main - * process drops the workspace metadata, and neither touches `visibleSessionIds`. Startup replays + * process drops the workspace metadata, and neither touches the chat tab table. Startup replays * that index, restores the session from it and republishes the tab, so the chat comes back at the * next launch pointing at a workspace that is gone. Worktree ids are path-derived and can be * recreated, so a later workspace at the same path inherits the tab — which is the hazard diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 7e68340fdba..016b759d877 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -105,7 +105,7 @@ export type AgentLaunchOutcome = sessionId: string handle: string /** The host-owned id of the tab that shows this chat: the tab half of the reserved `paneKey` - * when one was sent, else the one the host recorded. Identity, not placement, like the + * when one was sent, else the one the host gave its tab. Identity, not placement, like the * terminal arm's `paneKey`. Absent from hosts that predate it. */ tabId?: string } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 6cfe07e70b4..0be26cb40f9 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -1,6 +1,5 @@ import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind' import { isAgentSessionLaunchArgs } from './agent-session-launch-args' -import { isAgentSessionSurfaceTabId } from './agent-session-surface-tab-id' import { isAgentSessionConversationName } from './agent-session-conversation-name' import { isPersistedAgentSessionHandoffStage, @@ -142,9 +141,6 @@ export type AgentSessionRecord = { /** The name Orca gave this conversation, so a later acquisition need not name it again. */ conversationName?: string launchArgs?: AgentSessionLaunchArgs - /** The id of the tab that shows this conversation on every client: host-owned and pinned once; - * readers copy it, never derive it. Older records are backfilled at load with the derived id. */ - surfaceTabId?: string lease: AgentSessionLease createdAt: number updatedAt: number @@ -357,7 +353,6 @@ export function isPersistedAgentSessionRecord( (record.conversationName === undefined || isAgentSessionConversationName(record.conversationName)) && (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && - (record.surfaceTabId === undefined || isAgentSessionSurfaceTabId(record.surfaceTabId)) && !Object.hasOwn(record, 'launchEnv') && isPersistedAgentSessionLease(record.lease) && record.lease.sessionId === record.sessionId && diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 38849ab0f05..7118a2237eb 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -297,7 +297,7 @@ export type AgentSessionAttachResult = { page: AgentSessionHistoryPage /** Submissions the crash boundary settled as `unknown` while attaching. */ unconfirmedClientMessageIds: string[] - /** The host-owned id of the tab that shows this chat. Absent from hosts that predate it. */ + /** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */ tabId?: string } From 01ce5edf1aeec8a4b5cf06f531434be7d7c0cbcd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:00:48 -0700 Subject: [PATCH 008/387] fix(claude): end a Claude chat on its root's exit even when a descendant survived (#22946) * fix(claude): end a Claude chat on its root's exit even when a descendant survived When Claude's own process exited but a descendant it started survived the close ladder (for example an MCP server that ignores SIGTERM and was born in the second the tree was snapshotted, which the ladder never force-kills), the adapter withheld `ended`, and both the lease release and every later start refused with "provider close unproven". The chat stayed stuck until Orca restarted. The root is the conversation's only writer and the lease follows it, so a first-hand root exit now ends the session whatever the tree verdict. `claudeRootExitObserved` is the one place that decides it, and crash publication, close finalization and acquisition all read it. A descendant seen alive is logged, and never reported as gone. * test(claude): drop comments that still say a live descendant holds the lease * docs(claude): state the root-exit rule without an unqualified only-writer claim * test(claude): drop the remaining unqualified only-writer claims from root-exit tests * docs(runtime): say a root-exit settlement's descendants were not proven gone * docs(claude): say exit recovery also publishes on an observed root exit * docs(claude): an observed root exit also settles a retained exit * docs(claude): say an observed root exit is dropped by its own settlement, not the release --- .../claude-agent-sdk-exit-proof.test.ts | 4 +- .../claude/claude-agent-sdk-exit-proof.ts | 6 +- .../claude/claude-stream-json-connection.ts | 5 + .../claude-structured-acquisition-release.ts | 4 +- .../claude-structured-session-adapter.test.ts | 57 ++--- .../claude-structured-session-adapter.ts | 4 +- .../claude/claude-structured-session-close.ts | 6 +- ...laude-structured-session-exit-lifecycle.ts | 3 +- .../claude-structured-session-startup.test.ts | 3 +- .../claude/claude-structured-session-state.ts | 2 +- .../structured-agent-session-adapter.ts | 12 +- ...-session-acquisition-failure-settlement.ts | 8 +- ...ude-structured-session-integration.test.ts | 199 +++++++++--------- 13 files changed, 151 insertions(+), 162 deletions(-) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts index 3f15f8e8fca..f9412a10488 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.test.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -432,8 +432,8 @@ describe('claude child tree reaper', () => { const child = mockChild() // Reap #1 completed and saw a descendant alive at its deadline; the root then // left on its own and the re-verification on a loaded host could not read the - // table. "Could not look" must not erase "was seen alive": the lease release - // gate is exactly the pair this distinguishes. + // table. "Could not look" must not erase "was seen alive": the report never + // calls a survivor gone. const terminateDescendants = vi .fn() .mockResolvedValueOnce('live') diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts index 17533f87a70..883bf8efd6a 100644 --- a/src/main/claude/claude-agent-sdk-exit-proof.ts +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -26,7 +26,7 @@ import { /** * A later reap may only raise the latched verdict. An observed exit is final, and * a descendant seen alive at a deadline is never forgotten by a later look that - * could not read the table: the lease gate discriminates on exactly that pair. + * could not read the table, so the report never calls a survivor gone. */ const TREE_VERDICT_TRUST: Record = { unverifiable: 0, @@ -89,8 +89,8 @@ export type ClaudeChildTreeReaper = { reap(): Promise /** * `unverifiable` until a reap observes otherwise. `exited` is the only verdict - * that lets a close release the lease; `live` names a descendant that was seen - * still running, which no later caller may collapse into "unknown". + * that proves a close; `live` names a descendant that was seen still running, + * which no later caller may collapse into "unknown". */ readonly treeVerdict: DescendantTreeVerdict } diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 1a2a65bca34..c6da4419f79 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -315,6 +315,11 @@ export async function openClaudeStreamJsonConnection( }) inbox.fail(new Error('claude stream-json connection closed')) if (!proven) { + if (exited && tree.treeVerdict === 'live') { + console.warn('[claude-stream-json] root exited but a descendant survived the close:', { + pid: spawner.pid + }) + } closePromise = null return false } diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index 6be06c86e93..3df76971df1 100644 --- a/src/main/claude/claude-structured-acquisition-release.ts +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -35,8 +35,8 @@ export async function releaseClaudeAcquisition(input: { const retriedProof = firstProof || (await exit.connection.close()) if (retriedProof) { await input.onExitProven?.(input.sessionId, exit) - // Keep the first-hand exit evidence indexed until the tree proof succeeds; - // a failed close must be retryable and cannot look like an absent session. + // Only a proven close drops the exit here. An unknown one stays indexed so it is retryable and + // cannot look like an absent session; an observed root exit is dropped by its own settlement. input.exits.delete(input.sessionId) return true } diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 04bc6d5ffff..376cac9d52f 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -556,21 +556,18 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { .catch((error: unknown) => error) } - it('releases on a first-hand root exit while still carrying the CLI diagnostic', async () => { - // The root's pid and start time are the lease's identity, and they are - // provably dead: latching the session would strand a signed-out user. - const error = await failedStart({ root: 'exited', tree: 'unverifiable' }) + // The root's pid and start time are the lease's identity, and they are provably dead: latching + // the session would strand a signed-out user. The lease follows the root, so a descendant seen + // alive does not hold the session either. + it.each(['unverifiable', 'live'] as const)( + 'releases on a first-hand root exit with its tree %s while still carrying the CLI diagnostic', + async (tree) => { + const error = await failedStart({ root: 'exited', tree }) - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') - }) - - it('never releases while a descendant was observed alive', async () => { - const error = await failedStart({ root: 'exited', tree: 'live' }) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - }) + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') + } + ) it('never releases for a root Orca never saw leave', async () => { const error = await failedStart({ root: 'live', tree: 'unverifiable' }) @@ -590,27 +587,19 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { return { adapter, connection } } - it('classifies cleanup after a first-hand exit removed the session as a root exit, never as proven', async () => { - // The host may still be committing or proving the lease when the child dies; - // its cleanup must find the exit the ladder observed, not an absence. - const { adapter, connection } = await exitedAfterPublish({ - root: 'exited', - tree: 'unverifiable' - }) - const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) + // The host may still be committing or proving the lease when the child dies; its cleanup must + // find the exit the ladder observed, not an absence. + it.each(['unverifiable', 'live'] as const)( + 'classifies cleanup after a first-hand exit with its tree %s as a root exit, never as proven', + async (tree) => { + const { adapter, connection } = await exitedAfterPublish({ root: 'exited', tree }) + const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') - expect(connection.closeCount).toBe(2) - }) - - it('never releases after an exit that left a descendant observed alive', async () => { - const { adapter } = await exitedAfterPublish({ root: 'exited', tree: 'live' }) - const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - }) + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') + expect(connection.closeCount).toBe(2) + } + ) it('forgets a retained exit once the session is acquired again', async () => { const options: Parameters[0] = {} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e9e233e4934..b8cc5c1dec5 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -109,8 +109,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } /** Resolves once every first-hand exit observed so far has published its - * lifecycle event — or has failed its tree proof and stayed indexed for a - * retry. Publication trails observation by the close ladder and the + * lifecycle event — or, with neither its tree proven gone nor its root's exit + * observed, stayed indexed for a retry. Publication trails observation by the close ladder and the * transcript cursor write, so nothing outside can otherwise tell the two * apart without guessing at wall-clock. */ drainObservedExits = (): Promise => drainClaudeObservedExits(this.exits) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f0bba7ae2b0..e27b2a41e93 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -19,12 +19,12 @@ import { closeProcessRegistry } from '../../shared/child-process/close-process-r import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' -/** The root's own exit was seen first-hand; only its descendants went unverified. */ +/** The root's own exit was seen first-hand. The lease follows the root, so a descendant + * left unverified or seen alive does not hold it. */ export function claudeRootExitObserved( connection: ClaudeStreamJsonConnection | null | undefined ): boolean { - const verdict = connection?.exitVerdict - return verdict?.root === 'exited' && verdict.tree === 'unverifiable' + return connection?.exitVerdict.root === 'exited' } export function claudeAcquisitionCleanupError( diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index fdcfed379c5..4f39a8dc467 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -55,7 +55,8 @@ export function observeClaudeSessionExit( .catch(() => undefined) } -/** Lifecycle recovery is published only after the child tree proof is true. */ +/** Lifecycle recovery is published only after the close ladder ran and proved the tree gone or + * observed the root's own exit. */ export function settleClaudeUnexpectedExit( lifecycle: ClaudeExitLifecycle, sessionId: string, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a106c172c45..362f4d691d0 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -206,8 +206,7 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.drainStartup('session-1') await adapter.drainObservedExits() - // A failed start is released on the same evidence a failed create is; a proven-live - // descendant would have answered `tree: 'live'` instead. + // A failed start is released on the same evidence a failed create is. expect(events.find((event) => event.type === 'ended')).toMatchObject({ cause: 'unexpected-exit', startupUnproven: true diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 7d99aff577b..37cc762c6f5 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -210,7 +210,7 @@ export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAda */ export type ClaudeSessionExit = { connection: ClaudeStreamJsonConnection - /** Full session identity retained until its child tree is proven gone. */ + /** Full session identity retained until the exit settles. */ session: ClaudeSession error: Error /** The exit path's first proof attempt; retries must observe this result. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 27cd3c30240..c99b16d38a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -63,9 +63,9 @@ export class AgentSessionPromptAnswerRejectedError extends Error { /** * The provider's own root process was observed to exit, but its descendant tree - * could not be verified. The lease keys on the root's pid and start time, so its - * observed death releases the reservation; nothing is claimed about descendants. - * Never thrown when a descendant was observed still alive — that stays unproven. + * was not proven gone. The lease keys on the root's pid and start time, so its + * observed death releases the reservation; nothing is claimed about descendants, + * including one seen still alive. */ export class AgentSessionAcquisitionRootExitObservedError extends Error { constructor(cause: unknown) { @@ -195,7 +195,7 @@ export type StructuredAgentSessionAdapter = { /** Reaps an acquired provider when the host cannot commit or prove its lease. * Returns true only after provider child exit is proven. Throws * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own - * exit was observed first-hand but its descendants could not be verified. */ + * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise dispatch(input: { sessionId: string @@ -353,8 +353,8 @@ function provenExitAcquisitionFailure(cause: unknown): unknown { } /** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root - * exit or a processless child ends the session even with descendants unverified; any other - * failure, including known-live descendants, still throws. */ + * exit or a processless child ends the session whatever its descendants did; any other + * failure still throws. */ export async function stopAgentSessionProviderRoot(stop: () => Promise): Promise { try { return (await stop()) === true diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 9335dd230bb..fea8d2e68f5 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -15,8 +15,8 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' * How the failed attempt's provider process was accounted for. * - `exit-proven`: cleanup observed the whole tree gone. * - `root-exit-observed`: the owner root's exit was observed first-hand, so the - * identity this lease is keyed on is dead, but its descendants could not be - * verified. Releases the lease and says exactly that, claiming nothing more. + * identity this lease is keyed on is dead, but its descendants were not proven + * gone. Releases the lease and says exactly that, claiming nothing more. * - `processless`: the attempt failed before a process existed. * - `unproven`: nothing about the process was observed; the reservation latches. */ @@ -104,7 +104,7 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( args.exitProof === 'root-exit-observed' ? { kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not verifiable', + detail: 'the provider process exited; its descendants were not proven gone', observedAt: args.now } : { @@ -166,7 +166,7 @@ function acquisitionDeathEvidence( if (exitProof === 'root-exit-observed') { return { kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not verifiable', + detail: 'the provider process exited; its descendants were not proven gone', observedAt } } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 480720da93b..6c2555e9c76 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -389,40 +389,44 @@ describe('a structured Claude session over agentSession.*', () => { expect(claude.connections).toHaveLength(1) }) - it('releases a session whose CLI self-exited during create, with its diagnostic intact', async () => { - claude.setSelfExit({ - message: 'claude stream-json exited (code 1): claude: not signed in', - // The root's death is first-hand; its descendants were never snapshottable. - exitVerdict: { root: 'exited', tree: 'unverifiable' } - }) + // The root's death is first-hand. Its descendants were never snapshottable, or one was seen + // alive; either way the lease follows the root, so the reservation goes with it. + it.each(['unverifiable', 'live'] as const)( + 'releases a session whose CLI self-exited during create with its tree %s, with its diagnostic intact', + async (tree) => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + exitVerdict: { root: 'exited', tree } + }) - const failed = await call('agentSession.create', createIntentParams()) + const failed = await call('agentSession.create', createIntentParams()) - // Answered once, as the refusal a replay of this operation gives, never thrown first. - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), - ownerVerdict: 'exited' + // Answered once, as the refusal a replay of this operation gives, never thrown first. + expect(failed).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: expect.stringContaining('claude: not signed in'), + ownerVerdict: 'exited' + } } - } - }) - const lease = leaseOf(SESSION) - // Latching here would refuse every later attach with agent_session_ownership_unknown, - // wedging a user who only needs to sign in. - expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) - expect(lease.deathEvidence).toMatchObject({ - kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not verifiable' - }) + }) + const lease = leaseOf(SESSION) + // Latching here would refuse every later attach with agent_session_ownership_unknown, + // wedging a user who only needs to sign in. + expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) + expect(lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not proven gone' + }) - claude.setSelfExit(null) - // Signing in and reopening the chat works: the reservation was not latched. - await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) - }) + claude.setSelfExit(null) + // Signing in and reopening the chat works: the reservation was not latched. + await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) + } + ) it('answers a create whose whole CLI tree exited as exited on the first call', async () => { claude.setSelfExit({ @@ -448,42 +452,28 @@ describe('a structured Claude session over agentSession.*', () => { claude.setSelfExit(null) }) - it('keeps a session reserved when a descendant of the failed start was seen alive', async () => { - claude.setSelfExit({ - message: 'claude stream-json exited (code 1): claude: not signed in', - exitVerdict: { root: 'exited', tree: 'live' } - }) + it.each(['unverifiable', 'live'] as const)( + 'reopens a chat whose stop saw the Claude root exit with its tree %s', + async (tree) => { + await ok('agentSession.create', createIntentParams()) + const first = claude.live() + first.exitVerdict = { root: 'exited', tree } + first.close = async () => { + first.closed = true + return false + } + const host = getStructuredAgentSessionHost() + // The idle release clock's eviction: the lease follows the root, so the host lets go. + await host?.close(SESSION) + expect(host?.hasSession(SESSION)).toBe(false) - await call('agentSession.create', createIntentParams()) + // What the chat surface's `agentSession.hold` does when the user comes back to it. + await host?.hold(SESSION, 'desktop-chat:reopen') - // A live descendant still holds the provider session: releasing would hand a - // second writer to it. - expect(leaseOf(SESSION)).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'manual-recovery' - }) - claude.setSelfExit(null) - }) - - it('reopens a chat whose stop saw the Claude root exit but not its descendants', async () => { - await ok('agentSession.create', createIntentParams()) - const first = claude.live() - first.exitVerdict = { root: 'exited', tree: 'unverifiable' } - first.close = async () => { - first.closed = true - return false + expect(claude.connections).toHaveLength(2) + expect(host?.hasSession(SESSION)).toBe(true) } - const host = getStructuredAgentSessionHost() - // The idle release clock's eviction: the lease follows the root, so the host lets go. - await host?.close(SESSION) - expect(host?.hasSession(SESSION)).toBe(false) - - // What the chat surface's `agentSession.hold` does when the user comes back to it. - await host?.hold(SESSION, 'desktop-chat:reopen') - - expect(claude.connections).toHaveLength(2) - expect(host?.hasSession(SESSION)).toBe(true) - }) + ) it('routes a published Claude first-hand exit through fenced host reconciliation', async () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) @@ -497,46 +487,51 @@ describe('a structured Claude session over agentSession.*', () => { expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) }) - it('restarts an open chat after a Claude crash whose descendants could not be verified', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - // The open chat surface is what asks the host to bring Claude back. - await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open') - const connection = claude.live() - connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } - connection.close = async () => { - connection.closed = true - return false - } - // Claude takes the message but crashes before echoing it. - connection.send = async (message) => { - connection.sent.push(message) - } - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } - const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( - 'agentSession.send', - { envelope: envelope('agentSession.send', { body }, created.fence), body } - ) - await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) - connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + // A descendant seen alive is one that survived the close ladder, such as an MCP server that + // ignores SIGTERM; it no longer holds the chat. + it.each(['unverifiable', 'live'] as const)( + 'restarts an open chat after a Claude crash whose tree was %s', + async (tree) => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + // The open chat surface is what asks the host to bring Claude back. + await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open') + const connection = claude.live() + connection.exitVerdict = { root: 'exited', tree } + connection.close = async () => { + connection.closed = true + return false + } + // Claude takes the message but crashes before echoing it. + connection.send = async (message) => { + connection.sent.push(message) + } + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } + const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( + 'agentSession.send', + { envelope: envelope('agentSession.send', { body }, created.fence), body } + ) + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) - expect((await inFlight).submission).toMatchObject({ - dispatchState: 'unknown', - reason: 'provider_exited_before_acknowledgement' - }) - // Held back, sends failed with the crash until the idle clock stopped the chat. - await waitForStructuredAgentSessionRecovery() - expect(claude.connections).toHaveLength(2) - expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) - const lease = leaseOf(SESSION) - expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null }) - const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } - const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', { - envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence), - body: next - }) - expect(sent.submission.dispatchState).toBe('accepted') - expect(claude.live().sent).toHaveLength(1) - }) + expect((await inFlight).submission).toMatchObject({ + dispatchState: 'unknown', + reason: 'provider_exited_before_acknowledgement' + }) + // Held back, sends failed with the crash until the idle clock stopped the chat. + await waitForStructuredAgentSessionRecovery() + expect(claude.connections).toHaveLength(2) + expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) + const lease = leaseOf(SESSION) + expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null }) + const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } + const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', { + envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence), + body: next + }) + expect(sent.submission.dispatchState).toBe('accepted') + expect(claude.live().sent).toHaveLength(1) + } + ) it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { shellEnv = { ...shellEnv, ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' } From 18bbf6f209520c63b29464d048373e1580f9c008 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:01:40 -0700 Subject: [PATCH 009/387] refactor(orchestration): resolve every caller and target to one orchestration party, keyed by the Orca session id (#22555) * refactor(orchestration): resolve a session caller at the dispatch entry and bind it by actor WIP: entry resolver on both dispatchers, caller identity through run scope, actor-keyed Run binding and unbind, actor writes on bind/create/assign, and actor-aware mail ownership exclusions. * test(orchestration): pin actor-keyed Run binding, stale-actor precedence and actor mail ownership Keeps the non-session dispatch path synchronous so terminal and session-tab streams reach their handler without an extra async hop. * feat(orchestration): resolve session callers before params parse and pin every verb on both routes A session caller need not name itself in a param that requires a caller: the entry binds the declared caller to the session before the schema runs. Session refusal codes pass through the RPC error map, and DB row reads added here carry their SAFETY rationale. * test(orchestration): pin the SSH check's pane through the caller-identity lookup * test(orchestration): pin a Run-less session's direct check and receipt binding without a caller param Drops the actor clause from self-dispatch detection: a creator and assignee can only share an actor when they already share a handle or pane. * test(orchestration): pin a session's own Run for plain and group sends and the assignee-only mail sweep * test(orchestration): name the party-naming field population for its role * fix(orchestration): clear a worker actor an older binary's unbind leaves, and refuse a worker without its identity An older binary unbinds a structured worker's Run by clearing handle and pane, which leaves the actor looking like a handle-less chat binding. The every-open repair clears that shape for actors recorded as structured workers only, and the resolver refuses a worker session whose worker identity is gone, so this binary never writes the shape itself. * refactor(orchestration): read a Run's coordinator actor through its generation, and give a party's addresses one owner The coordinator actor now counts only at the consumer generation it was written at, so a Run binding matches a session by that rule alone. It replaces two mechanisms for the same fact: the rule that an actor beside a handle it did not bind with never matches, and the open-time repair that cleared a structured worker's actor an older binary's unbind left. Every write of an older binary that rebinds or unbinds bumps the generation, so both shapes stop counting by themselves, including a chat's actor after a rebind then an unbind, which neither old mechanism caught. createRun and the same-coordinator actor correction write the generation in the statement that writes the actor. The resolver still refuses a structured worker whose worker identity is gone; its predicate moves next to the worker identity lookup. addressSpellingsOf is the one owner of the addresses a party is reachable at (a structured worker's handle and session actor). createRun, bindRun, the coordinator unbind and the declared-caller check use it instead of hand-built sets, and each has a test at both of a worker's addresses. * fix(orchestration): say a released session is not running, and scope the pane-key credential claim to requests without a session A released lease is evicted, not ended: a user turn resumes the session, so the refusal now says it is not running right now instead of that it has ended. The worker pane-key comment claimed the random leaf is what stops anyone who learns a session id from acting as the worker. On the same-host socket route the session id now names the worker with no token by design; the pane key still matters where a request names no session (a PTY agent's, or the paired-client route, which refuses session ids). * test(orchestration): pin the same-coordinator actor correction's generation write on a row an older binary wrote * refactor(orchestration): resolve session callers by the bare Orca session id, typed apart from its address Carries the Orca session id rename into caller resolution, Run binding and Dispatch creation. The caller identity holds the bare `orcaSessionId`; the `session:` spelling is derived by formatOrcaSessionAddress wherever mail needs it. `OrcaSessionId` and `OrcaSessionAddress` are distinct branded strings. Only isOrcaSessionId and parseOrcaSessionAddress produce an id, and only formatOrcaSessionAddress produces an address, so comparing the two is a type error. The Orca session id columns on the row types carry the id type. Every reader that compares a stored id with a mail address now compares like with like: the active-Dispatch ownership check formats the stored id (orcaSessionAddressSql), and the stray-mail sweep, the creator nesting lookup and the recorded-worker check bind a parsed or typed bare id. The Run-mailbox ownership check keeps its existing handle comparison beside the session one. * fix(orchestration): accept a worker's ask to every address its coordinator is reachable at A worker's preamble names its coordinator as `session:` when the coordinator is a structured session, but ask only accepted `run:` or the coordinator's terminal handle. A chat coordinator has no handle, and a coordinating structured worker has two addresses, so ask --to the session address was refused as dispatch_run_mismatch. The check now takes the Run's current coordinator addresses from addressSpellingsOf(runCoordinatorKey(run)). * refactor(orchestration): require every caller-identity entry point to be handed the resolved session The resolved session parameter was optional on resolveRunScope, resolveOrchestrationCaller, orchestrationCallerIdentity, resolveDispatchCreator and resolveDispatchCallerWorktreeId, so a method that forgot to pass it would compile and silently treat a chat's session address as a terminal handle. It is now required and typed `OrchestrationSessionCaller | undefined`, so leaving it out is a type error. Every call site already passed it; no behavior changes. * fix(orchestration): deliver mail sent to a session address to the mailbox that session reads A send to session: was resolved like a terminal handle: no live pane, so a chat's current Run was missed (two Runs read as ambiguous), a Run-less chat was refused though it reads its direct mailbox, and a structured worker's session address never reached its Dispatch. Resolve a worker's session address as its handle, a chat's by its bound Run, and fall back to the chat's durable direct mailbox while it runs on this host. * refactor(orchestration): resolve every party through one resolver with one mailbox address A structured worker is reachable at its handle and at its session address, and a chat only at its session address. Callers and targets were each compared or resolved at their own site, some against one spelling and some against every spelling, and the sites that did neither refused or misrouted. Add orchestration-party: resolveOrchestrationParty (and resolveOrcaSessionParty for a bare id) is now the only place an address becomes a party. The session caller, the declared-caller check, ask's target and inbox's filter all resolve through it, so caller and recipient resolution cannot disagree, including on a recorded worker whose identity this host lost. Every session-to-party step passes through canonicalOrcaSessionId, the seam later lineage canonicalization plugs into. mailboxAddressOf replaces addressSpellingsOf: a party has one mailbox address (a worker's handle today), and Run binding remembers and reroutes only that. A request with no session id that declares a session address as its caller now gets the party it names: a worker's handle, as if it had named it, or a session_caller_chat_not_declarable refusal for a chat, which is identified only by the session id its own environment sends. * fix(orchestration): route dispatch and mail targets through the party they name dispatch --to a worker's session address stored that address as the assignee handle, so the worker, which reads by its handle, never saw the Dispatch. The assignee now resolves through the party resolver: a worker's either spelling assigns its handle and records its Orca session id. A chat cannot receive a dispatch yet, so dispatch --to a chat is refused with session_chat_not_dispatchable before any row is written. Recipient routing resolves the party once and, for any session-backed party, finds its Run by its durable binding rather than by a live pane. A structured worker that coordinates a child Run while assigned in its parent got mail at its parent Dispatch mailbox whenever its session was evicted, which it never reads while bound to the child Run. Terminal handles keep the same live-pane lookup. * refactor(orchestration): delete the SQL that matched a worker's second spelling With every caller and target resolved to one mailbox address, no writer stores mail under a structured worker's session address: sends resolve it to the handle, a declared session caller is rewritten to the handle or refused, replies answer a stored canonical sender, and questions, answers, escalations, federation and legacy mail write run:, dispatch: or handle addresses (legacy rows are legacy_direct, which these queries never read). The branches that matched that spelling were unreachable and are removed: - the session-address OR in activeDispatchOwnsAddressSql, back to the assignee handle alone; - the session branches in routeForeignDirectMessagesToOwnedMailboxes and findActiveDispatchForDirectMessageOwner, which return to the base-branch form. Tests that inserted such rows directly now pin the behaviour through the verbs: both spellings of a worker land in the one mailbox it reads. * test(orchestration): pin one canonical address per party across every caller and target - every target param in ORCHESTRATION_TARGET_PARAM, for a worker's handle and session address and a chat's session address: send, ask, dispatch (a chat refused with no row) and inbox; - a declared session-address caller on a request with no session id: a worker acts as its handle, a chat is refused; - a worker that coordinates a child Run while assigned in its parent gets mail in the child Run with no live pane; - no mail writer stores to_handle or from_handle as a worker's session address; - the caller a session id resolves to and the recipient its address resolves to agree in every party state, and every session-to-party step goes through canonicalOrcaSessionId. * refactor(orchestration): drop the session caller's chat-to-terminal-view handoff remnants The structured-chat terminal handoff is gone from the base branch: a session is owned natively only. - the lease rule no longer speaks of either owner or a handoff keeping identity; - an in-progress owner change (new-owner-proving, recovering, manual-recovery, which the base branch still produces) is refused as changing owners, not as switching between chat and terminal view; - the native to terminal-view to native identity test is deleted, and the terminal-evidence test no longer describes that evidence as a terminal view's. * fix(orchestration): read a declared caller param by typed access, not Reflect.get --- .../canonical-orca-session-id.ts | 7 + .../dispatch-context-store.ts | 2 + .../orchestration/db/dispatch-depth.ts | 53 +- .../orchestration/db/dispatch-row-writer.ts | 18 +- .../db/messages/direct-mailbox-routing.ts | 8 +- .../db/orca-session-address-sql.ts | 10 + .../orchestration/db/runs/run-binding.ts | 63 +- .../db/runs/run-coordinator-mail-routing.ts | 23 +- ...n-coordinator-orca-session-binding.test.ts | 405 ++++++++++++ .../db/runs/run-coordinator-orca-session.ts | 7 +- .../orchestration/db/runs/run-create.ts | 34 +- .../orchestration/db/runs/run-lookup.ts | 54 +- ...tured-worker-orca-session-backfill.test.ts | 4 + .../worker-dispatch-authority.ts | 6 +- .../failed-start-dispatch-identity.ts | 6 +- .../worker-terminal-resource-store.ts | 16 + .../orchestration-caller-identity.ts | 76 +++ ...tion-orca-session-column-migration.test.ts | 31 +- .../orchestration/orchestration-party.ts | 108 ++++ ...n-coordinator-orca-session-address.test.ts | 5 +- ...structured-worker-group-addressing.test.ts | 9 + src/main/runtime/orchestration/types.ts | 7 +- src/main/runtime/rpc/core.ts | 3 + .../rpc/dispatcher-unary-method-invocation.ts | 3 +- src/main/runtime/rpc/dispatcher.ts | 20 +- src/main/runtime/rpc/errors.ts | 4 +- .../methods/orchestration-caller-workspace.ts | 8 +- ...on-structured-worker-start-failure.test.ts | 2 +- .../federation/federated-worker-start.ts | 5 +- .../rpc/methods/orchestration/gates/gates.ts | 24 +- .../orchestration/messaging/ask-methods.ts | 12 +- .../orchestration/messaging/check-methods.ts | 17 +- .../orchestration/messaging/check-run.ts | 5 + .../messaging/message-methods.ts | 50 +- .../messaging/recipient-routing.ts | 34 +- .../orchestration/messaging/send-group.ts | 5 +- .../orchestration/messaging/send-methods.ts | 13 +- .../rpc/methods/orchestration/routing.ts | 14 +- .../orchestration/runs/dispatch-creator.ts | 29 +- .../orchestration/runs/dispatch-methods.ts | 20 +- .../orchestration/runs/run-receipt.test.ts | 3 +- .../methods/orchestration/runs/run-scope.ts | 78 ++- .../rpc/methods/orchestration/runs/runs.ts | 35 +- .../explicit-worker-terminal-validation.ts | 19 +- .../worker/local-worker-start.ts | 29 +- .../methods/orchestration/worker/workers.ts | 15 +- .../rpc/orchestration-mutation-executor.ts | 7 +- .../rpc/orchestration-mutation-receipt.ts | 14 +- .../orchestration-party-addressing.test.ts | 321 ++++++++++ .../rpc/orchestration-party-agreement.test.ts | 207 +++++++ ...chestration-session-caller-test-fixture.ts | 167 +++++ .../rpc/orchestration-session-caller.test.ts | 404 ++++++++++++ .../rpc/orchestration-session-caller.ts | 291 +++++++++ .../orchestration-session-coordinator.test.ts | 581 ++++++++++++++++++ .../orchestration-session-recipient.test.ts | 121 ++++ .../runtime/rpc/rpc-streaming-dispatcher.ts | 42 +- .../runtime/structured-worker-authority.ts | 35 ++ .../runtime/structured-worker-identity.ts | 22 +- src/main/ssh/ssh-remote-orca-cli.test.ts | 7 +- .../orca-session-address-test-fixture.ts | 9 + src/shared/orca-session-address.test.ts | 6 +- src/shared/orca-session-address.ts | 25 +- .../orchestration-compatibility-evidence.ts | 5 + .../orchestration-session-caller-codes.ts | 15 + 64 files changed, 3457 insertions(+), 221 deletions(-) create mode 100644 src/main/runtime/orchestration/canonical-orca-session-id.ts create mode 100644 src/main/runtime/orchestration/db/orca-session-address-sql.ts create mode 100644 src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts create mode 100644 src/main/runtime/orchestration/orchestration-caller-identity.ts create mode 100644 src/main/runtime/orchestration/orchestration-party.ts create mode 100644 src/main/runtime/rpc/orchestration-party-addressing.test.ts create mode 100644 src/main/runtime/rpc/orchestration-party-agreement.test.ts create mode 100644 src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts create mode 100644 src/main/runtime/rpc/orchestration-session-caller.test.ts create mode 100644 src/main/runtime/rpc/orchestration-session-caller.ts create mode 100644 src/main/runtime/rpc/orchestration-session-coordinator.test.ts create mode 100644 src/main/runtime/rpc/orchestration-session-recipient.test.ts create mode 100644 src/shared/orca-session-address-test-fixture.ts create mode 100644 src/shared/orchestration-session-caller-codes.ts diff --git a/src/main/runtime/orchestration/canonical-orca-session-id.ts b/src/main/runtime/orchestration/canonical-orca-session-id.ts new file mode 100644 index 00000000000..7889e257f48 --- /dev/null +++ b/src/main/runtime/orchestration/canonical-orca-session-id.ts @@ -0,0 +1,7 @@ +import type { OrcaSessionId } from '../../../shared/orca-session-address' + +/** The Orca session id orchestration addresses a session by; every session-to-party step calls this. */ +export function canonicalOrcaSessionId(orcaSessionId: OrcaSessionId): OrcaSessionId { + // Later lineage canonicalization (a `/clear`ed session to its lineage root) plugs in here. + return orcaSessionId +} diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index 38905f30434..67be16e6cf2 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -9,6 +9,7 @@ import { recordedCreatorIdentity, type DispatchCreator } from '../dispatch-depth import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' export function createDispatchContext( this: OrchestrationDb, @@ -65,6 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, + assigneeOrcaSessionId: structuredWorkerOrcaSessionIdForIncarnation(processIncarnation), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index cc4ba026db9..93c5bd65fb6 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -7,6 +7,7 @@ import { import { OrchestrationError } from '../orchestration-error' import { isEquivalentPaneKey } from './pane-key-match' import type { OrchestrationDb } from './orchestration-db' +import type { OrcaSessionId } from '../../../../shared/orca-session-address' import type { DispatchContextRow, RemoteDispatchAttachmentRow } from '../types' import { potentiallyLiveRemoteAttachmentSql } from './federation/remote-attachment-liveness' @@ -26,17 +27,33 @@ export type DispatchCreator = paneKey?: string /** Remote attachment matching requires the exact incarnation; local rows do not. */ processIncarnation?: string + /** A structured worker's bare Orca session id, recorded beside its handle. */ + orcaSessionId?: OrcaSessionId | null } + /** A structured session with no terminal handle, identified by its Orca session id alone. */ + | { kind: 'session'; orcaSessionId: OrcaSessionId } /** Creator identity to persist on a new row, so depth can later tell delegation from bookkeeping. */ export function recordedCreatorIdentity(creator: DispatchCreator): { creatorHandle: string | null creatorPaneKey: string | null + creatorOrcaSessionId: OrcaSessionId | null } { if (creator.kind === 'system') { - return { creatorHandle: null, creatorPaneKey: null } + return { creatorHandle: null, creatorPaneKey: null, creatorOrcaSessionId: null } + } + if (creator.kind === 'session') { + return { + creatorHandle: null, + creatorPaneKey: null, + creatorOrcaSessionId: creator.orcaSessionId + } + } + return { + creatorHandle: creator.handle, + creatorPaneKey: creator.paneKey ?? null, + creatorOrcaSessionId: creator.orcaSessionId ?? null } - return { creatorHandle: creator.handle, creatorPaneKey: creator.paneKey ?? null } } /** @@ -84,14 +101,14 @@ export function resolveCreatorDepth(this: OrchestrationDb, creator: DispatchCrea // Local rows match on handle/pane as they always have. process_incarnation is // nullable here and context-only dispatch stores null deliberately, so // requiring it would drop real parents. - const local = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) as - | DispatchContextRow - | undefined + const local = findActiveDispatchForCreator.call(this, creator) if (local && !isSelfCreatedDispatch(local)) { depths.push(local.depth) } - for (const attachment of findPotentiallyLiveAttachmentsForCreator.call(this, creator)) { + const attachments = + creator.kind === 'terminal' ? findPotentiallyLiveAttachmentsForCreator.call(this, creator) : [] + for (const attachment of attachments) { depths.push(attachment.depth) } @@ -109,16 +126,36 @@ export function resolveCreatorDispatchId( if (creator.kind === 'system') { return null } - const own = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) + const own = findActiveDispatchForCreator.call(this, creator) // Why: a self-dispatch is not a parent Attempt, so it must not be stamped as the child's creator. const local = own && !isSelfCreatedDispatch(own) ? own : undefined - const remote = findPotentiallyLiveAttachmentsForCreator.call(this, creator) + const remote = + creator.kind === 'terminal' ? findPotentiallyLiveAttachmentsForCreator.call(this, creator) : [] if ((local ? 1 : 0) + remote.length !== 1) { return null } return local?.id ?? remote[0]?.dispatch_id ?? null } +/** The live Dispatch this creator is itself working on, found the way its identity is recorded. */ +function findActiveDispatchForCreator( + this: OrchestrationDb, + creator: Exclude +): DispatchContextRow | undefined { + if (creator.kind === 'terminal') { + return this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) + } + const row = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(creator.orcaSessionId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return row as DispatchContextRow | undefined +} + /** * Remote attachments matching this caller's pane AND exact process incarnation. * diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer.ts b/src/main/runtime/orchestration/db/dispatch-row-writer.ts index 84862ee343d..e8e4192e137 100644 --- a/src/main/runtime/orchestration/db/dispatch-row-writer.ts +++ b/src/main/runtime/orchestration/db/dispatch-row-writer.ts @@ -1,5 +1,6 @@ import type Database from '../../../sqlite/sync-database' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' +import type { OrcaSessionId } from '../../../../shared/orca-session-address' /** * The only place that inserts rows representing a live supervised worker. @@ -14,11 +15,11 @@ import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, process_incarnation, - creator_dispatch_id, creator_handle, creator_pane_key, + assignee_handle, assignee_pane_key, assignee_orca_session_id, process_incarnation, + creator_dispatch_id, creator_handle, creator_pane_key, creator_orca_session_id, status, failure_count, depth, dispatched_at ) -SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') +SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') FROM tasks WHERE id = ? AND status = 'ready' AND NOT EXISTS ( @@ -45,8 +46,9 @@ WHERE id = ? AND status = 'ready' const STARTING_DISPATCH_CONTEXT_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, retry_of_dispatch_id, - creator_dispatch_id, creator_handle, creator_pane_key, depth, status, dispatched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` + creator_dispatch_id, creator_handle, creator_pane_key, creator_orca_session_id, depth, status, + dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` const REMOTE_DISPATCH_ATTACHMENT_SQL = `INSERT INTO remote_dispatch_attachments ( dispatch_id, home_run_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, depth @@ -70,10 +72,12 @@ export function claimDispatchContextRow( launchTokenHash: string | null assigneeHandle: string assigneePaneKey: string | null + assigneeOrcaSessionId?: OrcaSessionId | null processIncarnation: string | null creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorOrcaSessionId?: OrcaSessionId | null priorFailures: number depth: number taskId: string @@ -89,10 +93,12 @@ export function claimDispatchContextRow( params.launchTokenHash, params.assigneeHandle, params.assigneePaneKey, + params.assigneeOrcaSessionId ?? null, params.processIncarnation, params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorOrcaSessionId ?? null, params.priorFailures, params.depth, params.taskId, @@ -118,6 +124,7 @@ export function insertStartingDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorOrcaSessionId?: OrcaSessionId | null } ): void { assertStampedDepth(params.depth) @@ -131,6 +138,7 @@ export function insertStartingDispatchContextRow( params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorOrcaSessionId ?? null, params.depth ) } diff --git a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts index aa7d93fbea2..c1e5ee99562 100644 --- a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts @@ -1,6 +1,7 @@ import type { MessageType } from '../../types' import type { OrchestrationDb } from '../orchestration-db' import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type MailboxRoutingPage } from './mailbox-routing-page' +import { activeDispatchOwnsAddressSql } from '../runs/run-coordinator-mail-routing' export function hasUndeliveredDirectMessageForRun( this: OrchestrationDb, @@ -48,12 +49,7 @@ export function routeDirectMessagePage( try { const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?' const dispatchOwnershipClause = preserveActiveDispatchOwnership - ? ` AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.run_id = messages.run_id - AND dispatch_contexts.assignee_handle = messages.to_handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - )` + ? ` AND NOT ${activeDispatchOwnsAddressSql('messages.run_id', 'messages.to_handle')}` : '' const params: (string | number)[] = [runId, directHandle] if (throughSequence !== undefined) { diff --git a/src/main/runtime/orchestration/db/orca-session-address-sql.ts b/src/main/runtime/orchestration/db/orca-session-address-sql.ts new file mode 100644 index 00000000000..83bb7ad2c56 --- /dev/null +++ b/src/main/runtime/orchestration/db/orca-session-address-sql.ts @@ -0,0 +1,10 @@ +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../shared/orca-session-address' + +/** + * The `session:` address of a bare Orca session id column or expression, NULL when it is NULL. + * The only way SQL compares a stored id with a mail address: the id side is formatted, never the + * address side stripped, so a handle or `run:` address can never equal a bare id. + */ +export function orcaSessionAddressSql(orcaSessionIdSql: string): string { + return `('${ORCA_SESSION_ADDRESS_PREFIX}' || ${orcaSessionIdSql})` +} diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index c5e6f34abfa..b396b79f1ec 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -3,13 +3,21 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' +import { + mailboxAddressOf, + runBoundToCoordinator, + runCoordinatorKey +} from '../../orchestration-caller-identity' export function bindRun( this: OrchestrationDb, params: { runId: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null takeoverLegacy?: boolean legacyCoordinatorAuthority?: { runId: string @@ -20,6 +28,11 @@ export function bindRun( } } ): RunRow | undefined { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + orcaSessionId: params.coordinatorOrcaSessionId ?? null + } this.db.exec('BEGIN IMMEDIATE') try { const run = this.getRunRaw(params.runId) @@ -27,9 +40,7 @@ export function bindRun( this.db.exec('ROLLBACK') return undefined } - const sameBinding = - run.coordinator_pane_key !== null && - isEquivalentPaneKey(run.coordinator_pane_key, params.coordinatorPaneKey) + const sameBinding = runBoundToCoordinator(run, coordinator) const adoption = this.getLegacyAdoption() const adoptedRun = adoption?.adopted_run_id === params.runId const legacyAuthority = params.legacyCoordinatorAuthority @@ -49,6 +60,7 @@ export function bindRun( legacyPrincipal.terminal_handle === legacyAuthority.terminalHandle && isEquivalentPaneKey(legacyPrincipal.pane_key, legacyAuthority.paneKey) && params.coordinatorHandle === legacyAuthority.terminalHandle && + params.coordinatorPaneKey !== null && isEquivalentPaneKey(params.coordinatorPaneKey, legacyAuthority.paneKey) ) if (legacyAuthority && !provenLegacyBinding) { @@ -109,14 +121,17 @@ export function bindRun( } ) } - this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId) - for (const handle of new Set( - [run.coordinator_handle, params.coordinatorHandle].filter((value): value is string => - Boolean(value) - ) - )) { - this.rememberRunCoordinatorHandle(params.runId, handle) - this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, handle) + this.unbindOtherRunsForCoordinator(coordinator, params.runId) + // The mailbox address of the coordinator being replaced and of the one binding now. + for (const address of new Set([ + mailboxAddressOf(runCoordinatorKey(run)), + mailboxAddressOf(coordinator) + ])) { + if (address === null) { + continue + } + this.rememberRunCoordinatorHandle(params.runId, address) + this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, address) } if ( (params.takeoverLegacy && !takeoverAlreadyApplied) || @@ -128,26 +143,40 @@ export function bindRun( coordinatorPrincipal?.status === 'committed' && (params.takeoverLegacy || coordinatorPrincipal.terminal_handle !== params.coordinatorHandle || + params.coordinatorPaneKey === null || !isEquivalentPaneKey(coordinatorPrincipal.pane_key, params.coordinatorPaneKey)) ) { this.setLegacyCompatibilityPrincipalStatus(coordinatorPrincipal.id, 'revoked') } } - // The Orca session id belongs to the coordinator being replaced; nothing here resolves the new one's. this.db .prepare( `UPDATE runs - SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = NULL, - coordinator_orca_session_id_generation = NULL, + SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + 1, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` ) - .run(params.coordinatorHandle, params.coordinatorPaneKey, params.runId) + .run( + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId, + params.runId + ) this.fenceUnacknowledgedMailboxDeliveries(`run:${params.runId}`) if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) } + } else if (runCoordinatorKey(run).orcaSessionId !== coordinator.orcaSessionId) { + // Same coordinator, so no new consumer: correct an Orca session id a writer without the column left. + this.db + .prepare( + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + WHERE id = ?` + ) + .run(coordinator.orcaSessionId, params.runId) } this.db.exec('COMMIT') } catch (error) { diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts index 07a6044d5aa..d42b93dfdd9 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts @@ -1,6 +1,16 @@ import type { OrchestrationDb } from '../orchestration-db' import { currentRunCoordinatorSessionAddressSql } from './run-coordinator-orca-session' +/** Mail to an active Dispatch assignee's address in the same Run is that worker's, not coordinator mail. */ +export function activeDispatchOwnsAddressSql(runIdSql: string, addressSql: string): string { + return `EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE dispatch_contexts.run_id = ${runIdSql} + AND dispatch_contexts.assignee_handle = ${addressSql} + AND dispatch_contexts.status IN ('pending', 'dispatched') + )` +} + export function rememberRunCoordinatorHandle( this: OrchestrationDb, runId: string, @@ -43,11 +53,7 @@ export function createCoordinatorMailRoutingTrigger(this: OrchestrationDb): void SELECT 1 FROM run_coordinator_handles WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle ) - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle - AND status IN ('pending', 'dispatched') - ) + AND NOT ${activeDispatchOwnsAddressSql('NEW.run_id', 'NEW.to_handle')} BEGIN UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence; END; @@ -69,12 +75,7 @@ export function routeAllUnreadDirectMessagesToRunMailbox( `UPDATE messages SET to_handle = ? WHERE run_id = ? AND to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery' - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.run_id = messages.run_id - AND dispatch_contexts.assignee_handle = messages.to_handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - )` + AND NOT ${activeDispatchOwnsAddressSql('messages.run_id', 'messages.to_handle')}` ) .run(`run:${runId}`, runId, directHandle) } diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts new file mode 100644 index 00000000000..3340f625b66 --- /dev/null +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts @@ -0,0 +1,405 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../../../structured-worker-identity' +import { OrchestrationDb } from '../../db' +import { + formatOrcaSessionAddress, + type OrcaSessionId +} from '../../../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../../../shared/orca-session-address-test-fixture' + +const CHAT_X_ID = testOrcaSessionId('1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13') +const CHAT_X = formatOrcaSessionAddress(CHAT_X_ID) +const CHAT_Y_ID = testOrcaSessionId('6d2a9e41-0c7b-4f38-9a15-b3e8c1d57f20') +const CHAT_Y = formatOrcaSessionAddress(CHAT_Y_ID) +const WORKER_SESSION = testOrcaSessionId('9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48') +const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION) +const OTHER_WORKER_SESSION = testOrcaSessionId('2e8b4d61-5a3c-4e97-b0f2-7c1d9a6e3b54') +const PTY_PANE = 'tab_pty:11111111-1111-4111-8111-111111111111' +const OTHER_PANE = 'tab_other:22222222-2222-4222-8222-222222222222' +const UNCAPPED = Number.MAX_SAFE_INTEGER + +function chat(orcaSessionId: OrcaSessionId) { + return { terminalHandle: null, paneKey: null, orcaSessionId } +} + +describe('Run binding by Orca session id', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + function createChatRun(orcaSessionId: OrcaSessionId, objective = 'chat run') { + return db.createRun({ + objective, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: orcaSessionId + }) + } + + function directMail(runId: string, to: string, subject = 'direct') { + return db.insertMessage({ from: 'term_sender', to, subject, body: '', runId }) + } + + function structuredWorker(sessionId = WORKER_SESSION) { + return { + terminalHandle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(sessionId), + orcaSessionId: sessionId + } + } + + // A binary without the Orca session id column: its bindRun and unbindOtherRunsForPane statements. + function olderBinaryRebind(runId: string, handle: string, paneKey: string) { + db.db + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run(handle, paneKey, runId) + } + + function olderBinaryUnbind(runId: string) { + db.db + .prepare( + `UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run(runId) + } + + /** Unread mail addressed straight to `to`, as a row written before the address was cached. */ + function strayMail(runId: string, to: string) { + const message = directMail(runId, 'term_late', `to ${to}`) + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(to, message.id) + return message.id + } + + it('binds a handle-less session by its Orca session id and remembers its session address', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: CHAT_X_ID + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(run.id) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_X)).toEqual([run.id]) + // Mail to the session's address reaches the Run mailbox, as mail to a coordinator handle does. + expect(directMail(run.id, CHAT_X).to_handle).toBe(`run:${run.id}`) + }) + + it("never unbinds another session's Run, and unbinds only the same session's other Runs", () => { + db = new OrchestrationDb(':memory:') + const ptyRun = db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_pty', + coordinatorPaneKey: PTY_PANE + }) + const yRun = createChatRun(CHAT_Y_ID, 'y') + const xFirst = createChatRun(CHAT_X_ID, 'x first') + const pending = db.insertMessage({ + from: 'term_sender', + to: 'term_late', + subject: 'queued before the rebind', + body: '', + runId: xFirst.id + }) + // Mail addressed to the session that the cache did not reroute on insert, as a pre-cache row. + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) + const generation = db.getRunRaw(xFirst.id)?.consumer_generation ?? 0 + + const xSecond = createChatRun(CHAT_X_ID, 'x second') + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(xSecond.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y_ID))?.id).toBe(yRun.id) + expect(db.getRunRaw(yRun.id)?.coordinator_orca_session_id).toBe(CHAT_Y_ID) + expect(db.getRunRaw(ptyRun.id)?.coordinator_pane_key).toBe(PTY_PANE) + expect(db.getRunRaw(xFirst.id)).toMatchObject({ + coordinator_handle: null, + coordinator_orca_session_id: null, + consumer_generation: generation + 1 + }) + // Pending coordinator mail follows the Run, as it does when a pane is unbound. + expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${xFirst.id}`) + }) + + it('stops counting an Orca session id once a binary without the column rebinds the Run to a terminal', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + expect( + db.getCurrentRunForCoordinator({ + terminalHandle: 'term_taker', + paneKey: PTY_PANE, + orcaSessionId: null + })?.id + ).toBe(run.id) + createChatRun(CHAT_X_ID, 'next') + expect(db.getRunRaw(run.id)?.coordinator_handle).toBe('term_taker') + }) + + it('does not hand a chat back a Run an older binary rebound and then unbound', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + olderBinaryUnbind(run.id) + + // Handle and pane are gone and the id is still there: the shape of a live chat binding. + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: CHAT_X_ID + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + const next = createChatRun(CHAT_X_ID, 'next') + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(next.id) + }) + + it("stops counting a structured worker's Orca session id once an older binary unbinds its Run", () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + expect(db.getCurrentRunForCoordinator(worker)?.id).toBe(run.id) + olderBinaryUnbind(run.id) + expect(db.getCurrentRunForCoordinator(worker)).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(worker.orcaSessionId))).toBeUndefined() + }) + + it('remembers a coordinating structured worker at its handle, and the v42 trigger its inert session address', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + + expect(db.getRunMailboxOwnerIdsForHandle(worker.terminalHandle)).toEqual([run.id]) + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ADDRESS)).toEqual([run.id]) + expect(directMail(run.id, WORKER_ADDRESS).to_handle).toBe(`run:${run.id}`) + }) + + it('hands a Run to a different session like a terminal takeover: fenced, rerouted, remembered', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + const pending = directMail(run.id, 'term_late') + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) + const before = db.getRunRaw(run.id)?.consumer_generation ?? 0 + + db.bindRun({ + runId: run.id, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_Y_ID + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_orca_session_id: CHAT_Y_ID, + consumer_generation: before + 1 + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y_ID))?.id).toBe(run.id) + expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${run.id}`) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_Y)).toEqual([run.id]) + }) + + it('rebinding the same session is not a new consumer, and fills a missing Orca session id in place', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey + }) + // As an older binary writes the row: no Orca session id, and no generation for one. + db.db + .prepare('UPDATE runs SET coordinator_orca_session_id_generation = NULL WHERE id = ?') + .run(run.id) + const before = db.getRunRaw(run.id)?.consumer_generation + + db.bindRun({ + runId: run.id, + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_orca_session_id: WORKER_SESSION, + consumer_generation: before + }) + // Written at the current generation, so the filled id counts on its own. + expect(db.getCurrentRunForCoordinator(chat(WORKER_SESSION))?.id).toBe(run.id) + }) + + it("reroutes and remembers each worker's one mailbox address when one takes a Run from another", () => { + db = new OrchestrationDb(':memory:') + const first = structuredWorker() + const second = structuredWorker(OTHER_WORKER_SESSION) + const run = db.createRun({ + objective: 'first worker coordinates', + coordinatorHandle: first.terminalHandle, + coordinatorPaneKey: first.paneKey, + coordinatorOrcaSessionId: first.orcaSessionId + }) + // A worker's mailbox address is its handle; nothing writes mail to its session address. + const addresses = [first.terminalHandle, second.terminalHandle] + const stray = addresses.map((address) => strayMail(run.id, address)) + + db.bindRun({ + runId: run.id, + coordinatorHandle: second.terminalHandle, + coordinatorPaneKey: second.paneKey, + coordinatorOrcaSessionId: second.orcaSessionId + }) + + for (const id of stray) { + expect(db.getMessageById(id)?.to_handle).toBe(`run:${run.id}`) + } + for (const address of addresses) { + expect(db.getRunMailboxOwnerIdsForHandle(address)).toEqual([run.id]) + } + }) + + it("reroutes a worker's mailbox address when its next Run unbinds the last", () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const bind = { + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorOrcaSessionId: worker.orcaSessionId + } + const last = db.createRun({ objective: 'last', ...bind }) + const stray = [strayMail(last.id, worker.terminalHandle)] + + db.createRun({ objective: 'next', ...bind }) + + for (const id of stray) { + expect(db.getMessageById(id)?.to_handle).toBe(`run:${last.id}`) + } + }) +}) + +describe('Dispatch Orca session ids recorded by every writer', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('records the assignee Orca session id from a structured incarnation and a creator one', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_X_ID + }) + const handle = mintStructuredWorkerHandle() + const assigned = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'structured' }).id, + assigneeHandle: handle, + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, + maxDepth: UNCAPPED + }) + const pty = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'pty' }).id, + assigneeHandle: 'term_pty', + assigneePaneKey: PTY_PANE, + processIncarnation: 'pty_proc:1', + creator: { kind: 'terminal', handle: 'term_c', paneKey: OTHER_PANE }, + maxDepth: UNCAPPED + }) + + expect(assigned).toMatchObject({ + assignee_orca_session_id: WORKER_SESSION, + creator_handle: null, + creator_pane_key: null, + creator_orca_session_id: CHAT_X_ID + }) + expect(pty).toMatchObject({ assignee_orca_session_id: null, creator_orca_session_id: null }) + }) + + it('nests under the Dispatch a handle-less creator is assigned by its Orca session id', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: 'term_c', + coordinatorPaneKey: OTHER_PANE + }) + const parent = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'parent' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + + const child = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'child' }).id, + assigneeHandle: 'term_child', + assigneePaneKey: PTY_PANE, + processIncarnation: 'pty_proc:2', + creator: { kind: 'session', orcaSessionId: WORKER_SESSION }, + maxDepth: UNCAPPED + }) + + expect(child).toMatchObject({ creator_dispatch_id: parent.id, depth: parent.depth + 1 }) + }) + + it('records the starting creator and the attached assignee of a worker-start', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_X_ID + }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, + maxDepth: UNCAPPED, + taskSpec: 'work', + taskRunId: run.id, + startOptions: {} + }) + expect(started.dispatch).toMatchObject({ + creator_orca_session_id: CHAT_X_ID, + assignee_orca_session_id: null + }) + + const handle = mintStructuredWorkerHandle() + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + worktreeId: 'wt_1', + effects: [], + setupState: 'not_applicable' + }) + + expect(db.getDispatchContextById(started.dispatch.id)?.assignee_orca_session_id).toBe( + WORKER_SESSION + ) + }) +}) diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts index 2ed9d9809a2..007e979df4d 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts @@ -1,4 +1,5 @@ -import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../../shared/orca-session-address' +import { orcaSessionAddressSql } from '../orca-session-address-sql' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' import type { RunRow } from '../../types' type RunCoordinatorOrcaSessionFields = Pick< @@ -13,7 +14,7 @@ type RunCoordinatorOrcaSessionFields = Pick< */ export function currentRunCoordinatorOrcaSessionId( run: RunCoordinatorOrcaSessionFields -): string | null { +): OrcaSessionId | null { return run.coordinator_orca_session_id_generation === run.consumer_generation ? run.coordinator_orca_session_id : null @@ -27,5 +28,5 @@ export function currentRunCoordinatorOrcaSessionIdSql(row: string): string { /** The coordinator's `session:` address in SQL; NULL when it has no current Orca session id. */ export function currentRunCoordinatorSessionAddressSql(row: string): string { - return `('${ORCA_SESSION_ADDRESS_PREFIX}' || ${currentRunCoordinatorOrcaSessionIdSql(row)})` + return orcaSessionAddressSql(currentRunCoordinatorOrcaSessionIdSql(row)) } diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index 3f99e018936..9b7eab6ab0d 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -1,6 +1,8 @@ import type { RunRow } from '../../types' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' +import { mailboxAddressOf } from '../../orchestration-caller-identity' // ── Runs ── @@ -8,23 +10,39 @@ export function createRun( this: OrchestrationDb, params: { objective: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null } ): RunRow { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + orcaSessionId: params.coordinatorOrcaSessionId ?? null + } const id = generateId('run') this.db.exec('BEGIN IMMEDIATE') try { - this.unbindOtherRunsForPane(params.coordinatorPaneKey) + this.unbindOtherRunsForCoordinator(coordinator) this.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_handle, coordinator_pane_key, - consumer_generation, legacy - ) VALUES (?, ?, ?, ?, 1, 0)` + id, objective, coordinator_handle, coordinator_pane_key, coordinator_orca_session_id, + coordinator_orca_session_id_generation, consumer_generation, legacy + ) VALUES (?, ?, ?, ?, ?, 1, 1, 0)` ) - .run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey) - this.rememberRunCoordinatorHandle(id, params.coordinatorHandle) + .run( + id, + params.objective, + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId + ) + const address = mailboxAddressOf(coordinator) + if (address !== null) { + this.rememberRunCoordinatorHandle(id, address) + } this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 0a60b4e7f9d..94a57c345a3 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -1,4 +1,10 @@ import type { RunRow } from '../../types' +import { + mailboxAddressOf, + runBoundToCoordinator, + runCoordinatorKey, + type OrchestrationCoordinatorKey +} from '../../orchestration-caller-identity' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../shared/orchestration-run-pagination' import { isEquivalentPaneKey, @@ -22,6 +28,13 @@ const RUNS_BOUND_TO_PANE_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid` +// Why: one statement so pane and Orca session id matches keep a single rowid order; the JS predicate decides. +const RUNS_BOUND_TO_COORDINATOR_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs + WHERE legacy = 0 AND ( + (coordinator_pane_key IS NOT NULL AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?) + OR coordinator_orca_session_id = ? + ) + ORDER BY rowid` export function getRun(this: OrchestrationDb, id: string): RunRow | undefined { const run = this.getRunRaw(id) @@ -122,15 +135,38 @@ export function getRunRaw(this: OrchestrationDb, id: string): RunRow | undefined return this.db.prepare(RUN_BY_ID_SQL).get(id) as RunRow | undefined } -export function unbindOtherRunsForPane( +export function getCurrentRunForCoordinator( this: OrchestrationDb, - paneKey: string, + caller: OrchestrationCoordinatorKey +): RunRow | undefined { + const run = this.runsBoundToCoordinator(caller)[0] + return run ? exposeRunTimestamps(run) : undefined +} + +/** Runs bound to this caller by pane or by Orca session id; a caller without one matches as before. */ +export function runsBoundToCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey +): RunRow[] { + if (caller.orcaSessionId === null) { + return caller.paneKey === null ? [] : this.runsBoundToPane(caller.paneKey) + } + const suffix = caller.paneKey === null ? null : paneKeyMatchSuffix(caller.paneKey) + const rows = this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.orcaSessionId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return (rows as RunRow[]).filter((run) => runBoundToCoordinator(run, caller)) +} + +export function unbindOtherRunsForCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey, exceptRunId?: string ): void { - for (const run of this.runsBoundToPane(paneKey)) { + for (const run of this.runsBoundToCoordinator(caller)) { if (run.id !== exceptRunId) { - if (run.coordinator_handle) { - this.routeAllUnreadDirectMessagesToRunMailbox(run.id, run.coordinator_handle) + const address = mailboxAddressOf(runCoordinatorKey(run)) + if (address !== null) { + this.routeAllUnreadDirectMessagesToRunMailbox(run.id, address) } this.db .prepare( @@ -160,8 +196,10 @@ export type RunLookupMethods = { listRuns: typeof listRuns getCurrentRunForPane: typeof getCurrentRunForPane runsBoundToPane: typeof runsBoundToPane + getCurrentRunForCoordinator: typeof getCurrentRunForCoordinator + runsBoundToCoordinator: typeof runsBoundToCoordinator getRunRaw: typeof getRunRaw - unbindOtherRunsForPane: typeof unbindOtherRunsForPane + unbindOtherRunsForCoordinator: typeof unbindOtherRunsForCoordinator requireRun: typeof requireRun } @@ -173,8 +211,10 @@ export function attachRunLookup(ctor: { prototype: object }): void { listRuns, getCurrentRunForPane, runsBoundToPane, + getCurrentRunForCoordinator, + runsBoundToCoordinator, getRunRaw, - unbindOtherRunsForPane, + unbindOtherRunsForCoordinator, requireRun }) } diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index 57bf53e7026..273d7a09aca 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -96,6 +96,10 @@ describe('structured worker Orca session id backfill', () => { processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), ownership: 'owned' }) + // Rows a writer without the column left; a current writer records the incarnation's Orca session id. + db.db.exec( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = NULL, creator_orca_session_id = NULL' + ) backfillStructuredWorkerOrcaSessionIds(db.db) diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index 166ccd7193e..bc7b2c0cc0c 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' export function prepareStartingWorkerAuthority( this: OrchestrationDb, @@ -53,8 +54,8 @@ export function prepareStartingWorkerAuthority( const contextUpdate = this.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, - assignee_orca_session_id = NULL, host_scope = ?, + SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, + process_incarnation = ?, host_scope = ?, capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 @@ -63,6 +64,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, + structuredWorkerOrcaSessionIdForIncarnation(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index c9b9b5d0afd..490cfbb1138 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -1,5 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' /** * A start that dies before `prepareStartingWorkerAuthority` never filled the Dispatch context in, @@ -21,13 +22,14 @@ export function recordFailedStartDispatchIdentity( db.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ?, - assignee_orca_session_id = NULL + SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, + process_incarnation = ?, host_scope = ? WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` ) .run( resource.terminal_handle, resource.pane_key, + structuredWorkerOrcaSessionIdForIncarnation(resource.process_incarnation), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index e6942503dbf..fe26380d539 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -123,6 +123,20 @@ export function getWorkerTerminalResourceByHandle( .get(terminalHandle) as WorkerTerminalResourceRow | undefined } +export function getWorkerTerminalResourceByProcessIncarnation( + this: OrchestrationDb, + processIncarnation: string +): WorkerTerminalResourceRow | undefined { + const row = this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? ORDER BY updated_at DESC LIMIT 1` + ) + .get(processIncarnation) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return row as WorkerTerminalResourceRow | undefined +} + export function getWorkerTerminalResourceFormerlyOwnedBy( this: OrchestrationDb, dispatchId: string @@ -238,6 +252,7 @@ export type WorkerTerminalResourceStoreMethods = { createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource getWorkerTerminalResourceByHandle: typeof getWorkerTerminalResourceByHandle + getWorkerTerminalResourceByProcessIncarnation: typeof getWorkerTerminalResourceByProcessIncarnation getWorkerTerminalResourceByOwner: typeof getWorkerTerminalResourceByOwner getWorkerTerminalResourceFormerlyOwnedBy: typeof getWorkerTerminalResourceFormerlyOwnedBy recordWorkerTerminalRecoveryAttempt: typeof recordWorkerTerminalRecoveryAttempt @@ -251,6 +266,7 @@ export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): createWorkerTerminalResourceStatement, getWorkerTerminalResource, getWorkerTerminalResourceByHandle, + getWorkerTerminalResourceByProcessIncarnation, getWorkerTerminalResourceByOwner, getWorkerTerminalResourceFormerlyOwnedBy, recordWorkerTerminalRecoveryAttempt, diff --git a/src/main/runtime/orchestration/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts new file mode 100644 index 00000000000..cce3cecd548 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -0,0 +1,76 @@ +import type { RunRow } from './types' +import { isEquivalentPaneKey } from './db/pane-key-match' +import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' + +/** + * Who an orchestration caller is, as Run binding and mail routing match it. + * + * A PTY agent is its terminal: a handle and a pane key, no Orca session id. An agent that is a + * structured session is its Orca session id, addressed as `session:`; a structured worker also + * has the handle and pane key it was minted, and an ordinary chat has neither. Methods pass this + * through whole and never branch on which fields are set; the lookups below own that. + */ +export type OrchestrationCallerIdentity = Readonly<{ + /** Mailbox address the caller sends from and reads: its terminal handle, else its session address. */ + address: string + terminalHandle: string | null + paneKey: string | null + /** The bare Orca session id the caller is addressed by; mail spells it `session:`. */ + orcaSessionId: OrcaSessionId | null +}> + +/** The part of a caller a Run binding stores and matches. */ +export type OrchestrationCoordinatorKey = Pick< + OrchestrationCallerIdentity, + 'terminalHandle' | 'paneKey' | 'orcaSessionId' +> + +/** A caller the dispatch entry resolved from the Orca session id in its injected environment. */ +export type OrchestrationSessionCaller = OrchestrationCallerIdentity & + Readonly<{ + orcaSessionId: OrcaSessionId + /** The session record the request came from. */ + sessionId: OrcaSessionId + /** Where the session runs, from its record; `worker-start --worktree current` places here. */ + workspaceId: string + }> + +/** A caller with neither a pane nor an Orca session id can never be bound to a Run. */ +export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { + return caller.paneKey !== null || caller.orcaSessionId !== null +} + +/** The one address a party reads mail at and is sent mail at; null for a key naming nobody. */ +export function mailboxAddressOf( + party: Pick +): string | null { + // Handle first because a worker's mail and Dispatch rows are keyed by it today; flips when sessions become canonical. + if (party.terminalHandle !== null) { + return party.terminalHandle + } + return party.orcaSessionId === null ? null : formatOrcaSessionAddress(party.orcaSessionId) +} + +/** Who a Run's binding names now; an Orca session id an older binding left behind is not part of it. */ +export function runCoordinatorKey(run: RunRow): OrchestrationCoordinatorKey { + return { + terminalHandle: run.coordinator_handle, + paneKey: run.coordinator_pane_key, + orcaSessionId: currentRunCoordinatorOrcaSessionId(run) + } +} + +export function runBoundToCoordinator(run: RunRow, caller: OrchestrationCoordinatorKey): boolean { + if ( + caller.paneKey !== null && + run.coordinator_pane_key !== null && + isEquivalentPaneKey(run.coordinator_pane_key, caller.paneKey) + ) { + return true + } + return ( + caller.orcaSessionId !== null && + currentRunCoordinatorOrcaSessionId(run) === caller.orcaSessionId + ) +} diff --git a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts index 69186334828..b721dcc166e 100644 --- a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts @@ -11,6 +11,7 @@ import { import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { RUN_PANE_KEY_MATCH_SUFFIX_SQL } from './db/pane-key-match' import { currentRunCoordinatorOrcaSessionId, @@ -18,8 +19,8 @@ import { } from './db/runs/run-coordinator-orca-session' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' -const SESSION_ID = '5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11' -const CHAT_SESSION_ID = '9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93' +const SESSION_ID = testOrcaSessionId('5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11') +const CHAT_SESSION_ID = testOrcaSessionId('9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93') const CHAT_SESSION_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) const ORCA_SESSION_ID_COLUMNS = [ 'assignee_orca_session_id', @@ -49,6 +50,25 @@ const HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL = ` VALUES (NEW.id, NEW.coordinator_handle); END;` +// The mail routing trigger as main recreated it on every open at v41 and before: handle-only. +const HANDLE_ONLY_MAIL_ROUTING_TRIGGER_SQL = ` + CREATE TRIGGER trg_messages_route_coordinator_mail + AFTER INSERT ON messages + WHEN NEW.read = 0 AND NEW.delivery_contract = 'current_delivery' + AND EXISTS (SELECT 1 FROM runs WHERE runs.id = NEW.run_id AND runs.legacy = 0) + AND EXISTS ( + SELECT 1 FROM run_coordinator_handles + WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle + ) + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle + AND status IN ('pending', 'dispatched') + ) + BEGIN + UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence; + END;` + const V41_RUN_COLUMNS = 'id, objective, home_database, coordinator_handle, coordinator_pane_key, consumer_generation, legacy, created_at, updated_at' @@ -117,11 +137,13 @@ function stripOrcaSessionSchema(path: string, version: number): void { DROP INDEX idx_dispatch_assignee_orca_session_id; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; + DROP TRIGGER trg_messages_route_coordinator_mail; ALTER TABLE runs DROP COLUMN coordinator_orca_session_id; ALTER TABLE runs DROP COLUMN coordinator_orca_session_id_generation; ALTER TABLE dispatch_contexts DROP COLUMN assignee_orca_session_id; ALTER TABLE dispatch_contexts DROP COLUMN creator_orca_session_id; ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} + ${HANDLE_ONLY_MAIL_ROUTING_TRIGGER_SQL} `) raw.pragma(`user_version = ${version}`) raw.close() @@ -419,8 +441,11 @@ describe('orchestration Orca session id column migration', () => { it('fills structured-worker rows written after the stamp reached v42 on the next open', () => { const path = tempDbPath() const first = new OrchestrationDb(path) - // No writer records an Orca session id yet, which is also the shape a binary rolled back past v42 writes. const rows = seedStructuredAndPtyRows(first) + // The shape a binary rolled back past v42 writes: its INSERTs name no Orca session id column. + first.db.exec( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = NULL, creator_orca_session_id = NULL; UPDATE runs SET coordinator_orca_session_id = NULL' + ) expect( first.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id ).toBeNull() diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts new file mode 100644 index 00000000000..65b6d7a5c5a --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -0,0 +1,108 @@ +// The one place an orchestration address becomes a party, so no two sites can disagree on who it names. +import { + formatOrcaSessionAddress, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { + isRecordedStructuredWorkerSession, + resolveStructuredWorkerIdentity, + resolveStructuredWorkerIdentityForSession +} from '../structured-worker-authority' +import { canonicalOrcaSessionId } from './canonical-orca-session-id' +import type { OrchestrationDb } from './db' +import { mailboxAddressOf, type OrchestrationCallerIdentity } from './orchestration-caller-identity' +import { OrchestrationError } from './orchestration-error' + +/** A party as Run binding and mail routing match it; `address` is its one mailbox address. */ +export type OrchestrationParty = OrchestrationCallerIdentity + +/** A party named by an Orca session id: a structured worker, or a chat. */ +export type OrchestrationSessionParty = OrchestrationParty & + Readonly<{ orcaSessionId: OrcaSessionId }> + +const NO_EFFECTS = { effectsApplied: false } as const + +/** Every param naming a party other than the caller; a new one is added here with its own test. */ +export const ORCHESTRATION_TARGET_PARAM: Readonly> = { + 'orchestration.send': 'to', + 'orchestration.ask': 'to', + 'orchestration.dispatch': 'to', + 'orchestration.inbox': 'terminal' +} + +/** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ +export function resolveOrcaSessionParty( + orcaSessionId: OrcaSessionId, + db: OrchestrationDb | null | undefined +): OrchestrationSessionParty { + const id = canonicalOrcaSessionId(orcaSessionId) + const worker = resolveStructuredWorkerIdentityForSession(id, db) + if (!worker && db && isRecordedStructuredWorkerSession(id, db)) { + // Why: handle-less, it would split one worker into two parties and bind like a chat. + throw new OrchestrationError( + CODES.notLive, + `Agent session ${id} is a structured worker whose worker identity this host no longer has, so orchestration cannot address it. No effects were applied.`, + NO_EFFECTS + ) + } + const key = { terminalHandle: worker?.handle ?? null, orcaSessionId: id } + return { + ...key, + address: mailboxAddressOf(key) ?? formatOrcaSessionAddress(id), + paneKey: worker?.paneKey ?? null + } +} + +/** The party an address names; a PTY handle is itself and costs no lookup. */ +export function resolveOrchestrationParty( + address: string, + db: OrchestrationDb | null | undefined +): OrchestrationParty { + const orcaSessionId = parseOrcaSessionAddress(address) + if (orcaSessionId) { + return resolveOrcaSessionParty(orcaSessionId, db) + } + const worker = resolveStructuredWorkerIdentity(address, db) + return { + address, + terminalHandle: address, + paneKey: worker?.paneKey ?? null, + orcaSessionId: + worker && isOrcaSessionId(worker.sessionId) ? canonicalOrcaSessionId(worker.sessionId) : null + } +} + +/** A caller named by a param: naming a chat's address proves nothing, unlike its own session id. */ +export function resolveDeclaredCallerParty( + address: string, + db: OrchestrationDb | null | undefined +): OrchestrationParty { + const party = resolveOrchestrationParty(address, db) + if (party.terminalHandle === null) { + throw new OrchestrationError( + CODES.chatNotDeclarable, + `Agent session ${party.orcaSessionId} is a chat, and a chat is identified only by the session id its own environment sends, never by naming its address. No effects were applied.`, + NO_EFFECTS + ) + } + return party +} + +/** A Dispatch assignee: a terminal or a structured worker, never a chat yet. */ +export function resolveDispatchAssigneeParty( + address: string, + db: OrchestrationDb | null | undefined +): OrchestrationParty { + const party = resolveOrchestrationParty(address, db) + if (party.terminalHandle === null) { + throw new OrchestrationError( + CODES.chatNotDispatchable, + `Agent session ${party.orcaSessionId} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, + NO_EFFECTS + ) + } + return party +} diff --git a/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts index f93e1e4a4b5..4dd07052d6f 100644 --- a/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts +++ b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts @@ -6,6 +6,7 @@ import { formatOrcaSessionAddress, parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -14,9 +15,9 @@ import { import { OrchestrationDb } from './db' import { backfillStructuredWorkerOrcaSessionIds } from './db/schema/structured-worker-orca-session-backfill' -const CHAT_SESSION_ID = '3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14' +const CHAT_SESSION_ID = testOrcaSessionId('3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14') const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) -const WORKER_SESSION_ID = '4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25' +const WORKER_SESSION_ID = testOrcaSessionId('4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25') const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION_ID) const PTY_PANE = 'tab_pty:66666666-6666-4666-8666-666666666666' diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 93fa58fd6b5..31ee011193a 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -186,6 +186,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { const db = { getLegacyAdoptedRunMailboxOwner: () => null, getCurrentRunForPane: () => undefined, + getCurrentRunForCoordinator: () => undefined, getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], insertMessages: (rows: { to: string }[]) => { @@ -227,6 +228,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { const db = { getLegacyAdoptedRunMailboxOwner: () => null, getCurrentRunForPane: () => undefined, + getCurrentRunForCoordinator: () => undefined, getActiveDispatchForIdentity: () => ({ run_id: 'run_1' }), getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], @@ -262,12 +264,19 @@ describe('sendGroupMessage actually composes structured workers in', () => { getOrchestrationDb: () => db, notifyMessageArrived: () => {} } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub cast is unchanged; the gate flags it only because this diff adds the sender field inside its span. await sendGroupMessage({ params: { subject: 's', body: 'b', type: 'status', priority: 'normal' }, runtime: runtime as never, db: db as never, from: 'term_sender', groupAddress: '@codex', + sender: { + address: 'term_sender', + terminalHandle: 'term_sender', + paneKey: null, + orcaSessionId: null + }, senderPaneKey: undefined, senderRunId: 'run_1', explicitRunId: undefined, diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index d00ea480c7d..cd2b12a7da2 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -1,4 +1,5 @@ import type { TerminalExitCause } from '../../../shared/terminal-exit-cause' +import type { OrcaSessionId } from '../../../shared/orca-session-address' export const MESSAGE_TYPES = [ 'status', 'dispatch', @@ -47,7 +48,7 @@ export type RunRow = { coordinator_handle: string | null coordinator_pane_key: string | null /** Bare Orca session id the coordinator is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - coordinator_orca_session_id: string | null + coordinator_orca_session_id: OrcaSessionId | null /** The consumer_generation the id was written at; see currentRunCoordinatorOrcaSessionId. */ coordinator_orca_session_id_generation: number | null consumer_generation: number @@ -283,7 +284,7 @@ export type DispatchContextRow = { assignee_handle: string | null assignee_pane_key: string | null /** Bare Orca session id the assignee is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - assignee_orca_session_id: string | null + assignee_orca_session_id: OrcaSessionId | null capability_hash: string | null process_incarnation: string | null capability_revoked_at: string | null @@ -294,7 +295,7 @@ export type DispatchContextRow = { creator_handle: string | null creator_pane_key: string | null /** Bare Orca session id the creator is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - creator_orca_session_id: string | null + creator_orca_session_id: OrcaSessionId | null host_scope: string | null status: DispatchStatus failure_count: number diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 0eae5f48bb3..5e1fb1f75ef 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -10,6 +10,7 @@ import type { } from '../../../shared/mobile-relay-credential-contract' import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -100,6 +101,8 @@ export type RpcContext = { replayedMutationReceipt?: unknown // Why: Run-scoped handlers must compare declared handles with request attestation. orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence + // Why: resolved once at the dispatch entry from the caller's Orca session id; the session wins. + orchestrationCaller?: OrchestrationSessionCaller // Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding. legacyCoordinatorRunId?: string legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof diff --git a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts index 60d9728f152..d8bf3e25b53 100644 --- a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts +++ b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts @@ -82,7 +82,8 @@ export async function invokeDispatcherUnaryMethod({ request, effectiveParams, invoke, - legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint + legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, + context.orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) return result diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 6ed2f1fd9eb..e38c0768bc6 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -23,6 +23,11 @@ import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' +import { + needsOrchestrationCallerResolution, + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -69,7 +74,15 @@ export class RpcDispatcher { return migrationFence } - const parsedParams = parseRpcRequestParams(request, method, meta) + let resolved: ResolvedOrchestrationRequest = { request } + if (needsOrchestrationCallerResolution(request)) { + try { + resolved = await resolveOrchestrationSessionCaller(this.runtime, request, options) + } catch (error) { + return mapDispatcherError(request, meta, error) + } + } + const parsedParams = parseRpcRequestParams(resolved.request, method, meta) if (parsedParams.error) { return parsedParams.error } @@ -89,7 +102,7 @@ export class RpcDispatcher { try { const result = await invokeDispatcherUnaryMethod({ runtime: this.runtime, - request, + request: resolved.request, method, params: parsedParams.value, context: { @@ -106,7 +119,8 @@ export class RpcDispatcher { clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, - authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint + authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, + orchestrationCaller: resolved.caller }, orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index c2402d87849..ec7f7a0559d 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -3,6 +3,7 @@ // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access' @@ -153,7 +154,8 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ SKILL_INSTALL_RPC_ERROR_CODE, // Why: an owner conflict is a distinct client decision (reload the host, re-adopt, // stop offering the action) — flattened to runtime_error it can only be guessed at. - ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES) + ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES), + ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES) ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts index 556938eef23..a3bbe7f01a3 100644 --- a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts +++ b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts @@ -14,12 +14,18 @@ */ import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationSessionCaller } from '../../orchestration/orchestration-caller-identity' import { isStructuredWorkerHandle } from '../../structured-worker-identity' export async function resolveDispatchCallerWorktreeId( runtime: Pick, - callerHandle: string + callerHandle: string, + callerSession: OrchestrationSessionCaller | undefined ): Promise { + // A session caller's workspace is on its record, whichever owner (chat or terminal) holds it. + if (callerSession) { + return callerSession.workspaceId + } if (isStructuredWorkerHandle(callerHandle)) { const worktreeId = runtime.getOrchestrationDispatchAuthority?.(callerHandle)?.worktreeId ?? null if (worktreeId) { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index 1f29dfb4904..98a843fb695 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -136,7 +136,7 @@ describe('a structured worker-start that fails after the session exists', () => db, run: { id: 'run_1' } as never, existingTask: { id: 't1', spec: 'do the thing' } as never, - coordinatorPane: null, + coordinator: null, orchestrationMutation: undefined }) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts index a7c59b7064b..957b4a6dde7 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts @@ -1,3 +1,4 @@ +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import { isTuiAgent } from '../../../../../../shared/tui-agent-config' import type { RuntimeStatus } from '../../../../../../shared/runtime-types' import { @@ -45,6 +46,8 @@ export async function startFederatedWorker(args: { method: string payloadHash: string } + /** The coordinator's resolved session, when it is one; recorded as the Dispatch creator. */ + callerSession?: OrchestrationSessionCaller }): Promise { const { params, runtime, db, task, runId, orchestrationMutation } = args if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { @@ -117,7 +120,7 @@ export async function startFederatedWorker(args: { const setupDecision = createsWorktree ? (params.setup ?? 'run') : 'not_applicable' const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, args.callerSession), maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: task?.id, taskSpec: params.spec, diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index 29be91b4324..f0736db916c 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -84,7 +84,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateCreate', params: GateCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() let options: string[] | undefined if (params.options) { @@ -107,7 +110,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) if (task.run_id !== run.id) { throw taskNotFoundError(`Task ${params.task} was not found in Run ${run.id}.`, { @@ -127,7 +131,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateResolve', params: GateResolveParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const existing = db.getGate(params.id) if (!existing) { @@ -138,7 +145,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) // Why: a gate outside the caller's Run is indistinguishable from a missing one, so probing cannot map foreign Runs. if (existing.run_id !== run.id) { @@ -155,7 +163,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateList', params: GateListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const explicitRun = params.run ? db.getRun(params.run) : undefined // Why: same read posture as taskList — an explicitly named Run is inspectable, an unnamed one means the caller's own. @@ -167,7 +178,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: params.run === undefined, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const gates = db .listGates({ diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index ef191250a8b..9b49e7a07a1 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -5,6 +5,11 @@ import { isGroupAddress } from '../../../../orchestration/groups' import { AskParams } from '../schemas' import { rejectFederatedExplicitTarget } from '../routing' import { askRemoteRunHome } from './ask-remote' +import { + mailboxAddressOf, + runCoordinatorKey +} from '../../../../orchestration/orchestration-caller-identity' +import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ @@ -87,7 +92,12 @@ export const ORCHESTRATION_ASK_METHODS = [ `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` ) } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + if ( + params.to && + params.to !== `run:${run.id}` && + resolveOrchestrationParty(params.to, db).address !== + mailboxAddressOf(runCoordinatorKey(run)) + ) { throw new OrchestrationError( 'dispatch_run_mismatch', `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index 20ac25e6512..ee52efed6b9 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -6,6 +6,8 @@ import { checkRunMailbox } from './check-run' import { checkWorkerMailbox } from './check-worker' import { checkDirectMailbox } from './check-direct' import { orchestrationSkillRecoveryData } from '../../../../../../shared/orchestration-rpc-contract' +import { hasRunBindingKey } from '../../../../orchestration/orchestration-caller-identity' +import { orchestrationCallerIdentity } from '../runs/run-scope' import { callerHoldsDispatchPane, dispatchFenced, @@ -20,6 +22,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, signal, legacyCoordinatorRunId, @@ -31,9 +34,14 @@ export const ORCHESTRATION_CHECK_METHODS = [ const handle = params.terminal ?? 'unknown' const typeFilter = parseMessageTypes(params.types) - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + const caller = orchestrationCallerIdentity(runtime, { + handle, + session: orchestrationCaller, + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + paneKey: runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + }) + const paneKey = caller.paneKey ?? undefined + const boundRun = hasRunBindingKey(caller) ? db.getCurrentRunForCoordinator(caller) : undefined if (params.run || boundRun) { return checkRunMailbox({ params, @@ -41,6 +49,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ db, handle, paneKey, + callerSession: orchestrationCaller, typeFilter, signal, legacyCoordinatorRunId, @@ -93,7 +102,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ } // Why: a consuming check on a handle with no live pane and no Dispatch can never see // Run mail, so an empty inbox would read as "nothing yet" instead of a stale caller. - if (!paneKey && consumingCheck) { + if (!hasRunBindingKey(caller) && consumingCheck) { throw new OrchestrationError( 'stable_pane_required', `Terminal ${handle} has no live pane bound to a Run, so this inbox can never receive Run mail. Rebind this terminal with orchestration run-use, or read the Run mailbox with --run .`, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts index 6db89cf4a20..248d79ea68e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts @@ -9,6 +9,7 @@ import { routeAllMailboxPages } from '../schemas' import { resolveRunScope } from '../runs/run-scope' import type { CheckParams } from '../schemas' import type { z } from 'zod' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' type CheckParamsInput = z.infer @@ -18,6 +19,7 @@ export async function checkRunMailbox(args: { db: OrchestrationDb handle: string paneKey: string | undefined + callerSession: OrchestrationSessionCaller | undefined typeFilter: MessageType[] | undefined signal: AbortSignal | undefined legacyCoordinatorRunId: string | undefined @@ -31,6 +33,7 @@ export async function checkRunMailbox(args: { db, handle, paneKey, + callerSession, typeFilter, signal, legacyCoordinatorRunId, @@ -52,6 +55,7 @@ export async function checkRunMailbox(args: { runId: params.run, callerTerminalHandle: handle, callerPaneKey: paneKey, + callerSession, requireCurrentConsumer: true, legacyCoordinatorRunId, callerEvidence: orchestrationCompatibilityEvidence @@ -73,6 +77,7 @@ export async function checkRunMailbox(args: { runId: run.id, callerTerminalHandle: handle, callerPaneKey: paneKey, + callerSession, requireCurrentConsumer: true, legacyCoordinatorRunId, callerEvidence: orchestrationCompatibilityEvidence diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index 51a9d9bc0c5..3e59073349b 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -4,12 +4,13 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../../../shared/orchestration-rpc-contract' import { abbreviateOrchestrationTasks } from '../../../../../../shared/orchestration-task-summary' import { parseOrchestrationTaskDepsFlag } from '../../../../orchestration/task-deps-flag' -import { resolveRunScope } from '../runs/run-scope' +import { orchestrationCallerIdentity, resolveRunScope } from '../runs/run-scope' import { readMutationReplayNudge, stripMutationReplayNudge } from '../../../orchestration-mutation-executor' import { exposeMessage } from './mailbox-message-receipt' +import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' import { recordReceiptBeforeNudge, replayMutationNudge } from './mutation-replay-nudge' import { ReplyParams, @@ -27,6 +28,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, legacyCoordinatorRunId, recordMutationReceipt, @@ -73,7 +75,8 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const answered = db.answerQuestion({ messageId: question.message_id, @@ -135,7 +138,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ const db = runtime.getOrchestrationDb() // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). const messages = params.terminal - ? db.getAllMessagesForHandle(params.terminal, params.limit) + ? db.getAllMessagesForHandle( + resolveOrchestrationParty(params.terminal, db).address, + params.limit + ) : db.getInbox(params.limit) return { messages, count: messages.length } } @@ -144,7 +150,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskCreate', params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined const run = resolveRunScope(runtime, { @@ -152,10 +161,19 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. + const creatorHandle = params.callerTerminalHandle + ? orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: orchestrationCaller, + paneKey: null + }).terminalHandle + : null + const creatorAuthority = creatorHandle + ? runtime.getOrchestrationDispatchAuthority(creatorHandle) : null const task = db.createTask({ spec: params.spec, @@ -163,7 +181,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ displayName: params.displayName, deps, parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, + createdByTerminalHandle: creatorHandle ?? undefined, ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation ? { createdByPaneKey: creatorAuthority.paneKey, @@ -180,7 +198,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskList', params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const explicitRun = params.run ? db.getRun(params.run) : undefined const run = @@ -191,7 +212,8 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: params.run === undefined, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. const joined = db.listTasksWithDispatch({ @@ -218,14 +240,18 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskUpdate', params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const run = resolveRunScope(runtime, { runId: params.run, callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const existing = db.getTask(params.id) if (!existing || existing.run_id !== run.id) { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index f2b5e939a65..861cfadeac6 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -2,6 +2,9 @@ import type { LegacyAdoptedMailboxOwner, OrchestrationDb } from '../../../../orc import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { readStructuredAgentSessionRecord } from '../../../../structured-worker-authority' +import { structuredWorkerHostScope } from '../../../../structured-worker-identity' +import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' const ACTIVE_DISPATCH_STATUSES: readonly DispatchStatus[] = ['pending', 'dispatched'] @@ -55,9 +58,20 @@ export function resolveBareOrchestrationRecipient(params: { explicitRunId?: string legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null }): BareRecipientResolution { - const { runtime, db, handle } = params - const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + const { runtime, db } = params + const party = resolveOrchestrationParty(params.handle, db) + const handle = party.address + const paneKey = + party.terminalHandle === null + ? undefined + : (runtime.getLiveTerminalPaneKey(party.terminalHandle) ?? undefined) + // Why: a session-backed party's Run binding is durable, so it outranks whether its pane is live. + const boundRun = + party.orcaSessionId !== null + ? db.getCurrentRunForCoordinator(party) + : paneKey + ? db.getCurrentRunForPane(paneKey) + : undefined if (boundRun) { const mismatch = runMismatch(handle, boundRun.id, params.explicitRunId) return mismatch ?? { ok: true, to: `run:${boundRun.id}`, runId: boundRun.id } @@ -102,7 +116,19 @@ export function resolveBareOrchestrationRecipient(params: { } } - const message = `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` + const chatSessionId = party.terminalHandle === null ? party.orcaSessionId : null + if (chatSessionId !== null) { + const record = readStructuredAgentSessionRecord(chatSessionId) + // Unlike a terminal handle, a session address outlives its process, so its direct mail is durable. + if (record && structuredWorkerHostScope(record.location)) { + return { ok: true, to: handle, runId: params.senderRunId } + } + } + + const message = + chatSessionId !== null + ? `Agent session ${chatSessionId} does not run on this host and has no durable Run/Dispatch mailbox.` + : `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` return { ok: false, code: 'terminal_not_found', diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index d5ee366a40f..cb75bca2705 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -13,6 +13,7 @@ import { exposeMessages } from './mailbox-message-receipt' import { recordReceiptBeforeNudge } from './mutation-replay-nudge' import type { BareRecipientResolution, SendRecipientWarning } from './recipient-routing' import type { SendParams } from '../schemas' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' import type { z } from 'zod' type SendParamsInput = z.infer @@ -94,6 +95,7 @@ export async function sendGroupMessage(args: { db: OrchestrationDb from: string groupAddress: string + sender: OrchestrationCallerIdentity senderPaneKey: string | undefined senderRunId: string | undefined explicitRunId: string | undefined @@ -108,6 +110,7 @@ export async function sendGroupMessage(args: { db, from, groupAddress, + sender, senderPaneKey, senderRunId, explicitRunId, @@ -117,7 +120,7 @@ export async function sendGroupMessage(args: { } = args // Audience follows the sender's binding, never a caller-supplied message Run or payload. function resolveAudienceRunId(): string { - const coordinated = senderPaneKey ? db.getCurrentRunForPane(senderPaneKey) : undefined + const coordinated = db.getCurrentRunForCoordinator(sender) const runId = coordinated?.id ?? db.getActiveDispatchForIdentity(from, senderPaneKey)?.run_id ?? diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 7d1edb77602..28f5a4d67d5 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -19,6 +19,7 @@ import { sendRemoteMessage } from './send-remote' import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' +import { orchestrationCallerIdentity } from '../runs/run-scope' export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ @@ -35,6 +36,7 @@ export const ORCHESTRATION_SEND_METHODS = [ recordMutationReceipt, markWorkerDoneMutationEffectFree, replayedMutationReceipt, + orchestrationCaller, signal } ) => { @@ -59,7 +61,12 @@ export const ORCHESTRATION_SEND_METHODS = [ ? orchestrationCompatibilityCallerAuthority : undefined // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const sender = orchestrationCallerIdentity(runtime, { + handle: from, + session: orchestrationCaller, + paneKey: attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) + }) + const senderPaneKey = sender.paneKey ?? undefined const remoteAttachment = senderPaneKey ? db.findActiveRemoteAttachmentForPane(senderPaneKey) : undefined @@ -84,8 +91,7 @@ export const ORCHESTRATION_SEND_METHODS = [ params.to && isGroupAddress(params.to) && !params.to.toLowerCase().startsWith('@worktree:') // Run groups validate their own audience; message scope cannot select a parent Dispatch. const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, + sender, to: params.to, runId: runGroup ? undefined : params.run, payload: runGroup ? undefined : params.payload @@ -199,6 +205,7 @@ export const ORCHESTRATION_SEND_METHODS = [ db, from, groupAddress: to, + sender, senderPaneKey, senderRunId: routing.run?.id, explicitRunId: params.run, diff --git a/src/main/runtime/rpc/methods/orchestration/routing.ts b/src/main/runtime/rpc/methods/orchestration/routing.ts index 47001283895..5978eb60cd1 100644 --- a/src/main/runtime/rpc/methods/orchestration/routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/routing.ts @@ -1,6 +1,7 @@ import type { MessageType } from '../../../orchestration/db' import type { RunRow } from '../../../orchestration/types' import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { OrchestrationCallerIdentity } from '../../../orchestration/orchestration-caller-identity' import { MESSAGE_TYPES } from '../../../orchestration/types' import { OrchestrationError } from '../../../orchestration/orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../../../orchestration/db' @@ -20,8 +21,8 @@ export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | export function resolveMessageRun( runtime: OrcaRuntimeService, params: { - from?: string - senderPaneKey?: string + /** The sender as Run binding and Dispatch identity see it. */ + sender: OrchestrationCallerIdentity to?: string runId?: string payload?: string @@ -50,9 +51,7 @@ export function resolveMessageRun( const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined + : db.getActiveDispatchForIdentity(params.sender.address, params.sender.paneKey ?? undefined) if (params.to?.startsWith('dispatch:') && !dispatch) { throw new OrchestrationError( 'dispatch_not_found', @@ -63,9 +62,8 @@ export function resolveMessageRun( const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (!run) { + run = db.getCurrentRunForCoordinator(params.sender) } if (resolvedRunId && (!run || run.legacy === 1)) { throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts index 8cdbcb8da11..0ee7f7523e7 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts @@ -1,5 +1,7 @@ import type { DispatchCreator } from '../../../../orchestration/db/dispatch-depth' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { orchestrationCallerIdentity } from './run-scope' /** * Identify a CLI caller for nesting-depth purposes. @@ -10,18 +12,35 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' */ export function resolveDispatchCreator( runtime: OrcaRuntimeService, - callerHandle: string | undefined + callerHandle: string | undefined, + callerSession: OrchestrationSessionCaller | undefined ): DispatchCreator { if (!callerHandle) { // No declared caller means no resolvable parent. Depth 0 is the same answer // the pre-existing Run-binding check already gives this case. return { kind: 'system' } } - const authority = runtime.getOrchestrationDispatchAuthority?.(callerHandle) + const caller = orchestrationCallerIdentity(runtime, { + handle: callerHandle, + session: callerSession, + paneKey: null + }) + if (caller.terminalHandle === null) { + // A handle-less session: its Orca session id is its whole identity. + return caller.orcaSessionId + ? { kind: 'session', orcaSessionId: caller.orcaSessionId } + : { kind: 'system' } + } + const authority = runtime.getOrchestrationDispatchAuthority?.(caller.terminalHandle) return { kind: 'terminal', - handle: callerHandle, - paneKey: authority?.paneKey ?? runtime.getTerminalPaneKey(callerHandle) ?? undefined, - processIncarnation: authority?.processIncarnation ?? undefined + handle: caller.terminalHandle, + paneKey: + authority?.paneKey ?? + caller.paneKey ?? + runtime.getTerminalPaneKey(caller.terminalHandle) ?? + undefined, + processIncarnation: authority?.processIncarnation ?? undefined, + ...(caller.orcaSessionId ? { orcaSessionId: caller.orcaSessionId } : {}) } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 75c83878335..843df33357a 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -12,6 +12,7 @@ import { } from '../../../../orchestration/task-dispatch-refusal' import { resolveRunScope } from './run-scope' import { DispatchParams, DispatchShowParams } from '../schemas' +import { resolveDispatchAssigneeParty } from '../../../../orchestration/orchestration-party' export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ @@ -21,6 +22,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, legacyCoordinatorRunId, revalidateLegacyCoordinator, @@ -37,7 +39,8 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) if (task.run_id !== run.id) { throw taskNotFoundError(`Task ${task.id} was not found in Run ${run.id}.`, { @@ -45,12 +48,13 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ runId: run.id }) } + const assignee = params.to ? resolveDispatchAssigneeParty(params.to, db).address : undefined // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. if (params.dryRun) { const maxDepth = runtime.getNestedWorkerMaxDepth() const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), + resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth ) const preamble = buildDispatchPreamble({ @@ -59,19 +63,17 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ canDispatchSubWorkers: previewDepth < maxDepth, taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', - workerHandle: params.to ?? 'worker', + workerHandle: assignee ?? 'worker', devMode: params.devMode, - ...(params.to - ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } - : {}) + ...(assignee ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(assignee) } : {}) }) return { dispatch: null, injected: false, dryRun: true, preamble } } - if (!params.to) { + if (!assignee) { throw new Error('Missing --to') } - const to = params.to + const to = assignee if (task.status !== 'ready') { throw taskNotStartableError( @@ -131,7 +133,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ assigneePaneKey, launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth: runtime.getNestedWorkerMaxDepth() }) const dispatchCapability = params.inject diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts index 49afdfe119a..93d6f7bdda9 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { exposeRun } from './run-receipt' import type { RunRow } from '../../../../orchestration/types' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' // Why: typecheck cannot see the strip because the RPC return types are loose. const RUN_ROW: RunRow = { @@ -9,7 +10,7 @@ const RUN_ROW: RunRow = { home_database: '/tmp/orca/orchestration.db', coordinator_handle: 'term_coord', coordinator_pane_key: 'tab_coord:11111111-1111-4111-8111-111111111111', - coordinator_orca_session_id: '22222222-2222-4222-8222-222222222222', + coordinator_orca_session_id: testOrcaSessionId('22222222-2222-4222-8222-222222222222'), coordinator_orca_session_id_generation: 3, consumer_generation: 3, legacy: 0, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts index 6b066723315..0496c92806e 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts @@ -6,11 +6,19 @@ import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../../../../orca-runtime' +import { + hasRunBindingKey, + type OrchestrationCallerIdentity, + type OrchestrationSessionCaller +} from '../../../../orchestration/orchestration-caller-identity' +import { resolveDeclaredCallerParty } from '../../../../orchestration/orchestration-party' export type RunScopeParams = { runId?: string callerTerminalHandle?: string callerPaneKey?: string + /** Resolved at the dispatch entry; when set it is the caller, whatever the declared handle. */ + callerSession: OrchestrationSessionCaller | undefined requireCurrentConsumer: boolean legacyCoordinatorRunId?: string // Why: the caller's declared handle is a user param; this is the attested one to check it against. @@ -36,10 +44,30 @@ export function assertCallerHandleMatchesEvidence( } } +/** + * The caller as Run binding and mail routing see it. A session the dispatch entry resolved is the + * caller outright; otherwise it is the party the declared handle names, at the pane it resolved to. + */ +export function orchestrationCallerIdentity( + runtime: OrcaRuntimeService, + caller: { + handle: string + paneKey: string | null | undefined + session: OrchestrationSessionCaller | undefined + } +): OrchestrationCallerIdentity { + if (caller.session) { + return caller.session + } + const party = resolveDeclaredCallerParty(caller.handle, runtime.getOrchestrationDb()) + return { ...party, paneKey: caller.paneKey ?? null } +} + export type OrchestrationCallerParams = { callerTerminalHandle: string callerEvidence?: OrchestrationCompatibilityEvidence callerAuthority?: OrchestrationCompatibilityCallerAuthority + callerSession: OrchestrationSessionCaller | undefined /** Preserve legacy callers that treated a missing pane as an ordinary fence. */ requireStablePane?: boolean /** @@ -50,33 +78,42 @@ export type OrchestrationCallerParams = { evidenceAssertedByCaller?: boolean } -/** Resolve the caller's runtime pane and, by default, attest its declared handle. */ +/** Resolve the caller's identity and, by default, attest its declared handle. */ export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams & { requireStablePane: true } -): string +): OrchestrationCallerIdentity export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams -): string | null +): OrchestrationCallerIdentity | null export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams -): string | null { +): OrchestrationCallerIdentity | null { if (!params.evidenceAssertedByCaller) { assertCallerHandleMatchesEvidence(runtime, params.callerTerminalHandle, params.callerEvidence) } - const paneKey = - params.callerAuthority?.terminalHandle === params.callerTerminalHandle - ? params.callerAuthority.paneKey - : runtime.getTerminalPaneKey(params.callerTerminalHandle) - if (!paneKey && params.requireStablePane) { - throw new OrchestrationError( - 'stable_pane_required', - 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' - ) + const caller = orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: params.callerSession, + paneKey: + params.callerSession === undefined + ? params.callerAuthority?.terminalHandle === params.callerTerminalHandle + ? params.callerAuthority.paneKey + : runtime.getTerminalPaneKey(params.callerTerminalHandle) + : undefined + }) + if (!hasRunBindingKey(caller)) { + if (params.requireStablePane) { + throw new OrchestrationError( + 'stable_pane_required', + 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' + ) + } + return null } - return paneKey ?? null + return caller } // Why: task and gate mutations must share one Run-binding rule. @@ -101,14 +138,21 @@ export function resolveRunScope(runtime: OrcaRuntimeService, params: RunScopePar if (explicit && params.legacyCoordinatorRunId === explicit.id) { return explicit } - const paneKey = params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle) - if (!paneKey) { + const caller = orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: params.callerSession, + paneKey: + params.callerSession === undefined + ? (params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle)) + : undefined + }) + if (!hasRunBindingKey(caller)) { throw new OrchestrationError( 'stable_pane_required', 'The coordinator terminal has no stable pane identity.' ) } - const current = db.getCurrentRunForPane(paneKey) + const current = db.getCurrentRunForCoordinator(caller) if (!current) { if (explicit) { throw new OrchestrationError( diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index eab6eb2913e..f074b5eba03 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -14,18 +14,20 @@ export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', params: RunCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - const paneKey = resolveOrchestrationCaller(runtime, { + handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller, requireStablePane: true }) const db = runtime.getOrchestrationDb() - const priorRun = db.getCurrentRunForPane(paneKey) + const priorRun = db.getCurrentRunForCoordinator(caller) const run = db.createRun({ objective: params.objective, - coordinatorHandle: params.from, - coordinatorPaneKey: paneKey + coordinatorHandle: caller.terminalHandle, + coordinatorPaneKey: caller.paneKey, + coordinatorOrcaSessionId: caller.orcaSessionId }) runtime.cancelMessageWaiters(params.from) if (priorRun) { @@ -43,19 +45,22 @@ export const ORCHESTRATION_RUN_METHODS = [ runtime, legacyCoordinatorAuthority, orchestrationCompatibilityEvidence, - orchestrationCompatibilityCallerAuthority: callerAuthority + orchestrationCompatibilityCallerAuthority: callerAuthority, + orchestrationCaller } ) => { - const paneKey = resolveOrchestrationCaller(runtime, { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, callerAuthority, + callerSession: orchestrationCaller, requireStablePane: true, evidenceAssertedByCaller: true }) if ( params.takeoverLegacy && - (callerAuthority?.terminalHandle !== params.from || callerAuthority.paneKey !== paneKey) + (callerAuthority?.terminalHandle !== params.from || + callerAuthority.paneKey !== caller.paneKey) ) { throw new OrchestrationError( 'legacy_read_only', @@ -65,11 +70,12 @@ export const ORCHESTRATION_RUN_METHODS = [ } assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence) const db = runtime.getOrchestrationDb() - const priorRun = db.getCurrentRunForPane(paneKey) + const priorRun = db.getCurrentRunForCoordinator(caller) const run = db.bindRun({ runId: params.id, - coordinatorHandle: params.from, - coordinatorPaneKey: paneKey, + coordinatorHandle: caller.terminalHandle, + coordinatorPaneKey: caller.paneKey, + coordinatorOrcaSessionId: caller.orcaSessionId, takeoverLegacy: params.takeoverLegacy, legacyCoordinatorAuthority }) @@ -90,13 +96,14 @@ export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCurrent', params: RunCurrentParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - const paneKey = resolveOrchestrationCaller(runtime, { + handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller, requireStablePane: true }) - const run = runtime.getOrchestrationDb().getCurrentRunForPane(paneKey) + const run = runtime.getOrchestrationDb().getCurrentRunForCoordinator(caller) return { run: run ? exposeRun(run) : null } } }), diff --git a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts index 6e20cc40a29..f9b9e195e21 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts @@ -1,6 +1,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' /** * Admits a caller-supplied `--terminal` as this dispatch's worker pane. @@ -13,18 +14,22 @@ export async function assertExplicitWorkerTerminalUsable(args: { runtime: OrcaRuntimeService terminal: string from: string - coordinatorPane: string | null + coordinator: OrchestrationCallerIdentity | null resolvedWorktreeId: string | undefined }): Promise { - const { runtime, terminal, from, coordinatorPane, resolvedWorktreeId } = args + const { runtime, terminal, from, coordinator, resolvedWorktreeId } = args const explicitTerminal = await runtime.showTerminal(terminal) const targetPane = runtime.getTerminalPaneKey(terminal) - const callerPane = coordinatorPane ?? runtime.getTerminalPaneKey(from) + const callerPane = coordinator?.paneKey ?? runtime.getTerminalPaneKey(from) // A structured coordinator has no terminal to show, so its own identity is the raw handle plus - // the pane key; showing `from` unconditionally would throw for exactly those callers. - const coordinatorHandle = isStructuredWorkerHandle(from) - ? from - : (await runtime.showTerminal(from)).handle + // the pane key; showing `from` unconditionally would throw for exactly those callers. A + // handle-less session has no terminal at all, so its address is its identity. + const coordinatorHandle = + coordinator?.terminalHandle === null + ? coordinator.address + : isStructuredWorkerHandle(from) + ? from + : (await runtime.showTerminal(from)).handle if ( explicitTerminal.handle === coordinatorHandle || (targetPane !== null && targetPane === callerPane) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f8cd1033c97..d32232f8447 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -2,6 +2,10 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' +import type { + OrchestrationCallerIdentity, + OrchestrationSessionCaller +} from '../../../../orchestration/orchestration-caller-identity' import { resolveDispatchCreator } from '../runs/dispatch-creator' import { resolveDispatchCallerWorktreeId } from '../../orchestration-caller-workspace' import { @@ -38,18 +42,25 @@ export async function startLocalWorker(args: { runtime: OrcaRuntimeService db: OrchestrationDb run: RunRow - coordinatorPane: string | null + coordinator: OrchestrationCallerIdentity | null + callerSession?: OrchestrationSessionCaller existingTask?: TaskRow orchestrationMutation?: WorkerStartMutation /** Settings-driven; the executing host still gets to refuse below. */ mode: WorkerStartModeReceipt }): Promise { - const { params, runtime, db, run, coordinatorPane, existingTask, orchestrationMutation } = args + const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const { orchestrationMutation } = args + const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) - const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId(runtime, params.from) + const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId( + runtime, + params.from, + callerSession + ) const creationWorktree = createsWorktree ? await runtime.showManagedWorktree(`id:${coordinatorWorktreeId}`) : undefined @@ -70,7 +81,7 @@ export async function startLocalWorker(args: { runtime, terminal: params.terminal, from: params.from, - coordinatorPane, + coordinator, resolvedWorktreeId: resolvedWorktree?.id }) } @@ -95,7 +106,7 @@ export async function startLocalWorker(args: { : 'existing_worktree' } const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, callerSession), maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: existingTask?.id, taskSpec: params.spec, @@ -103,10 +114,12 @@ export async function startLocalWorker(args: { taskDeps: parseTaskDeps(params.deps), taskParentId: params.parent, taskRunId: run.id, - taskCreatedByTerminalHandle: params.from, + // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. + taskCreatedByTerminalHandle: coordinator?.terminalHandle ?? undefined, taskCreatedByPaneKey: coordinatorPane ?? undefined, - taskCreatedByProcessIncarnation: - runtime.getTerminalProcessIncarnation(params.from) ?? undefined, + taskCreatedByProcessIncarnation: coordinator?.terminalHandle + ? (runtime.getTerminalProcessIncarnation(coordinator.terminalHandle) ?? undefined) + : undefined, taskCreatedByRunGeneration: run.consumer_generation, retryOf: params.retryOf, startOptions, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index b1a1f40d45a..be4e40ab2b6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -20,7 +20,7 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params: WorkerStartParams, handler: async ( params, - { runtime, orchestrationMutation, orchestrationCompatibilityEvidence } + { runtime, orchestrationMutation, orchestrationCompatibilityEvidence, orchestrationCaller } ) => { if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { throw new OrchestrationError( @@ -30,11 +30,12 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ } const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) const db = runtime.getOrchestrationDb() - const coordinatorPane = resolveOrchestrationCaller(runtime, { + const coordinator = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) - const run = coordinatorPane ? db.getCurrentRunForPane(coordinatorPane) : undefined + const run = coordinator ? db.getCurrentRunForCoordinator(coordinator) : undefined if (!run || (params.run && params.run !== run.id)) { throw new OrchestrationError( 'consumer_fenced', @@ -62,7 +63,8 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ db, runId: run.id, task: existingTask, - orchestrationMutation + orchestrationMutation, + callerSession: orchestrationCaller }) return receipt && typeof receipt === 'object' ? { ...receipt, mode } : receipt } @@ -71,7 +73,8 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ runtime, db, run, - coordinatorPane, + coordinator, + callerSession: orchestrationCaller, existingTask, orchestrationMutation, mode diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index e6ad7952d8c..010541357f5 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -4,6 +4,7 @@ import { isTerminalPromptMutation } from '../../../shared/orchestration-rpc-contract' import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaSessionId } from '../../../shared/orca-session-address' import { OrchestrationError } from '../orchestration/orchestration-error' import type { RpcRequest } from './core' import { @@ -53,7 +54,9 @@ export class OrchestrationMutationExecutor { request: RpcRequest, params: unknown, invoke: (mutation?: DurableMutationInvocation) => unknown, - callerFingerprintOverride?: string + callerFingerprintOverride?: string, + /** The resolved session's Orca session id; it joins the payload so another caller cannot replay it. */ + callerOrcaSessionId?: OrcaSessionId ): Promise { const requestId = request.orchestrationRequestId if (!requestId || !isDurableMutation(request.method, params)) { @@ -61,7 +64,7 @@ export class OrchestrationMutationExecutor { } const callerFingerprint = callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint() - const stableParams = replayStableCallerParams(this.runtime, params) + const stableParams = replayStableCallerParams(this.runtime, params, callerOrcaSessionId) const basePayloadHash = hashCanonical({ method: request.method, params: stableParams }) const key = `${callerFingerprint}:${requestId}` const db = this.runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/orchestration-mutation-receipt.ts b/src/main/runtime/rpc/orchestration-mutation-receipt.ts index 25a3fa1c177..1d94723550b 100644 --- a/src/main/runtime/rpc/orchestration-mutation-receipt.ts +++ b/src/main/runtime/rpc/orchestration-mutation-receipt.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { isTerminalPromptMutation } from '../../../shared/orchestration-rpc-contract' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaSessionId } from '../../../shared/orca-session-address' export const EFFECT_FREE_WORKER_DONE_CHECKPOINT = JSON.stringify({ pending: { effectFree: 'worker_done' } @@ -13,12 +14,21 @@ export type MutationReplayNudge = | { kind: 'messages'; targets: { to: string; type: string }[] } | { kind: 'federation'; runId?: string } -export function replayStableCallerParams(runtime: OrcaRuntimeService, params: unknown): unknown { +const CALLER_ORCA_SESSION_ID_KEY = '__orcaCallerOrcaSessionId' + +export function replayStableCallerParams( + runtime: OrcaRuntimeService, + params: unknown, + callerOrcaSessionId?: OrcaSessionId +): unknown { if (!params || typeof params !== 'object' || Array.isArray(params)) { return params } const source = params as Record - const result = { ...source } + // Absent for terminal callers, so their payload hashes are unchanged. + const result: Record = callerOrcaSessionId + ? { ...source, [CALLER_ORCA_SESSION_ID_KEY]: callerOrcaSessionId } + : { ...source } delete result.waitSubmitMs for (const property of ['from', 'callerTerminalHandle', 'terminal'] as const) { const handle = source[property] diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts new file mode 100644 index 00000000000..5c281583f0a --- /dev/null +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + idOf, + orchestrationRequest, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + WORKER_PANE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +const SESSION_Z = testOrcaSessionId('3f9a1c7e-6b2d-4e85-a0c4-9d1e7b3f5a26') +const ADDRESS_Z = formatOrcaSessionAddress(SESSION_Z) + +/** Worker Y's two spellings; a chat has only its session address. */ +const handle = mintStructuredWorkerHandle() +const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) +const WORKER_SPELLINGS = [ + ['its handle', handle], + ['its session address', ADDRESS_Y] +] as const + +let h: SessionCallerHarness + +beforeEach(() => { + h = createSessionCallerHarness(hostRef) + h.records.set(SESSION_Z, sessionRecord(SESSION_Z)) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) +}) + +afterEach(() => { + h.close() + vi.restoreAllMocks() +}) + +function call(sessionId: string | undefined, method: string, params: Row) { + return h.dispatch(orchestrationRequest(method, params, { sessionId })) +} + +async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await call(sessionId, method, params)) +} + +function chatRun(sessionId = SESSION_X): Promise { + return as(sessionId, 'orchestration.runCreate', { objective: 'o' }).then(({ run }) => idOf(run)) +} + +/** Worker Y assigned a Dispatch in `runId`, as `worker-start` leaves it. */ +function assignWorker(runId: string): string { + return h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }).id +} + +/** The PTY terminal assigned a Dispatch in a Run, so it may ask that Run's coordinator. */ +function assignTerminal(runId: string): void { + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'sub' }).id, + assigneeHandle: WORKER_HANDLE, + assigneePaneKey: WORKER_PANE, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) +} + +describe('every target param resolves both spellings of a party to one canonical address', () => { + it('covers exactly the target params the contract lists', () => { + expect(Object.keys(ORCHESTRATION_TARGET_PARAM).sort()).toEqual( + [ + 'orchestration.send', + 'orchestration.ask', + 'orchestration.dispatch', + 'orchestration.inbox' + ].sort() + ) + }) + + it.each(WORKER_SPELLINGS)('send: a worker at %s gets its Dispatch mailbox', async (_l, to) => { + const dispatchId = assignWorker(await chatRun()) + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to, + subject: 's' + }) + expect(message).toMatchObject({ to_handle: `dispatch:${dispatchId}` }) + }) + + it("send: a chat's session address is its own direct mailbox", async () => { + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_Z, + subject: 's' + }) + expect(message).toMatchObject({ to_handle: ADDRESS_Z }) + }) + + it.each(WORKER_SPELLINGS)('ask: a worker coordinator is asked at %s', async (_l, to) => { + assignWorker(await chatRun()) + const childRun = idOf((await as(SESSION_Y, 'orchestration.runCreate', { objective: 'c' })).run) + assignTerminal(childRun) + const asked = await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to, + question: 'q', + timeoutMs: 0 + }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: childRun }) + }) + + it("ask: a chat coordinator is asked at its session address, and not at a worker's", async () => { + const runId = await chatRun() + assignTerminal(runId) + const ask = (to: string) => + call(undefined, 'orchestration.ask', { from: WORKER_HANDLE, to, question: 'q', timeoutMs: 0 }) + + expect(await ask(ADDRESS_X)).toMatchObject({ ok: true }) + expect(await ask(ADDRESS_Y)).toMatchObject({ + ok: false, + error: { code: 'dispatch_run_mismatch' } + }) + }) + + it.each(WORKER_SPELLINGS)( + 'dispatch: a worker named by %s is the assignee, and its check reads the Dispatch', + async (_l, to) => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { task: task.id, to }) + + expect(dispatch).toMatchObject({ + assignee_handle: handle, + assignee_orca_session_id: SESSION_Y + }) + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: idOf(dispatch) + }) + } + ) + + it('dispatch: refuses a chat assignee with no row written', async () => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + + const response = await call(SESSION_X, 'orchestration.dispatch', { + task: task.id, + to: ADDRESS_Z + }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.chatNotDispatchable, + message: `Agent session ${SESSION_Z} is a chat, and a chat can't receive a dispatch yet. Start a worker with worker-start instead. No effects were applied.`, + data: { effectsApplied: false } + } + }) + expect(h.db.db.prepare('SELECT COUNT(*) AS n FROM dispatch_contexts').get()).toEqual({ n: 0 }) + expect(h.db.getTask(task.id)?.status).toBe('ready') + }) + + it.each(WORKER_SPELLINGS)('inbox: a worker named by %s lists its mail', async (_l, terminal) => { + const runId = await chatRun() + h.db.insertMessage({ from: 'term_x', to: handle, subject: 'direct', body: '', runId }) + + const { messages } = await as(undefined, 'orchestration.inbox', { terminal }) + + expect(messages).toEqual([expect.objectContaining({ subject: 'direct', to_handle: handle })]) + }) + + it("inbox: a chat's session address lists its direct mail", async () => { + await as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to: ADDRESS_Z, subject: 'z' }) + const { messages } = await as(undefined, 'orchestration.inbox', { terminal: ADDRESS_Z }) + expect(messages).toEqual([expect.objectContaining({ subject: 'z' })]) + }) +}) + +describe('a caller declared by a session address, on a request with no session id', () => { + it.each(WORKER_SPELLINGS)( + 'sends as the worker named by %s, from its handle', + async (_l, from) => { + const { message } = await as(undefined, 'orchestration.send', { + from, + to: ADDRESS_Z, + subject: 's' + }) + expect(message).toMatchObject({ from_handle: handle }) + } + ) + + it('checks the worker named by its session address as if it had named its handle', async () => { + const dispatchId = assignWorker(await chatRun()) + await as(SESSION_X, 'orchestration.send', { to: handle, subject: 'work' }) + + const byAddress = await as(undefined, 'orchestration.check', { terminal: ADDRESS_Y, all: true }) + const byHandle = await as(undefined, 'orchestration.check', { terminal: handle, all: true }) + + expect(byAddress).toEqual(byHandle) + expect(byAddress).toMatchObject({ dispatchId, messages: [{ subject: 'work' }] }) + }) + + it("refuses a chat's session address, with no effects", async () => { + const response = await call(undefined, 'orchestration.send', { + from: ADDRESS_Z, + to: WORKER_HANDLE, + subject: 's' + }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.chatNotDeclarable, + message: expect.stringContaining(`Agent session ${SESSION_Z} is a chat`), + data: { effectsApplied: false } + } + }) + expect(h.db.getInbox()).toEqual([]) + }) +}) + +describe('a structured worker that coordinates a child Run while assigned in its parent', () => { + it.each(WORKER_SPELLINGS)( + 'gets mail at %s in the child Run it reads, even with no live pane', + async (_l, to) => { + assignWorker(await chatRun()) + const childRun = idOf( + (await as(SESSION_Y, 'orchestration.runCreate', { objective: 'c' })).run + ) + // The harness answers no live pane for a structured handle, as for an evicted session. + expect(h.runtime.getLiveTerminalPaneKey(handle)).toBeNull() + + for (const sender of [SESSION_X, undefined]) { + const { message } = await as(sender, 'orchestration.send', { + ...(sender ? {} : { from: WORKER_HANDLE }), + to, + subject: `from ${sender ?? 'terminal'}` + }) + expect(message).toMatchObject({ to_handle: `run:${childRun}` }) + } + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + runId: childRun, + count: 2 + }) + } + ) +}) + +describe('no writer stores a structured worker under its session address', () => { + it('keeps every to_handle and from_handle at the canonical address across every mail writer', async () => { + const runId = await chatRun() + const task = h.db.createTask({ runId, spec: 'work' }) + await as(SESSION_X, 'orchestration.dispatch', { task: task.id, to: ADDRESS_Y }) + assignTerminal(runId) + + for (const to of [ADDRESS_Y, handle]) { + await as(SESSION_X, 'orchestration.send', { to, subject: 'down' }) + } + const fromWorker = await as(SESSION_Y, 'orchestration.send', { + from: ADDRESS_Y, + to: ADDRESS_X, + subject: 'up' + }) + await as(undefined, 'orchestration.send', { from: ADDRESS_Y, to: WORKER_HANDLE, subject: 'u' }) + await as(SESSION_X, 'orchestration.reply', { + id: idOf(fromWorker.message), + body: 'noted' + }) + await as(SESSION_X, 'orchestration.send', { to: '@all', subject: 'everyone' }) + await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to: ADDRESS_X, + question: 'q', + timeoutMs: 0 + }) + + const stored = h.db.db + .prepare( + `SELECT m.id FROM messages AS m JOIN dispatch_contexts AS d + ON d.assignee_orca_session_id IS NOT NULL + AND 'session:' || d.assignee_orca_session_id IN (m.to_handle, m.from_handle)` + ) + .all() + expect(h.db.getInbox(100).length).toBeGreaterThanOrEqual(7) + expect(stored).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-party-agreement.test.ts b/src/main/runtime/rpc/orchestration-party-agreement.test.ts new file mode 100644 index 00000000000..b0e0d302474 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-party-agreement.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + createSessionCallerHarness, + idOf, + orchestrationRequest, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +// A `/clear`ed member of X's lineage and of worker Y's, as a later lineage walk will map them. +const ALIAS_X = testOrcaSessionId('5c7e2a94-1d3b-4f68-b9a0-e4c2d6f81b37') +const ALIAS_Y = testOrcaSessionId('8a4d6f20-3e1c-4b79-a5d2-c0f7e9b3a164') + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) +vi.mock('../orchestration/canonical-orca-session-id', () => ({ + canonicalOrcaSessionId: (id: string) => + id === '5c7e2a94-1d3b-4f68-b9a0-e4c2d6f81b37' + ? '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' + : id === '8a4d6f20-3e1c-4b79-a5d2-c0f7e9b3a164' + ? '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' + : id +})) + +type Row = Record + +const handle = mintStructuredWorkerHandle() +const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) +let h: SessionCallerHarness + +beforeEach(() => { + h = createSessionCallerHarness(hostRef) + h.records.set(ALIAS_X, sessionRecord(ALIAS_X)) + h.records.set(ALIAS_Y, sessionRecord(ALIAS_Y)) +}) + +afterEach(() => { + h.close() + vi.restoreAllMocks() +}) + +function call(sessionId: string | undefined, method: string, params: Row) { + return h.dispatch(orchestrationRequest(method, params, { sessionId })) +} + +async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await call(sessionId, method, params)) +} + +function registerWorker(): void { + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) +} + +/** A Dispatch naming Y as a structured worker, in a Run a terminal coordinates. */ +function recordWorkerDispatch(): void { + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) +} + +type PartyState = { name: string; sessionId: OrcaSessionId; setup: () => Promise | void } + +const PARTY_STATES: PartyState[] = [ + { name: 'a chat with no Run', sessionId: SESSION_X, setup: () => {} }, + { + name: 'a chat coordinating a Run', + sessionId: SESSION_X, + setup: async () => void (await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })) + }, + { + name: 'a registered worker with a Dispatch', + sessionId: SESSION_Y, + setup: () => { + registerWorker() + recordWorkerDispatch() + } + }, + { + name: 'a recorded worker whose identity is gone', + sessionId: SESSION_Y, + setup: recordWorkerDispatch + }, + { + name: 'a chat that runs on another host', + sessionId: SESSION_X, + setup: () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:b' } })) + } + } +] + +describe('the caller a session id resolves to and the recipient its address resolves to agree', () => { + it.each(PARTY_STATES.map((state) => [state.name, state] as const))('%s', async (_name, state) => { + await state.setup() + const sent = await call(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: formatOrcaSessionAddress(state.sessionId), + subject: 'agree' + }) + const read = await call(state.sessionId, 'orchestration.check', { peek: true }) + + // Both refuse, or the caller reads exactly the mail sent to its address. + expect(sent.ok).toBe(read.ok) + if (read.ok) { + expect(resultOf(read)).toMatchObject({ messages: [{ subject: 'agree' }] }) + } + }) + + it('refuses a worker whose identity is gone with one code, as caller and as recipient', async () => { + recordWorkerDispatch() + const asCaller = await call(SESSION_Y, 'orchestration.runCurrent', {}) + const asRecipient = await call(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: formatOrcaSessionAddress(SESSION_Y), + subject: 's' + }) + for (const response of [asCaller, asRecipient]) { + expect(response).toMatchObject({ ok: false, error: { code: CODES.notLive } }) + } + }) +}) + +describe('every session-to-party step goes through the canonical session id', () => { + it('binds a claimed alias as its canonical session', async () => { + const { run } = await as(ALIAS_X, 'orchestration.runCreate', { objective: 'o' }) + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBe(SESSION_X) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: idOf(run) } + }) + }) + + it("delivers mail to an alias's address at the canonical session's mailbox", async () => { + const { message } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: formatOrcaSessionAddress(ALIAS_X), + subject: 's' + }) + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + }) + + it("resolves a worker alias's address to the worker's handle", async () => { + registerWorker() + const { message } = await as(undefined, 'orchestration.send', { + from: formatOrcaSessionAddress(ALIAS_Y), + to: ADDRESS_X, + subject: 's' + }) + expect(message).toMatchObject({ from_handle: handle }) + }) + + it.each([formatOrcaSessionAddress(ALIAS_X), ALIAS_X])( + 'accepts the session declared as its alias %s', + async (declared) => { + const { run } = await as(SESSION_X, 'orchestration.runCreate', { + objective: 'o', + from: declared + }) + expect(run).toMatchObject({ coordinator_handle: null }) + } + ) + + it('refuses an alias of a chat declared on a request with no session id, naming the chat', async () => { + const response = await call(undefined, 'orchestration.send', { + from: formatOrcaSessionAddress(ALIAS_X), + to: WORKER_HANDLE, + subject: 's' + }) + expect(response).toMatchObject({ + ok: false, + error: { code: CODES.chatNotDeclarable, message: expect.stringContaining(SESSION_X) } + }) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts new file mode 100644 index 00000000000..396b0b2bec7 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -0,0 +1,167 @@ +import { vi } from 'vitest' +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationDb } from '../orchestration/db' +import { structuredWorkerIdentities } from '../structured-worker-identity' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ORCHESTRATION_METHODS } from './methods/orchestration' + +export const SESSION_X = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +export const SESSION_Y = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') +export const ADDRESS_X = formatOrcaSessionAddress(SESSION_X) +export const ADDRESS_Y = formatOrcaSessionAddress(SESSION_Y) +export const PROVIDER_ID_X = 'c0ffee11-2233-4455-8677-8899aabbccdd' +export const WORKSPACE_X = 'repo_1::/work/tree-x' +export const WORKER_HANDLE = 'term_worker' +export const WORKER_PANE = 'tab_worker:77777777-7777-4777-8777-777777777777' + +export type SessionHostRef = { current: unknown } + +/** A live chat session record; the lease and location can be pushed into any state. */ +export function sessionRecord( + sessionId: string, + overrides: { + lease?: Partial + location?: Partial + providerId?: string + } = {} +): AgentSessionRecord { + const base = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId, runtimeKind: 'native', ...overrides.lease }) + ) + return { + ...base, + location: { ...base.location, workspaceId: WORKSPACE_X, ...overrides.location }, + providerHandleChain: base.providerHandleChain.map((link) => ({ + ...link, + handle: { + provider: 'claude' as const, + sessionId: overrides.providerId ?? `provider-${sessionId}`, + leafUuid: null + } + })) + } +} + +export type SessionCallerHarness = { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatcher: RpcDispatcher + records: Map + /** Unary socket route: what the local CLI's Unix socket and Electron IPC reach. */ + dispatch: (request: RpcRequest) => Promise + /** Streaming dispatcher, optionally as a paired client on another host. */ + dispatchStreaming: (request: RpcRequest, pairedDeviceId?: string) => Promise + close: () => void +} + +export function createSessionCallerHarness(hostRef: SessionHostRef): SessionCallerHarness { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === WORKER_HANDLE ? WORKER_PANE : null + ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) + const records = new Map([ + [SESSION_X, sessionRecord(SESSION_X, { providerId: PROVIDER_ID_X })], + [SESSION_Y, sessionRecord(SESSION_Y)] + ]) + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => records.get(sessionId) ?? null, + listRecords: () => [...records.values()] + } + } + } + structuredWorkerIdentities.clear() + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + return { + runtime, + db, + dispatcher, + records, + dispatch: (request) => dispatcher.dispatch(request), + dispatchStreaming: async (request, pairedDeviceId) => { + const replies: string[] = [] + await dispatcher.dispatchStreaming( + request, + (reply) => replies.push(reply), + pairedDeviceId ? { pairedDeviceId } : {} + ) + const [reply] = replies + if (replies.length !== 1 || reply === undefined) { + throw new Error(`expected exactly one reply, got ${replies.length}`) + } + const parsed: unknown = JSON.parse(reply) + return parsed + }, + close: () => { + hostRef.current = null + structuredWorkerIdentities.clear() + db.close() + } + } +} + +let requestCounter = 0 + +/** An orchestration request as the CLI sends it, naming its caller by session id when given. */ +export function orchestrationRequest( + method: string, + params: Record, + options: { + sessionId?: string + requestId?: string + evidence?: OrchestrationCompatibilityEvidence + } = {} +): RpcRequest { + requestCounter += 1 + const requestId = options.requestId ?? `req-${requestCounter}` + const evidence = + options.sessionId === undefined + ? options.evidence + : { ...options.evidence, agentSessionId: options.sessionId } + return { + id: `rpc-${requestCounter}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: requestId, + compatibilityInvocationId: requestId, + ...(evidence ? { orchestrationCompatibilityEvidence: evidence } : {}) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function resultOf(response: unknown): Record { + if (!isRecord(response) || response.ok !== true || !isRecord(response.result)) { + throw new Error(`expected a successful response, got ${JSON.stringify(response)}`) + } + return response.result +} + +/** The `id` of a row a receipt carries. */ +export function idOf(row: unknown): string { + if (!isRecord(row) || typeof row.id !== 'string') { + throw new Error(`expected a row with an id, got ${JSON.stringify(row)}`) + } + return row.id +} diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts new file mode 100644 index 00000000000..d96e214d442 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -0,0 +1,404 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ZodObject } from 'zod' +import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { DeviceRegistry } from '../device-registry' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { buildRegistry } from './core' +import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + createSessionCallerHarness, + orchestrationRequest, + PROVIDER_ID_X, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' +import { + needsOrchestrationCallerResolution, + ORCHESTRATION_CALLER_PARAM +} from './orchestration-session-caller' +import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +// Fields that can name a party: the caller in ORCHESTRATION_CALLER_PARAM, a target in ORCHESTRATION_TARGET_PARAM. +const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] as const +// `method field` pairs with such a field that is neither, so never resolves as a party. +const NAMES_NO_RESOLVED_PARTY: Readonly> = { + 'orchestration.run from': 'retired; refused before any handler', + 'orchestration.runShow from': 'reads a Run by id; `from` is unused', + 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', + 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', + 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', + 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' +} + +/** One request per identity-consulting method, valid enough to reach the dispatcher entry. */ +const MINIMAL_PARAMS: Readonly>> = { + 'orchestration.runCreate': { objective: 'o' }, + 'orchestration.runUse': { id: 'run_missing' }, + 'orchestration.runCurrent': {}, + 'orchestration.check': {}, + 'orchestration.send': { subject: 's', to: 'term_worker' }, + 'orchestration.reply': { id: 'msg_missing', body: 'b' }, + 'orchestration.ask': { question: 'q', to: 'term_worker' }, + 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, + 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, + 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, + 'orchestration.gateList': {}, + 'orchestration.taskCreate': { spec: 's' }, + 'orchestration.taskList': {}, + 'orchestration.taskUpdate': { id: 'task_missing', status: 'completed' }, + 'orchestration.workerStart': { spec: 's' } +} + +describe('orchestration session callers at the dispatch entry', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('classifies every party-naming field as the caller, a resolved target, or neither', () => { + const registry = buildRegistry(ORCHESTRATION_METHODS) + const partyNaming = [...registry.values()] + .flatMap((method) => { + const schema = method.params + return schema instanceof ZodObject + ? PARTY_NAMING_FIELDS.filter((field) => Object.hasOwn(schema.shape, field)).map( + (field) => `${method.name} ${field}` + ) + : [] + }) + .sort() + + // The population: 41 registered methods carrying 25 party-naming fields. + expect(registry.size).toBe(41) + expect(partyNaming).toHaveLength(25) + expect(partyNaming).toEqual( + [ + ...Object.entries(ORCHESTRATION_CALLER_PARAM).map( + ([method, field]) => `${method} ${field}` + ), + ...Object.entries(ORCHESTRATION_TARGET_PARAM).map( + ([method, field]) => `${method} ${field}` + ), + ...Object.keys(NAMES_NO_RESOLVED_PARTY) + ].sort() + ) + expect(Object.keys(MINIMAL_PARAMS).sort()).toEqual( + Object.keys(ORCHESTRATION_CALLER_PARAM).sort() + ) + }) + + it.each(Object.keys(ORCHESTRATION_CALLER_PARAM))( + 'refuses %s from a paired client before any effect, naming the host boundary', + async (method) => { + const response = await h.dispatchStreaming( + orchestrationRequest(method, MINIMAL_PARAMS[method] ?? {}, { sessionId: SESSION_X }), + 'paired-device-1' + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.hostBoundary, + message: expect.stringContaining('only on the host that runs that session'), + data: { effectsApplied: false } + } + }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toEqual([]) + } + ) + + it.each(Object.keys(ORCHESTRATION_CALLER_PARAM))( + 'resolves %s on the local route as the session, never as a terminal', + async (method) => { + const spy = vi.spyOn(h.runtime, 'verifyOrchestrationCompatibilityCaller') + const response = await h.dispatch( + orchestrationRequest(method, MINIMAL_PARAMS[method] ?? {}, { + sessionId: SESSION_X, + evidence: { + terminalHandle: 'term_other', + paneKey: 'tab_other:1:2', + launchToken: 'secret' + } + }) + ) + + // Whatever the method answers, it answered as the session: no host-boundary or session refusal, + // and terminal evidence on the same request never attested anyone. + if (!response.ok) { + expect(Object.values(CODES)).not.toContain(response.error.code) + expect(response.error.message).not.toContain('term_other') + } + for (const [evidence] of spy.mock.calls) { + expect(evidence?.terminalHandle).toBeUndefined() + } + } + ) + + it('admits the session on the real Unix-socket route and refuses it on the real paired route', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-session-caller-')) + const server = new OrcaRuntimeRpcServer({ + runtime: h.runtime, + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry'].addDevice('laptop', 'runtime') + const request = orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X + } + ) + + const replies: string[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ ...request, deviceToken: device.token }), + (reply) => replies.push(reply), + () => {}, + undefined, + undefined, + device.token + ) + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { code: CODES.hostBoundary } + }) + + const local = await server['handleMessage']( + JSON.stringify({ ...request, authToken: server['authToken'] }) + ) + expect(local).toMatchObject({ ok: true, result: { run: { objective: 'o' } } }) + expect( + h.db.getCurrentRunForCoordinator({ + terminalHandle: null, + paneKey: null, + orcaSessionId: SESSION_X + }) + ).toMatchObject({ objective: 'o', coordinator_orca_session_id: SESSION_X }) + }) + + describe('refuses a session that cannot act, before any destructive or consuming lookup', () => { + function seedPendingMail(): { runId: string; messageId: string } { + const run = h.db.createRun({ + objective: 'x', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION_X + }) + const message = h.db.insertMessage({ + from: 'term_worker', + to: ADDRESS_X, + subject: 'pending', + body: '', + runId: run.id + }) + return { runId: run.id, messageId: message.id } + } + + async function expectRefusedWithNoEffects( + sessionId: string, + code: string, + message: RegExp, + evidence?: OrchestrationCompatibilityEvidence + ): Promise { + const { messageId } = seedPendingMail() + for (const [method, params] of [ + ['orchestration.check', {}], + ['orchestration.reset', { messages: true }] + ] as const) { + const response = await h.dispatch( + orchestrationRequest(method, params, { sessionId, evidence }) + ) + expect(response, method).toMatchObject({ + ok: false, + error: { code, message: expect.stringMatching(message) } + }) + } + expect(h.db.getMessageById(messageId)).toMatchObject({ read: 0 }) + } + + it('an id that names no Orca session', async () => { + await expectRefusedWithNoEffects( + 'ffffffff-0000-4000-8000-000000000000', + CODES.unknown, + /No Orca agent session .* exists on this host/ + ) + }) + + it('a terminal handle presented as a session id', async () => { + await expectRefusedWithNoEffects('term_4f2c9a0b', CODES.unknown, /not an Orca session id/) + }) + + it("a provider's session id, with a hint naming the Orca id", async () => { + const response = await h.dispatch( + orchestrationRequest('orchestration.runCurrent', {}, { sessionId: PROVIDER_ID_X }) + ) + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.providerId, + message: expect.stringContaining(`This session's Orca id is ${SESSION_X}`), + data: { orcaSessionId: SESSION_X, effectsApplied: false } + } + }) + await expectRefusedWithNoEffects(PROVIDER_ID_X, CODES.providerId, /changes on \/clear/) + }) + + it('a released lease', async () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { claimStatus: 'released' } })) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /is not running right now/) + }) + + it('a lease mid owner change', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { + lease: { handoffStage: 'new-owner-proving', handoffOperationId: 'op' } + }) + ) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /is changing owners/) + }) + + it('a lease the host has not reconciled since restart', async () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { unreconciled: true } })) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /no live owner/) + }) + + it('a session whose record says it runs on another host', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /runs on another host/) + }) + + it('a request from an SSH or WSL environment', async () => { + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /an SSH environment/, { + host: { kind: 'ssh', targetId: 't', connectionIncarnation: 'c', attachmentId: 'a' } + }) + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /a WSL environment/, { + host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' } + }) + }) + + it('a structured worker session whose worker identity this host no longer has', async () => { + // A Dispatch recorded the session as a worker; no registry entry or custody row maps it now. + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SESSION_Y), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: SESSION_Y } + ) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: CODES.notLive, message: expect.stringContaining('no longer has') } + }) + expect( + h.db.listRuns().runs.filter((row) => row.coordinator_orca_session_id !== null) + ).toEqual([]) + }) + + it('an agent-session host that cannot be brought up to verify it', async () => { + hostRef.current = null + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /cannot be verified/) + }) + }) + + describe('a declared caller must name the session', () => { + it('refuses a declared terminal handle that is not the session', async () => { + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: 'term_sibling' }, + { sessionId: SESSION_X } + ) + ) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: `This caller is agent session ${SESSION_X} and cannot act as term_sibling. No effects were applied.` + } + }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toEqual([]) + }) + + it("refuses a caller-supplied pane key on check, the restart fallback's substitute", async () => { + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.check', + { terminalPaneKey: 'tab_worker:77777777-7777-4777-8777-777777777777' }, + { sessionId: SESSION_X } + ) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + }) + + it.each([ADDRESS_X, SESSION_X])('accepts the session named as %s', async (declared) => { + const run = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: declared }, + { sessionId: SESSION_X } + ) + ) + ).run + expect(run).toMatchObject({ objective: 'o', coordinator_handle: null }) + }) + }) + + it('leaves a terminal caller untouched: no claim, no normalization, no extra hop', async () => { + const request = orchestrationRequest('orchestration.runCreate', { + objective: 'o', + from: 'term_worker' + }) + expect(needsOrchestrationCallerResolution(request)).toBe(false) + + const run = resultOf(await h.dispatch(request)).run + expect(run).toMatchObject({ coordinator_handle: 'term_worker' }) + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts new file mode 100644 index 00000000000..e4bd09f2619 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -0,0 +1,291 @@ +/** + * Resolves an orchestration caller that names itself by the Orca agent session id in its injected + * environment. Both dispatchers call this once, before params parse and the unary/streaming split, + * so it runs ahead of legacy compatibility, receipt lookup and every method. Before parsing, so a + * session caller need not name itself in a param that requires a caller. + * + * The id names the caller; nothing here is a credential. Every agent on this host runs as the same + * user, so the checks are about getting the identity right, not about keeping anyone out: + * - Same host only. A paired client, an SSH environment or a WSL shell is another host, where + * "same machine, same user" does not hold, so a session claim from one is refused. + * - The Orca id, never the provider's: that one rotates on `/clear`. + * - A live lease: released, mid owner change or unreconciled sessions cannot act. + * - The session wins over any declared caller: a declared handle must name this same session, and + * a structured worker's session id maps to the handle and pane it was minted. + * - A request with no session id that declares a `session:` caller gets the party it names: a + * worker's handle, or a refusal for a chat, whose address alone identifies nobody. + */ +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isOrcaSessionId, parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' +import { OrchestrationError } from '../orchestration/orchestration-error' +import { canonicalOrcaSessionId } from '../orchestration/canonical-orca-session-id' +import type { OrchestrationDb } from '../orchestration/db' +import { + resolveDeclaredCallerParty, + resolveOrcaSessionParty, + resolveOrchestrationParty +} from '../orchestration/orchestration-party' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { RpcRequest } from './core' + +type CallerParam = 'from' | 'terminal' | 'callerTerminalHandle' + +/** + * Every method that consults caller identity, and the param it names its caller in. This list is + * the contract: a method that starts reading caller identity is added here with its own test. + * Methods not listed carry no caller identity for any caller; a session claim on them is still + * validated, then they run exactly as they do for a terminal caller. + */ +export const ORCHESTRATION_CALLER_PARAM: Readonly> = { + 'orchestration.runCreate': 'from', + 'orchestration.runUse': 'from', + 'orchestration.runCurrent': 'from', + 'orchestration.check': 'terminal', + 'orchestration.send': 'from', + 'orchestration.reply': 'from', + 'orchestration.ask': 'from', + 'orchestration.dispatch': 'from', + 'orchestration.gateCreate': 'from', + 'orchestration.gateResolve': 'from', + 'orchestration.gateList': 'from', + 'orchestration.taskCreate': 'callerTerminalHandle', + 'orchestration.taskList': 'callerTerminalHandle', + 'orchestration.taskUpdate': 'callerTerminalHandle', + 'orchestration.workerStart': 'from' +} + +export type OrchestrationRequestRoute = { + /** Set only by the paired WebSocket route: the request came from another host's client. */ + pairedDeviceId?: string +} + +export type ResolvedOrchestrationRequest = { + /** The request with its declared caller bound to the session and its evidence reduced to it. */ + request: RpcRequest + caller?: OrchestrationSessionCaller +} + +const NO_EFFECTS = { effectsApplied: false } as const + +/** + * Whether this request names its caller by a session id, or declares a `session:` address as its + * caller. Synchronous, so every other request, terminal callers included, takes no extra async hop. + */ +export function needsOrchestrationCallerResolution(request: RpcRequest): boolean { + if (!request.method.startsWith('orchestration.')) { + return false + } + return ( + request.orchestrationCompatibilityEvidence?.agentSessionId !== undefined || + declaredSessionAddress(request) !== undefined + ) +} + +function declaredSessionAddress(request: RpcRequest): string | undefined { + const name = ORCHESTRATION_CALLER_PARAM[request.method] + const params: unknown = request.params + if (!name || !params || typeof params !== 'object' || Array.isArray(params)) { + return undefined + } + const values: Record = { ...params } + const declared = values[name] + return typeof declared === 'string' && parseOrcaSessionAddress(declared) ? declared : undefined +} + +/** Only for a request `needsOrchestrationCallerResolution` accepts. Throws the refusal, if any. */ +export async function resolveOrchestrationSessionCaller( + runtime: OrcaRuntimeService, + request: RpcRequest, + route: OrchestrationRequestRoute | undefined +): Promise { + const evidence = request.orchestrationCompatibilityEvidence + if (evidence?.agentSessionId === undefined) { + return { request: bindDeclaredSessionAddress(runtime.getOrchestrationDb(), request) } + } + const claimed: unknown = evidence?.agentSessionId + if (route?.pairedDeviceId !== undefined) { + throw hostBoundary( + 'This request reached Orca from a paired client, and an agent session id identifies a caller only on the host that runs that session.' + ) + } + if (evidence?.host) { + throw hostBoundary( + `This command ran in ${evidence.host.kind === 'ssh' ? 'an SSH' : 'a WSL'} environment, and an agent session id identifies a caller only on the host that runs that session.` + ) + } + if (typeof claimed !== 'string' || !isOrcaSessionId(claimed)) { + throw new OrchestrationError( + CODES.unknown, + 'The caller named an agent session id that is not an Orca session id. No effects were applied.', + NO_EFFECTS + ) + } + const sessionId = claimed + const record = await readSessionRecord(runtime, sessionId) + assertSessionCanAct(sessionId, record) + const db = runtime.getOrchestrationDb() + const caller: OrchestrationSessionCaller = Object.freeze({ + ...resolveOrcaSessionParty(sessionId, db), + sessionId, + workspaceId: record.location.workspaceId + }) + return { + request: { + ...request, + params: bindDeclaredCaller(request.method, request.params, caller, db), + // Why: the session wins, so any terminal evidence on the same request never attests. + orchestrationCompatibilityEvidence: { agentSessionId: sessionId } + }, + caller + } +} + +async function readSessionRecord( + runtime: OrcaRuntimeService, + sessionId: string +): Promise { + let store: ReturnType + try { + await runtime.ensureStructuredAgentSessionHost() + store = sessionRecordStore() + } catch { + store = null + } + if (!store) { + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No effects were applied.`, + NO_EFFECTS + ) + } + const record = store.getRecord(sessionId) + if (record) { + return record + } + const owner = store.listRecords().find((candidate) => namesProviderSession(candidate, sessionId)) + if (owner) { + throw new OrchestrationError( + CODES.providerId, + `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${owner.sessionId}; use that instead. No effects were applied.`, + { ...NO_EFFECTS, orcaSessionId: owner.sessionId } + ) + } + throw new OrchestrationError( + CODES.unknown, + `No Orca agent session ${sessionId} exists on this host. No effects were applied.`, + NO_EFFECTS + ) +} + +function sessionRecordStore(): { + getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords: () => AgentSessionRecord[] +} | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +function namesProviderSession(record: AgentSessionRecord, id: string): boolean { + return record.providerHandleChain.some(({ handle }) => + handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id + ) +} + +function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): void { + if (!structuredWorkerHostScope(record.location)) { + throw hostBoundary( + `Agent session ${sessionId} runs on another host, and an agent session id identifies a caller only on the host that runs that session.` + ) + } + if (agentSessionLeaseAdmitsWriter(record.lease)) { + return + } + const { lease } = record + const reason = + lease.claimStatus === 'released' + ? // Why not "ended": a released lease is evicted and wakeable; only a running process may act. + 'is not running right now. A new message or user turn revives it; retry then.' + : lease.handoffStage !== null + ? 'is changing owners on this host. Retry when that finishes.' + : 'has no live owner on this host right now. Retry once it is running.' + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} ${reason} No effects were applied.`, + NO_EFFECTS + ) +} + +/** A request with no session id that declares a `session:` caller: a worker's is its handle. */ +function bindDeclaredSessionAddress(db: OrchestrationDb, request: RpcRequest): RpcRequest { + const name = ORCHESTRATION_CALLER_PARAM[request.method] + const declared = declaredSessionAddress(request) + const params: unknown = request.params + if (!name || declared === undefined || !params || typeof params !== 'object') { + return request + } + const party = resolveDeclaredCallerParty(declared, db) + return { ...request, params: { ...params, [name]: party.address } } +} + +/** Whether a declared caller names this session: any spelling that resolves to its party. */ +function declaredNamesCaller( + declared: unknown, + caller: OrchestrationSessionCaller, + db: OrchestrationDb +): boolean { + if (typeof declared !== 'string') { + return false + } + if (isOrcaSessionId(declared)) { + return canonicalOrcaSessionId(declared) === caller.orcaSessionId + } + try { + return resolveOrchestrationParty(declared, db).address === caller.address + } catch { + return false + } +} + +/** The declared caller, if any, must name this session; it is then replaced by its address. */ +function bindDeclaredCaller( + method: string, + params: unknown, + caller: OrchestrationSessionCaller, + db: OrchestrationDb +): unknown { + const name = ORCHESTRATION_CALLER_PARAM[method] + if (!name || !params || typeof params !== 'object' || Array.isArray(params)) { + return params + } + const values: Record = { ...params } + const declared = values[name] + if (declared !== undefined && !declaredNamesCaller(declared, caller, db)) { + throw consumerFenced(caller, String(declared)) + } + // Why: check's restart fallback takes a pane key from the caller; a session has its own or none. + if (values.terminalPaneKey !== undefined && values.terminalPaneKey !== caller.paneKey) { + throw consumerFenced(caller, `pane ${String(values.terminalPaneKey)}`) + } + values[name] = caller.address + return values +} + +function consumerFenced(caller: OrchestrationSessionCaller, declared: string): OrchestrationError { + return new OrchestrationError( + 'consumer_fenced', + `This caller is agent session ${caller.sessionId} and cannot act as ${declared}. No effects were applied.`, + NO_EFFECTS + ) +} + +function hostBoundary(reason: string): OrchestrationError { + return new OrchestrationError( + CODES.hostBoundary, + `${reason} Run the command on that host. No effects were applied.`, + NO_EFFECTS + ) +} diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts new file mode 100644 index 00000000000..1a3312f2013 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -0,0 +1,581 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + orchestrationRequest, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + WORKER_HANDLE, + WORKER_PANE, + WORKSPACE_X, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +describe('a structured chat coordinates through the same verbs as a terminal', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + async function runCreate(sessionId: string, objective = 'o'): Promise { + const { run } = await as(sessionId, 'orchestration.runCreate', { objective }) + return idOf(run) + } + + it('runs the supervised loop: run, task, dispatch, worker mail in, check, send, reply, gates', async () => { + const runId = await runCreate(SESSION_X) + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: SESSION_X + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: runId } + }) + + const { task } = await as(SESSION_X, 'orchestration.taskCreate', { spec: 'do it' }) + const taskId = idOf(task) + expect(task).toMatchObject({ run_id: runId, created_by_terminal_handle: null }) + expect(await as(SESSION_X, 'orchestration.taskList', {})).toMatchObject({ runId, count: 1 }) + + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { + task: taskId, + to: WORKER_HANDLE + }) + expect(dispatch).toMatchObject({ + assignee_handle: WORKER_HANDLE, + creator_handle: null, + creator_pane_key: null, + creator_orca_session_id: SESSION_X, + depth: 1 + }) + + // The worker writes to its coordinator's public address. + const { message: inbound } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_X, + subject: 'progress' + }) + expect(inbound).toMatchObject({ to_handle: `run:${runId}`, run_id: runId }) + + const checked = await as(SESSION_X, 'orchestration.check', {}) + expect(checked).toMatchObject({ runId, count: 1, messages: [{ subject: 'progress' }] }) + + const { message: outbound } = await as(SESSION_X, 'orchestration.send', { + to: WORKER_HANDLE, + subject: 'more' + }) + expect(outbound).toMatchObject({ from_handle: ADDRESS_X, run_id: runId }) + + const replied = await as(SESSION_X, 'orchestration.reply', { + id: idOf(inbound), + body: 'ack' + }) + expect(replied).toMatchObject({ message: { from_handle: ADDRESS_X, to_handle: WORKER_HANDLE } }) + + const { gate } = await as(SESSION_X, 'orchestration.gateCreate', { + task: taskId, + question: 'ship?' + }) + expect(await as(SESSION_X, 'orchestration.gateList', {})).toMatchObject({ runId, count: 1 }) + expect( + await as(SESSION_X, 'orchestration.gateResolve', { + id: idOf(gate), + resolution: 'yes' + }) + ).toMatchObject({ gate: { status: 'resolved' } }) + + expect( + await as(SESSION_X, 'orchestration.taskUpdate', { id: taskId, status: 'completed' }) + ).toMatchObject({ task: { status: 'completed' } }) + }) + + it("files a session's mail to a plain terminal under its own Run", async () => { + const runId = await runCreate(SESSION_X) + const { message } = await as(SESSION_X, 'orchestration.send', { + to: WORKER_HANDLE, + subject: 'no dispatch here' + }) + expect(message).toMatchObject({ + from_handle: ADDRESS_X, + to_handle: WORKER_HANDLE, + run_id: runId + }) + }) + + it("addresses a group to the session's own Run", async () => { + await runCreate(SESSION_X) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.send', + { to: '@all', subject: 'everyone' }, + { + sessionId: SESSION_X + } + ) + ) + // The audience resolved to the session's Run; that Run simply has no workers yet. + expect(response).toMatchObject({ + ok: false, + error: { + code: 'terminal_not_found', + message: 'No recipients resolved for group address: @all' + } + }) + }) + + it('asks as a coordinator does: only a supervised worker may ask', async () => { + await runCreate(SESSION_X) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.ask', + { question: 'q', to: WORKER_HANDLE }, + { + sessionId: SESSION_X + } + ) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'dispatch_inactive' } }) + }) + + it("lets its worker ask it at its session address, the one the worker's preamble names", async () => { + const runId = await runCreate(SESSION_X) + const taskId = idOf((await as(SESSION_X, 'orchestration.taskCreate', { spec: 'q' })).task) + await as(SESSION_X, 'orchestration.dispatch', { task: taskId, to: WORKER_HANDLE }) + + const asked = await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to: ADDRESS_X, + question: 'which way?', + timeoutMs: 0 + }) + expect(asked).toMatchObject({ timedOut: true }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: runId }) + }) + + it("places a worker-start in the session's own workspace", async () => { + const runId = await runCreate(SESSION_X) + vi.spyOn(h.runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + const placed = vi + .spyOn(h.runtime, 'showManagedTerminalWorkspace') + .mockRejectedValue(new Error('placement reached')) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.workerStart', + { spec: 'work', run: runId, agent: 'claude' }, + { + sessionId: SESSION_X + } + ) + ) + expect(placed).toHaveBeenCalledWith(`id:${WORKSPACE_X}`) + expect(response).toMatchObject({ ok: false, error: { message: 'placement reached' } }) + }) + + it("never lets one chat's run-create unbind another chat's Run", async () => { + const xFirst = await runCreate(SESSION_X, 'x first') + const yRun = await runCreate(SESSION_Y, 'y') + const xSecond = await runCreate(SESSION_X, 'x second') + + expect(await as(SESSION_Y, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: yRun } + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: xSecond } + }) + expect(h.db.getRunRaw(xFirst)?.coordinator_orca_session_id).toBeNull() + }) + + it('takes over a bound Run like a terminal does, and fences the previous coordinator', async () => { + const runId = await runCreate(SESSION_X) + await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_X, + subject: 'before takeover', + run: runId + }) + + expect(await as(SESSION_Y, 'orchestration.runUse', { id: runId })).toMatchObject({ + run: { id: runId } + }) + + const previous = await h.dispatch( + orchestrationRequest('orchestration.check', { run: runId }, { sessionId: SESSION_X }) + ) + expect(previous).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: `This coordinator terminal is no longer bound to Run ${runId}.` + } + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) + expect(await as(SESSION_Y, 'orchestration.check', {})).toMatchObject({ + runId, + messages: [{ subject: 'before takeover' }] + }) + }) + + it("wakes the previous coordinator's waiting check as fenced when another session takes over", async () => { + const runId = await runCreate(SESSION_X) + const waiter = vi.spyOn(h.runtime, 'waitForMessage') + const waiting = h.dispatch( + orchestrationRequest( + 'orchestration.check', + { run: runId, wait: true, timeoutMs: 5_000 }, + { + sessionId: SESSION_X + } + ) + ) + await vi.waitFor(() => expect(waiter).toHaveBeenCalledWith(`run:${runId}`, expect.anything())) + + await as(SESSION_Y, 'orchestration.runUse', { id: runId }) + + expect(await waiting).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: 'This mailbox consumer was replaced while waiting.' + } + }) + }) + + it('stops counting a coordinator Orca session id once an older binary rebinds the Run to a terminal', async () => { + const runId = await runCreate(SESSION_X) + // An older binary's bindRun rewrites handle and pane and bumps the generation, never the Orca session id. + h.db.db + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1 + WHERE id = ?` + ) + .run(WORKER_HANDLE, WORKER_PANE, runId) + + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) + expect(await as(undefined, 'orchestration.runCurrent', { from: WORKER_HANDLE })).toMatchObject({ + run: { id: runId } + }) + }) + + it('replays an idempotent retry from the same session and refuses it from another', async () => { + const first = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X, + requestId: 'retry-1' + } + ) + ) + const retry = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X, + requestId: 'retry-1' + } + ) + ) + const other = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_Y, + requestId: 'retry-1' + } + ) + ) + + const firstRun = idOf(resultOf(first).run) + expect(resultOf(retry)).toMatchObject({ run: { id: firstRun }, mutation: { replayed: true } }) + expect(other).toMatchObject({ ok: false, error: { code: 'request_mismatch' } }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(1) + }) +}) + +describe('a session with no Run, and receipts that carry no caller param', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('reads its direct mailbox on a consuming check, as a terminal with a live pane does', async () => { + h.db.insertMessage({ from: WORKER_HANDLE, to: ADDRESS_X, subject: 'direct', body: '' }) + + const checked = resultOf( + await h.dispatch(orchestrationRequest('orchestration.check', {}, { sessionId: SESSION_X })) + ) + + expect(checked).toMatchObject({ messages: [{ subject: 'direct', to_handle: ADDRESS_X }] }) + }) + + it('binds a receipt to the session even when the method names no caller', async () => { + const reset = (sessionId: string) => + h.dispatch( + orchestrationRequest( + 'orchestration.reset', + { messages: true }, + { + sessionId, + requestId: 'reset-1' + } + ) + ) + + expect(await reset(SESSION_X)).toMatchObject({ ok: true }) + expect(await reset(SESSION_X)).toMatchObject({ + ok: true, + result: { mutation: { replayed: true } } + }) + expect(await reset(SESSION_Y)).toMatchObject({ + ok: false, + error: { code: 'request_mismatch' } + }) + }) +}) + +describe('a structured worker that names itself by session id', () => { + let h: SessionCallerHarness + const workerSession = SESSION_Y + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(workerSession) + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + structuredWorkerIdentities.register({ + handle, + sessionId: workerSession, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + function dispatchToWorker(): { runId: string; dispatchId: string } { + const run = h.db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:12121212-1212-4212-8212-121212121212' + }) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + h.db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'instructions', + body: '', + runId: run.id + }) + return { runId: run.id, dispatchId: dispatch.id } + } + + it("reads its own Dispatch mailbox: the session id wins and maps to the worker's handle", async () => { + const { dispatchId } = dispatchToWorker() + expect(h.db.getDispatchContextById(dispatchId)?.assignee_orca_session_id).toBe(SESSION_Y) + + const bySession = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: workerSession }) + ) + ) + expect(bySession).toMatchObject({ messages: [{ subject: 'instructions' }] }) + + const namedByHandle = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.check', + { peek: true, terminal: handle }, + { + sessionId: workerSession + } + ) + ) + ) + expect(namedByHandle).toEqual(bySession) + }) + + it.each([ + ['its handle', handle], + ['its session address', ADDRESS_Y], + ['its bare session id', workerSession] + ])('accepts itself declared as %s and binds by its handle', async (_label, declared) => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'declared', from: declared }, + { sessionId: workerSession } + ) + ) + ) + expect(h.db.getRunRaw(idOf(run))).toMatchObject({ + coordinator_handle: handle, + coordinator_orca_session_id: SESSION_Y + }) + }) + + it('sends mail at either of its spellings to its Dispatch once assigned in a Run it coordinated before', async () => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: workerSession } + ) + ) + ) + const runId = idOf(run) + // A chat takes the Run over; the worker's addresses stay remembered as a former coordinator's. + resultOf( + await h.dispatch( + orchestrationRequest('orchestration.runUse', { id: runId }, { sessionId: SESSION_X }) + ) + ) + expect(h.db.getRunMailboxOwnerIdsForHandle(ADDRESS_Y)).toEqual([runId]) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + // Both spellings name one party, so both land in the one mailbox it reads, not the Run's. + for (const to of [ADDRESS_Y, handle]) { + const { message } = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { to, subject: to }, { sessionId: SESSION_X }) + ) + ) + expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}` }) + } + const read = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: workerSession }) + ) + ) + expect(read).toMatchObject({ + count: 2, + messages: expect.arrayContaining([ + expect.objectContaining({ subject: handle }), + expect.objectContaining({ subject: ADDRESS_Y }) + ]) + }) + }) + + it('coordinates with its handle, pane and Orca session id, one mailbox at both spellings', async () => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'nested' }, + { + sessionId: workerSession + } + ) + ) + ) + const runId = idOf(run) + + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: handle, + coordinator_pane_key: paneKey, + coordinator_orca_session_id: SESSION_Y + }) + expect(h.db.getRunMailboxOwnerIdsForHandle(handle)).toEqual([runId]) + for (const to of [handle, ADDRESS_Y]) { + const { message } = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: to }) + ) + ) + expect(message).toMatchObject({ to_handle: `run:${runId}` }) + } + }) + + it.each([ + ['its handle', handle], + ['its session address', ADDRESS_Y] + ])('is asked by its own worker at %s', async (_label, to) => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: workerSession } + ) + ) + ) + const runId = idOf(run) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'sub' }).id, + assigneeHandle: WORKER_HANDLE, + assigneePaneKey: WORKER_PANE, + creator: { kind: 'terminal', handle, paneKey, orcaSessionId: workerSession }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const asked = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.ask', { + from: WORKER_HANDLE, + to, + question: 'which way?', + timeoutMs: 0 + }) + ) + ) + expect(asked).toMatchObject({ timedOut: true }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: runId }) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts new file mode 100644 index 00000000000..bbf199e7f50 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + orchestrationRequest, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +describe('mail sent to a session address reaches the mailbox that session reads', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + function sendFromTerminal(to: string): Promise { + return as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to, subject: 'hello' }) + } + + it("files it under the chat's current Run, as a terminal coordinator's pane does", async () => { + await as(SESSION_X, 'orchestration.runCreate', { objective: 'first' }) + const current = idOf( + (await as(SESSION_X, 'orchestration.runCreate', { objective: 'next' })).run + ) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: `run:${current}`, run_id: current }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + runId: current, + messages: [{ subject: 'hello' }] + }) + }) + + it('delivers it to a chat with no Run, which reads its direct mailbox', async () => { + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + messages: [{ subject: 'hello' }] + }) + }) + + it('refuses an Orca session this host does not run', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + h.records.delete(SESSION_Y) + + for (const to of [ADDRESS_X, ADDRESS_Y]) { + const response = await h.dispatch( + orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: 's' }) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'terminal_not_found' } }) + } + }) + + it("routes a structured worker's session address to the Dispatch it is working", async () => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const runId = idOf((await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })).run) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const { message } = await as(SESSION_X, 'orchestration.send', { + to: ADDRESS_Y, + subject: 'to the worker' + }) + + expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}`, run_id: runId }) + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: dispatch.id, + messages: [{ subject: 'to the worker' }] + }) + }) +}) diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 1428b72d188..cdd5e1341d0 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -15,6 +15,11 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-browser-routing' import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' +import { + needsOrchestrationCallerResolution, + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type RpcStreamingDispatcherDependencies = { runtime: OrcaRuntimeService @@ -29,10 +34,11 @@ export class RpcStreamingDispatcher { // Why: streaming dispatch sends multiple responses through the reply callback instead of a Promise. async dispatch( - request: RpcRequest, + rawRequest: RpcRequest, reply: (response: string) => void, options?: RpcDispatchStreamingOptions ): Promise { + let request = rawRequest const { runtime, registry, orchestrationMutations, legacyOrchestration, meta } = this.dependencies const envelopeMeta = meta() @@ -57,11 +63,24 @@ export class RpcStreamingDispatcher { return } + // Why: before params parse and the unary/streaming split, so both branches see one caller. + let resolved: ResolvedOrchestrationRequest = { request } + if (needsOrchestrationCallerResolution(request)) { + try { + resolved = await resolveOrchestrationSessionCaller(runtime, request, options) + } catch (error) { + reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) + return + } + } + request = resolved.request + const orchestrationCaller = resolved.caller const parsedParams = parseRpcRequestParams(request, method, envelopeMeta) if (parsedParams.error) { reply(JSON.stringify(parsedParams.error)) return } + const params = parsedParams.value if (!isStreamingMethod(method)) { try { @@ -73,7 +92,7 @@ export class RpcStreamingDispatcher { const clientHostedBrowser = await routeDispatcherClientHostedBrowserRpc( runtime, request.method, - parsedParams.value + params ) if (clientHostedBrowser.handled) { recordRuntimeFeatureInteraction( @@ -88,16 +107,12 @@ export class RpcStreamingDispatcher { ) return } - const compatibility = await legacyOrchestration.tryHandle( - request, - parsedParams.value, - options?.signal - ) + const compatibility = await legacyOrchestration.tryHandle(request, params, options?.signal) if (compatibility.handled) { reply(JSON.stringify(successResponse(request.id, envelopeMeta, compatibility.result))) return } - const effectiveParams = compatibility.params ?? parsedParams.value + const effectiveParams = compatibility.params ?? params const legacyCoordinator = legacyOrchestration.createCoordinatorInvocation( request, compatibility.legacyCoordinatorAuthority @@ -136,14 +151,16 @@ export class RpcStreamingDispatcher { revalidateLegacyCoordinator: legacyCoordinator?.revalidate, orchestrationCompatibilityCallerAuthority: compatibility.orchestrationCompatibilityCallerAuthority, - orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence + orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence, + orchestrationCaller }) } const result = await orchestrationMutations.run( request, effectiveParams, invoke, - legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint + legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, + orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) @@ -162,7 +179,7 @@ export class RpcStreamingDispatcher { try { const result = await method.handler( - parsedParams.value, + params, { runtime, signal: options?.signal, @@ -177,7 +194,8 @@ export class RpcStreamingDispatcher { pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, - registerBinaryMessageHandler: options?.registerBinaryMessageHandler + registerBinaryMessageHandler: options?.registerBinaryMessageHandler, + orchestrationCaller }, emit ) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index c11119878f6..96d013c2ed3 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -8,12 +8,14 @@ */ import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { OrcaSessionId } from '../../shared/orca-session-address' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' import { isStructuredWorkerHandle, structuredWorkerIdentities, + structuredWorkerProcessIncarnation, structuredWorkerRecordIsCurrent, type StructuredWorkerIdentity } from './structured-worker-identity' @@ -47,6 +49,39 @@ export function resolveStructuredWorkerIdentity( return row ? structuredWorkerIdentities.rehydrate(row) : null } +/** The worker identity minted for a session, if that session is a structured worker. */ +export function resolveStructuredWorkerIdentityForSession( + sessionId: string, + db: OrchestrationDb | null | undefined +): StructuredWorkerIdentity | null { + const known = structuredWorkerIdentities.getBySessionId(sessionId) + if (known) { + return known + } + const row = db?.getWorkerTerminalResourceByProcessIncarnation?.( + structuredWorkerProcessIncarnation(sessionId) + ) + return row ? structuredWorkerIdentities.rehydrate(row) : null +} + +/** + * Whether this session was assigned a Dispatch as a structured worker. Such a session acts with its + * worker handle, so one whose handle is gone must not act handle-less, as a chat would. + */ +export function isRecordedStructuredWorkerSession( + sessionId: OrcaSessionId, + db: OrchestrationDb +): boolean { + return Boolean( + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` + ) + .get(sessionId, structuredWorkerProcessIncarnation(sessionId)) + ) +} + /** Identity plus a record that still proves this runtime owns the session. */ export function resolveStructuredWorkerAuthority( handle: string, diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 5be84c336ae..c32d6c37f15 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -25,6 +25,7 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope @@ -57,12 +58,13 @@ export function mintStructuredWorkerHandle(): string { /** * A RANDOM leaf, minted once per worker and persisted with the rest of the identity. * - * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A pane - * key is an identity credential on its own: `orchestration.check` is identity-gated, not - * capability-gated, and accepts a caller-supplied `terminalPaneKey` that `getActiveDispatchForIdentity` - * matches by leaf suffix. A derivable pane key would therefore let anyone who learns a session id — - * which the tab id embeds in plain text — read and consume that worker's mailbox with no token. - * PTY pane keys are safe only because their leaf UUID is random; this one has to be too. + * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A request + * that names no session — a PTY agent's, or any on the paired-client route, which refuses session + * ids — identifies its caller by pane: `orchestration.check` accepts a caller-supplied + * `terminalPaneKey` that `getActiveDispatchForIdentity` matches by leaf suffix. A pane key derivable + * from the session id, which the tab id embeds in plain text, would let such a request read and + * consume this worker's mailbox. On the same-host socket route the session id itself names the + * worker with no token, by design; the pane key is no credential there and must not become one. * * Restart stability comes from persisting the minted key, not from re-deriving it. */ @@ -122,6 +124,14 @@ export function sessionIdFromStructuredWorkerIncarnation( return sessionId.length > 0 ? sessionId : null } +/** The Orca session id a `structured:` incarnation names; null for any other. */ +export function structuredWorkerOrcaSessionIdForIncarnation( + processIncarnation: string | null | undefined +): OrcaSessionId | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(processIncarnation) + return sessionId !== null && isOrcaSessionId(sessionId) ? sessionId : null +} + /** Structured sessions can only exist local and outside WSL; anything else is not our authority. */ export function structuredWorkerHostScope( location: AgentSessionExecutionLocation diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 276d9cad87c..637a0c55ce3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -84,6 +84,7 @@ describe('runRemoteOrcaCli', () => { getActiveDispatchForIdentity: vi.fn(() => undefined), getActiveDispatchMailboxOwners: vi.fn(() => []), getCurrentRunForPane: vi.fn(() => undefined), + getCurrentRunForCoordinator: vi.fn(() => undefined), getRunMailboxOwnerIdsForHandle: vi.fn(() => []), findActiveRemoteAttachmentForPane: vi.fn(() => undefined) } @@ -584,7 +585,9 @@ describe('runRemoteOrcaCli', () => { ) expect(result.exitCode).toBe(0) - expect(db.getCurrentRunForPane).toHaveBeenCalledWith('tab_ssh:leaf_ssh') + expect(db.getCurrentRunForCoordinator).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: 'tab_ssh:leaf_ssh' }) + ) expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith( 'term_stale_ssh', 'tab_ssh:leaf_ssh' @@ -608,7 +611,7 @@ describe('runRemoteOrcaCli', () => { ) expect(result.exitCode).toBe(0) - expect(db.getCurrentRunForPane).not.toHaveBeenCalled() + expect(db.getCurrentRunForCoordinator).not.toHaveBeenCalled() expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith('term_legacy_worker', undefined) }) diff --git a/src/shared/orca-session-address-test-fixture.ts b/src/shared/orca-session-address-test-fixture.ts new file mode 100644 index 00000000000..8c41b49b8b2 --- /dev/null +++ b/src/shared/orca-session-address-test-fixture.ts @@ -0,0 +1,9 @@ +import { isOrcaSessionId, type OrcaSessionId } from './orca-session-address' + +/** A literal Orca session id for a test, checked by the same predicate production uses. */ +export function testOrcaSessionId(id: string): OrcaSessionId { + if (!isOrcaSessionId(id)) { + throw new Error(`Not an Orca session id: ${id}`) + } + return id +} diff --git a/src/shared/orca-session-address.test.ts b/src/shared/orca-session-address.test.ts index 96b8a2268e5..0d9875f936d 100644 --- a/src/shared/orca-session-address.test.ts +++ b/src/shared/orca-session-address.test.ts @@ -5,8 +5,9 @@ import { isOrcaSessionId, parseOrcaSessionAddress } from './orca-session-address' +import { testOrcaSessionId } from './orca-session-address-test-fixture' -const SESSION_ID = '0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70' +const SESSION_ID = testOrcaSessionId('0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70') const ADDRESS = `session:${SESSION_ID}` describe('Orca session address', () => { @@ -14,7 +15,8 @@ describe('Orca session address', () => { expect(ORCA_SESSION_ADDRESS_PREFIX).toBe('session:') expect(formatOrcaSessionAddress(SESSION_ID)).toBe(ADDRESS) expect(parseOrcaSessionAddress(ADDRESS)).toBe(SESSION_ID) - expect(formatOrcaSessionAddress(parseOrcaSessionAddress(ADDRESS) ?? '')).toBe(ADDRESS) + const parsed = parseOrcaSessionAddress(ADDRESS) + expect(parsed && formatOrcaSessionAddress(parsed)).toBe(ADDRESS) }) it('reads only the addressed spelling when parsing an address', () => { diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index f2ccd4fe00c..339ccb01f43 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -13,24 +13,41 @@ import { isAgentSessionId } from './agent-session-record' */ export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' +declare const orcaSessionIdBrand: unique symbol +declare const orcaSessionAddressBrand: unique symbol + +/** A bare Orca session id; only `isOrcaSessionId` and `parseOrcaSessionAddress` produce one. */ +export type OrcaSessionId = string & { readonly [orcaSessionIdBrand]: true } +/** A `session:` mail address; only `formatOrcaSessionAddress` produces one. */ +export type OrcaSessionAddress = string & { readonly [orcaSessionAddressBrand]: true } + // Terminal handles (`term_` from the PTY runtime, `structworker_` from structured-worker-identity) // share the session-id charset. A handle is never a session, so one handed over by mistake must not // become a durable Orca session id. const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const -export function isOrcaSessionId(id: string): boolean { +export function isOrcaSessionId(id: string): id is OrcaSessionId { return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) } -export function formatOrcaSessionAddress(orcaSessionId: string): string { - return `${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}` +export function formatOrcaSessionAddress(orcaSessionId: OrcaSessionId): OrcaSessionAddress { + const address = `${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}` + // Always true for a checked id; the check brands the address without a type assertion. + if (!isOrcaSessionAddress(address)) { + throw new Error(`Not an Orca session id: ${orcaSessionId}`) + } + return address } /** The bare Orca session id of a `session:` address; anything else reads as null. */ -export function parseOrcaSessionAddress(address: string | null | undefined): string | null { +export function parseOrcaSessionAddress(address: string | null | undefined): OrcaSessionId | null { if (!address?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { return null } const id = address.slice(ORCA_SESSION_ADDRESS_PREFIX.length) return isOrcaSessionId(id) ? id : null } + +function isOrcaSessionAddress(address: string): address is OrcaSessionAddress { + return parseOrcaSessionAddress(address) !== null +} diff --git a/src/shared/orchestration-compatibility-evidence.ts b/src/shared/orchestration-compatibility-evidence.ts index 72c7a03c20c..14beca112b7 100644 --- a/src/shared/orchestration-compatibility-evidence.ts +++ b/src/shared/orchestration-compatibility-evidence.ts @@ -26,6 +26,11 @@ export type OrchestrationCompatibilityEvidence = { paneKey?: string launchToken?: string host?: OrchestrationCompatibilityHostStamp + /** + * The Orca-minted agent session id from the caller's injected environment. When present it is + * the caller's identity: the dispatch entry resolves it, and a declared handle must name it. + */ + agentSessionId?: string } const SECRET_KEYS = new Set([ diff --git a/src/shared/orchestration-session-caller-codes.ts b/src/shared/orchestration-session-caller-codes.ts new file mode 100644 index 00000000000..7710ee95659 --- /dev/null +++ b/src/shared/orchestration-session-caller-codes.ts @@ -0,0 +1,15 @@ +/** Refusals for an orchestration party named by its Orca agent session id; each applies no effects. */ +export const ORCHESTRATION_SESSION_CALLER_ERROR_CODES = { + /** The request crossed a host boundary (a paired client, SSH, or WSL); session identity is same-host only. */ + hostBoundary: 'session_caller_host_boundary', + /** No Orca agent session with that id exists on this host. */ + unknown: 'session_caller_unknown', + /** The id is a provider's own session id, which rotates on `/clear`; the refusal names the Orca id. */ + providerId: 'session_caller_provider_id', + /** The session exists but has no live owner here: released, switching owners, or unreconciled. */ + notLive: 'session_caller_not_live', + /** A request with no session id named a chat's address as its caller. */ + chatNotDeclarable: 'session_caller_chat_not_declarable', + /** A Dispatch named a chat as its assignee, which a chat cannot be yet. */ + chatNotDispatchable: 'session_chat_not_dispatchable' +} as const From 77829142acac3358e28954099b9beb67534b7a81 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:02:10 -0400 Subject: [PATCH 010/387] fix(mobile): a terminal opens once at the phone's size, on the page and natively (OTA phase C follow-up) (#22960) * refactor(mobile): extract the once-per-route terminal viewport measure Behaviour-preserving move of measureViewportOnce into a pure module so the first-subscribe ordering can be tested without mounting the session route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send phone dims on a terminal's first subscribe A fresh terminal document has no xterm until its first init, so the viewport measure could not answer before the first subscribe. The subscribe went out without dims, the host serialized the snapshot at the desktop's size, the phone painted it, measured, resubscribed and replayed the whole snapshot a second time at the phone's size. The first subscribe of each document now opens an empty terminal, measures, and only then subscribes, so the host fits the PTY before serializing and the phone paints once. Measure failure still subscribes without dims and the existing fit pass takes over. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): converge the fit pass on the viewport the subscribe sent A frame-height refit can clear the measured flag after the first subscribe has already carried the phone's viewport. The fit pass then treated the snapshot as dimensionless and resubscribed, replaying the whole snapshot a second time at the same size. It now judges convergence against the viewport that subscribe actually sent; the refit still owns real layout changes through updateViewport. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): reject the failing measure without an async wrapper Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the fit-pass decision on the measured flag Converging on the viewport a subscribe carried accepted a stale one: after native chat covered the terminal through a rotate or dock, the return resubscribe sent the old dims, the host matched them and the pass never re-measured (#4579). The decision is back on the measured flag; the sent viewport only lets a matching fresh measure skip the round trip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): forget a document's first-subscribe mark only on web-ready The terminal handle re-attaches null then new on every theme or text-size change, and clearing the mark there let the next resubscribe post an empty init onto a live document. web-ready is the one signal that a new document started, so the mark is dropped there instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin each held-subscribe teardown guard on its own Splits the teardown test so dropping either the generation check or the in-flight check alone fails a test, and shares one MutableRef type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-session-route-parity.test.ts | 14 +- ...-terminal-first-subscribe-viewport.test.ts | 187 ++++++++++++++++++ ...obile-terminal-first-subscribe-viewport.ts | 88 +++++++++ ...bile-terminal-viewport-resubscribe.test.ts | 34 ++++ .../mobile-terminal-viewport-resubscribe.ts | 7 +- .../use-mobile-session-terminal-runtime.ts | 3 + ...ession-terminal-subscription-foundation.ts | 28 +-- ...se-mobile-session-terminal-subscription.ts | 30 ++- ...e-mobile-session-terminal-webview.test.tsx | 110 +++++++++++ .../use-mobile-session-terminal-webview.ts | 19 +- 10 files changed, 482 insertions(+), 38 deletions(-) create mode 100644 mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts create mode 100644 mobile/src/session/mobile-terminal-first-subscribe-viewport.ts create mode 100644 mobile/src/session/use-mobile-session-terminal-webview.test.tsx diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 60df6b17d08..e2f8b478cc0 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -92,10 +92,10 @@ const HOST_COMPONENT_NAMES = new Set([ // never pass it. Found by pullfrog on #22300. // Moved, count unchanged, when the Markdown actions' Back `useEffect` became `useBackClaim`, the // seam that also claims the key on the page while a draft is dirty. -const HEAD_MAIN_HOOK_SHA256 = '914bf72e0bf0abe2b22850467b5dfdc3ab3fd19111924f1de91d9c1d9d1ce74f' -const HEAD_HOOK_BINDING_SHA256 = 'd0677b98b909f8a24089ac5d6afe9ad2c238fa6c24969da1e84e01cd6f653a16' +const HEAD_MAIN_HOOK_SHA256 = 'f161e14a9c53d80c3dc75f51dd8ecb339b59b7239c9c3e067791b8612f51ede2' +const HEAD_HOOK_BINDING_SHA256 = '7a9e256e2058635850253a74b9faa05bcafbbcaa1e5b0b3ef7f677dde85753a2' const HEAD_CALLBACK_IDENTITY_SHA256 = - 'e396ab759b2401acef45dea428e97c7f8f24fff41aae7fb7858da5a5b145c64c' + '373dca17a060e63d8cb4e32416ca2889b8404cee78f7b47e632940a9980baf23' // Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends // and repo reads inside them now name their `RpcOperation` instead of the raw `sendRequest` port. // Refreshed in step 6 for the gesture flush, whose `terminal.send` became `terminalInputSend` and @@ -115,7 +115,7 @@ const HEAD_CALLBACK_IDENTITY_SHA256 = // the other's body. The hook and string counts are C7.2's and stand. // Refreshed once more for the two dictation failure handlers, which now both call // `reportDictationFailure` instead of each choosing between the setup sheet and a toast. -const HEAD_CALLBACK_BODY_SHA256 = '349ad6f766da14b3bce23a13f3a67f2b013efadd8896f63b7b68e724b9726d9b' +const HEAD_CALLBACK_BODY_SHA256 = '2ccbfb5ee57e7dfeb07dafaee6fa592b95862b3bc898a5ac2995e7c69913bfc4' // Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and // the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed // again when the reporter took the reply and interpreted it itself, retiring the hand-built @@ -172,7 +172,7 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345 // onLayout rather than reusing the loading View. Native measured 47 rows before and after: its // frame reported either way, and its window is its frame, so both measure paths agree there. const HEAD_RUNTIME_STRING_SHA256 = - 'd7eea5438ff7b4ee77c9bf652626adfdc3d2b49716ffb660df078f70bae0d88d' + '1be5398cdbdf96b6f7738b63a0e19f536496844140a947939a7f71824b08fe50' // Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now. const HEAD_HOST_JSX_SHA256 = 'ca4c8b46af86a05cb671de91c22111c9e4aaeefd4a04c7b8b09976ca01a31c9b' const HEAD_LEAF_JSX_SHA256 = 'c7e1a4b90197697f1eaa640c38da63281b4f7b84fb036ae2152f00c2f7d7cb77' @@ -566,7 +566,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(281) + expect(main.hooks).toHaveLength(282) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(79) @@ -611,7 +611,7 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(530) + expect(strings).toHaveLength(531) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(124) diff --git a/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts b/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts new file mode 100644 index 00000000000..e1da3e5d2f1 --- /dev/null +++ b/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest' +import { + deferFirstSubscribeUntilViewportMeasured, + measureTerminalViewportOnce, + type FirstSubscribeViewportGateArgs, + type TerminalViewportOnceArgs +} from './mobile-terminal-first-subscribe-viewport' + +const PHONE = { cols: 55, rows: 47 } + +function onceArgs(overrides: Partial = {}): TerminalViewportOnceArgs { + return { + handle: 'term-1', + ref: { + init: vi.fn(), + awaitReady: vi.fn(async () => {}), + measureFitDimensions: vi.fn(async () => PHONE) + }, + documentHasTerminal: true, + viewportRef: { current: null }, + viewportMeasuredRef: { current: false }, + terminalFrameHeightRef: { current: 751 }, + onMeasured: vi.fn(), + ...overrides + } +} + +describe('measureTerminalViewportOnce', () => { + it('stores the measured dims against the frame height', async () => { + const args = onceArgs() + await measureTerminalViewportOnce(args) + expect(args.ref?.measureFitDimensions).toHaveBeenCalledWith(751) + expect(args.viewportRef.current).toEqual(PHONE) + expect(args.viewportMeasuredRef.current).toBe(true) + expect(args.onMeasured).toHaveBeenCalledWith('term-1', PHONE, 751) + }) + + it('does nothing once the route has a viewport', async () => { + const args = onceArgs({ viewportMeasuredRef: { current: true } }) + await measureTerminalViewportOnce(args) + expect(args.ref?.measureFitDimensions).not.toHaveBeenCalled() + }) + + it('leaves the viewport unmeasured when the document cannot measure', async () => { + const args = onceArgs({ + ref: { + init: vi.fn(), + awaitReady: vi.fn(async () => {}), + measureFitDimensions: vi.fn(async () => null) + } + }) + await measureTerminalViewportOnce(args) + expect(args.viewportMeasuredRef.current).toBe(false) + expect(args.viewportRef.current).toBe(null) + }) +}) + +describe('measureTerminalViewportOnce on a fresh document', () => { + it('opens an empty terminal and waits for it before measuring, so cell metrics exist', async () => { + const order: string[] = [] + const args = onceArgs({ + documentHasTerminal: false, + ref: { + init: vi.fn((cols: number, rows: number, data?: string) => { + order.push(`init ${cols}x${rows} ${JSON.stringify(data)}`) + }), + awaitReady: vi.fn(async () => { + order.push('awaitReady') + }), + measureFitDimensions: vi.fn(async () => { + order.push('measure') + return PHONE + }) + } + }) + await measureTerminalViewportOnce(args) + expect(order).toEqual(['init 80x24 ""', 'awaitReady', 'measure']) + expect(args.viewportRef.current).toEqual(PHONE) + }) + + it('never re-inits a document that already holds a terminal', async () => { + const args = onceArgs({ documentHasTerminal: true }) + await measureTerminalViewportOnce(args) + expect(args.ref?.init).not.toHaveBeenCalled() + expect(args.viewportRef.current).toEqual(PHONE) + }) +}) + +function gateArgs( + overrides: Partial = {} +): FirstSubscribeViewportGateArgs { + return { + handle: 'term-1', + covered: false, + viewportMeasured: false, + subscribedDocuments: new Set(), + subscribingHandles: new Set(), + subscribeSeq: new Map([['term-1', 1]]), + measure: vi.fn(async () => {}), + subscribe: vi.fn(), + ...overrides + } +} + +describe('deferFirstSubscribeUntilViewportMeasured', () => { + it('holds the first subscribe of a document until the measure lands, then subscribes once', async () => { + let finishMeasure = (): void => {} + const args = gateArgs({ + measure: vi.fn( + () => + new Promise((resolve) => { + finishMeasure = resolve + }) + ) + }) + expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(true) + expect(args.measure).toHaveBeenCalledWith('term-1') + // Other callers see a subscribe in flight and stay out. + expect(args.subscribingHandles.has('term-1')).toBe(true) + expect(args.subscribe).not.toHaveBeenCalled() + finishMeasure() + await vi.waitFor(() => expect(args.subscribe).toHaveBeenCalledTimes(1)) + expect(args.subscribingHandles.has('term-1')).toBe(false) + // The resumed subscribe passes straight through. + expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(false) + }) + + it('still subscribes, without dims, when the measure fails', async () => { + const args = gateArgs({ measure: vi.fn(() => Promise.reject(new Error('gone'))) }) + expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(true) + await vi.waitFor(() => expect(args.subscribe).toHaveBeenCalledTimes(1)) + }) + + function heldSubscribe(subscribeSeq = new Map([['term-1', 1]])) { + let finishMeasure = (): void => {} + const args = gateArgs({ + subscribeSeq, + measure: vi.fn( + () => + new Promise((resolve) => { + finishMeasure = resolve + }) + ) + }) + deferFirstSubscribeUntilViewportMeasured(args) + return { args, finish: () => finishMeasure() } + } + const flush = async () => { + await Promise.resolve() + await Promise.resolve() + } + + it('drops the held subscribe when a newer subscribe generation started during the measure', async () => { + const subscribeSeq = new Map([['term-1', 1]]) + const { args, finish } = heldSubscribe(subscribeSeq) + subscribeSeq.set('term-1', 2) + finish() + await flush() + expect(args.subscribe).not.toHaveBeenCalled() + }) + + it('drops the held subscribe when its in-flight mark was cleared during the measure', async () => { + const { args, finish } = heldSubscribe() + args.subscribingHandles.delete('term-1') + finish() + await flush() + expect(args.subscribe).not.toHaveBeenCalled() + }) + + it('passes through when the viewport is known, but remembers the document', () => { + const args = gateArgs({ viewportMeasured: true }) + expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(false) + expect(args.subscribedDocuments.has('term-1')).toBe(true) + // A later refit invalidation must not re-open a live terminal's document. + expect(deferFirstSubscribeUntilViewportMeasured({ ...args, viewportMeasured: false })).toBe( + false + ) + expect(args.measure).not.toHaveBeenCalled() + }) + + it('passes through a native-chat-covered lease, which has no document', () => { + const args = gateArgs({ covered: true }) + expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(false) + expect(args.subscribedDocuments.has('term-1')).toBe(false) + expect(args.measure).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts b/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts new file mode 100644 index 00000000000..e6c79f5c5f9 --- /dev/null +++ b/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts @@ -0,0 +1,88 @@ +import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' +import type { MutableRef, TerminalViewportDims } from './mobile-terminal-viewport-resubscribe' + +/** Any size opens xterm; cell metrics, not the grid, are what the measure needs. */ +const EMPTY_TERMINAL_COLS = 80 +const EMPTY_TERMINAL_ROWS = 24 + +export type TerminalViewportMeasureTarget = Pick< + TerminalWebViewHandle, + 'init' | 'awaitReady' | 'measureFitDimensions' +> + +export type TerminalViewportOnceArgs = { + handle: string + ref: TerminalViewportMeasureTarget | undefined + /** False until the document's first init; it has no xterm, so no cell metrics, before one. */ + documentHasTerminal: boolean + viewportRef: MutableRef + viewportMeasuredRef: MutableRef + terminalFrameHeightRef: MutableRef + onMeasured: ( + handle: string, + dims: TerminalViewportDims | null | undefined, + frameHeight: number + ) => void +} + +/** Measures the phone viewport once per route; the dims then ride every subscribe so the host fits the PTY itself. */ +export async function measureTerminalViewportOnce(args: TerminalViewportOnceArgs): Promise { + if (args.viewportMeasuredRef.current) { + return + } + const ref = args.ref + if (ref && !args.documentHasTerminal) { + // Why: without this the first subscribe goes dimensionless, paints at the desktop's size, then resubscribes. + ref.init(EMPTY_TERMINAL_COLS, EMPTY_TERMINAL_ROWS, '') + await ref.awaitReady() + } + const dims = await ref?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined) + args.onMeasured(args.handle, dims, args.terminalFrameHeightRef.current) + if (dims) { + args.viewportRef.current = dims + args.viewportMeasuredRef.current = true + } +} + +export type FirstSubscribeViewportGateArgs = { + handle: string + covered: boolean + viewportMeasured: boolean + /** Handles whose current document has already been subscribed; cleared when that document goes away. */ + subscribedDocuments: Set + subscribingHandles: Set + subscribeSeq: ReadonlyMap + measure: (handle: string) => Promise + subscribe: (handle: string) => void +} + +/** + * Holds a document's first subscribe until the route's viewport is measured, so the host + * serializes the snapshot at the phone's size. Returns true when it took over the subscribe. + */ +export function deferFirstSubscribeUntilViewportMeasured( + args: FirstSubscribeViewportGateArgs +): boolean { + const { handle } = args + // A covered lease has no document to measure and asks for no viewport. + if (args.covered) { + return false + } + const firstForDocument = !args.subscribedDocuments.has(handle) + args.subscribedDocuments.add(handle) + if (!firstForDocument || args.viewportMeasured) { + return false + } + args.subscribingHandles.add(handle) + const seq = args.subscribeSeq.get(handle) + const resume = (): void => { + // Why: unsubscribeTerminal bumps the seq and clears the in-flight mark; a torn-down handle stays down. + if (args.subscribeSeq.get(handle) !== seq || !args.subscribingHandles.has(handle)) { + return + } + args.subscribingHandles.delete(handle) + args.subscribe(handle) + } + void args.measure(handle).then(resume, resume) + return true +} diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts index 35aac89f6f8..14f11b15c40 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts @@ -239,6 +239,7 @@ describe('runTerminalViewportFitPass', () => { viewportMeasured?: boolean viewport?: { cols: number; rows: number } | null measured?: { cols: number; rows: number } | null + sentViewport?: { cols: number; rows: number } | null budget?: TerminalViewportResubscribeBudget }) { const budget = overrides.budget ?? new TerminalViewportResubscribeBudget() @@ -266,6 +267,7 @@ describe('runTerminalViewportFitPass', () => { seq: 1, hostCols: overrides.hostCols ?? null, hostRows: overrides.hostRows ?? null, + sentViewport: overrides.sentViewport ?? null, budget, diagnostics, viewportRef: { current: overrides.viewport ?? null }, @@ -313,6 +315,38 @@ describe('runTerminalViewportFitPass', () => { expect(h.budget.attempts(HANDLE)).toBe(1) }) + it('re-measures a stale sent viewport after the flag was cleared, and resubscribes at the phone size', async () => { + const stale = { cols: 80, rows: 30 } + const h = makeHarness({ + hostCols: stale.cols, + hostRows: stale.rows, + viewportMeasured: false, + viewport: stale, + sentViewport: stale, + measured: PHONE + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1) + expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1) + expect(h.args.viewportRef.current).toEqual(PHONE) + }) + + it('re-measures locally and skips the round trip when the host matches the viewport the subscribe carried', async () => { + const h = makeHarness({ + hostCols: PHONE.cols, + hostRows: PHONE.rows, + viewportMeasured: false, + viewport: PHONE, + sentViewport: PHONE + }) + runTerminalViewportFitPass(h.args) + await settle() + expect(h.unsubscribeTerminal).not.toHaveBeenCalled() + expect(h.subscribeToTerminal).not.toHaveBeenCalled() + expect(h.budget.attempts(HANDLE)).toBe(0) + }) + it('treats an equal fresh measure as convergence instead of resubscribing', async () => { const budget = new TerminalViewportResubscribeBudget() budget.chargeAttempt(HANDLE) diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts index 175eb9d92b8..9113e84f1f4 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts @@ -173,7 +173,7 @@ export class TerminalViewportResubscribeBudget { } } -type MutableRef = { current: T } +export type MutableRef = { current: T } type TerminalFitWebView = { awaitReady: () => Promise @@ -185,6 +185,8 @@ export type TerminalViewportFitPassArgs = { seq: number hostCols: number | null hostRows: number | null + /** The viewport this subscribe carried; null when it went without one. */ + sentViewport: TerminalViewportDims | null budget: TerminalViewportResubscribeBudget diagnostics: Pick< MobileTerminalDiagnostics, @@ -230,7 +232,8 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v } return } - const viewportWasMeasured = args.viewportMeasuredRef.current + // Why: a subscribe that carried a viewport already told the host one; a fresh measure that matches it needs no round trip. + const viewportWasMeasured = args.viewportMeasuredRef.current || args.sentViewport != null void (async () => { // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). await args.getTerminalRef(handle)?.awaitReady() diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 374a9eefd94..5e4196e1500 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -76,6 +76,8 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget()) // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered. const webReadyHandlesRef = useRef>(new Set()) + // Why: a document's first subscribe waits for the viewport measure (see mobile-terminal-first-subscribe-viewport.ts). + const subscribedDocumentsRef = useRef>(new Set()) const activeHandleRef = useRef(null) const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef }) const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs) @@ -180,6 +182,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM terminalDiagnosticsRef, viewportResubscribeBudgetRef, webReadyHandlesRef, + subscribedDocumentsRef, activeHandleRef, activeSessionTabTypeRef, pendingActiveSessionTabIdRef, diff --git a/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts b/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts index 76d8229b288..efb482c8c8f 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts @@ -1,4 +1,5 @@ import { useRef, useCallback } from 'react' +import { measureTerminalViewportOnce } from './mobile-terminal-first-subscribe-viewport' import type { MobileSessionNativeChatDictationModel } from './use-mobile-session-native-chat-dictation' export function useMobileSessionTerminalSubscriptionFoundation( @@ -18,6 +19,7 @@ export function useMobileSessionTerminalSubscriptionFoundation( terminalDiagnosticsRef, viewportResubscribeBudgetRef, webReadyHandlesRef, + subscribedDocumentsRef, activeHandleRef, subscribeSeqRef, layoutSeqRef, @@ -75,6 +77,7 @@ export function useMobileSessionTerminalSubscriptionFoundation( terminalDiagnosticsRef.current.clearTerminalCache() viewportResubscribeBudgetRef.current.clear() webReadyHandlesRef.current.clear() + subscribedDocumentsRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() terminalCwdRef.current.clear() @@ -84,21 +87,18 @@ export function useMobileSessionTerminalSubscriptionFoundation( } }, [clearNativeChatInputLease]) - // Why: measure the phone viewport once from the first TerminalWebView; dims ride every subscribe so the server auto-fits without a separate RPC. const measureViewportOnce = useCallback( - async (handle: string) => { - if (viewportMeasuredRef.current) { - return - } - const dims = await getTerminalRef(handle)?.measureFitDimensions( - terminalFrameHeightRef.current || undefined - ) - terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current) - if (dims) { - viewportRef.current = dims - viewportMeasuredRef.current = true - } - }, + (handle: string) => + measureTerminalViewportOnce({ + handle, + ref: getTerminalRef(handle), + documentHasTerminal: initializedHandlesRef.current.has(handle), + viewportRef, + viewportMeasuredRef, + terminalFrameHeightRef, + onMeasured: (measuredHandle, dims, frameHeight) => + terminalDiagnosticsRef.current.viewportMeasured(measuredHandle, dims, frameHeight) + }), [getTerminalRef] ) return { diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index e92fb12488e..a440c22e319 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { isTerminalOscLinkRanges } from '../../../src/shared/terminal-osc-link-ranges' import * as nativeChatTerminalStream from './mobile-native-chat-terminal-stream' +import { deferFirstSubscribeUntilViewportMeasured } from './mobile-terminal-first-subscribe-viewport' import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' import { mobileTerminalSnapshotByteBudget } from './terminal-snapshot-byte-budget' import { @@ -31,6 +32,7 @@ export function useMobileSessionTerminalSubscription( terminalDiagnosticsRef, viewportResubscribeBudgetRef, webReadyHandlesRef, + subscribedDocumentsRef, activeHandleRef, subscribeSeqRef, layoutSeqRef, @@ -42,6 +44,7 @@ export function useMobileSessionTerminalSubscription( getTerminalRef, unsubscribeTerminal, unsubscribeTerminalRef, + measureViewportOnce, signalTerminalInventoryRecovery } = scope const subscribeToTerminal = useCallback( @@ -82,6 +85,22 @@ export function useMobileSessionTerminalSubscription( } } + if ( + deferFirstSubscribeUntilViewportMeasured({ + handle, + covered, + viewportMeasured: viewportMeasuredRef.current, + subscribedDocuments: subscribedDocumentsRef.current, + subscribingHandles: subscribingHandlesRef.current, + subscribeSeq: subscribeSeqRef.current, + measure: measureViewportOnce, + subscribe: subscribeToTerminal + }) + ) { + logSkippedGate('measuring-viewport') + return + } + subscribingHandlesRef.current.add(handle) if (covered) { leaseOnlyHandlesRef.current.add(handle) @@ -91,6 +110,10 @@ export function useMobileSessionTerminalSubscription( const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) diagnostics.streamArmed(handle, seq, viewportRef.current) + const sentViewport = nativeChatTerminalStream.mobileNativeChatSubscribeViewport( + covered, + viewportRef.current + ) // Why: viewport is embedded in the subscribe params so the server auto-fits before serializing scrollback (no focus→safeFit race). const unsub = subscribeMobileTerminalSafely( @@ -98,10 +121,7 @@ export function useMobileSessionTerminalSubscription( { terminal: handle, client: { id: clientId, type: 'mobile' as const }, - viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport( - covered, - viewportRef.current - ), + viewport: sentViewport, capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered), // Undefined on a phone, where no per-message cap exists; omitted rather than sent as // undefined so an older host sees the params it has always seen. @@ -197,6 +217,7 @@ export function useMobileSessionTerminalSubscription( seq, hostCols, hostRows, + sentViewport: sentViewport ?? null, budget: viewportResubscribeBudgetRef.current, diagnostics, viewportRef, @@ -277,6 +298,7 @@ export function useMobileSessionTerminalSubscription( clientId, getTerminalRef, markNativeChatInputLeaseReady, + measureViewportOnce, scheduleDelayedAction, showToast, signalTerminalInventoryRecovery diff --git a/mobile/src/session/use-mobile-session-terminal-webview.test.tsx b/mobile/src/session/use-mobile-session-terminal-webview.test.tsx new file mode 100644 index 00000000000..5dfede07429 --- /dev/null +++ b/mobile/src/session/use-mobile-session-terminal-webview.test.tsx @@ -0,0 +1,110 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' +import { deferFirstSubscribeUntilViewportMeasured } from './mobile-terminal-first-subscribe-viewport' +import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' +import type { MobileSessionTabSwitchingModel } from './use-mobile-session-tab-switching' +import { useMobileSessionTerminalWebview } from './use-mobile-session-terminal-webview' + +const HANDLE = 'term-1' + +function terminalHandle(): TerminalWebViewHandle { + return { + prepareForForegroundRecovery: vi.fn(), + write: vi.fn(), + init: vi.fn(), + resize: vi.fn(), + reflow: vi.fn(), + clear: vi.fn(), + measureFitDimensions: vi.fn(async () => null), + resetZoom: vi.fn(), + cancelSelect: vi.fn(), + doSelectAll: vi.fn(), + awaitReady: vi.fn(async () => {}) + } +} + +function makeScope() { + const fields = { + markdownDocs: new Map(), + fileDocs: new Map(), + terminalGestureInputBucketsRef: { current: new Map() }, + terminalGestureInputQueuesRef: { current: new Map() }, + terminalGestureInputInFlightRef: { current: new Set() }, + terminalRefs: { current: new Map() }, + terminalUnsubsRef: { current: new Map void>() }, + initializedHandlesRef: { current: new Set() }, + terminalDiagnosticsRef: { current: new MobileTerminalDiagnostics() }, + webReadyHandlesRef: { current: new Set() }, + subscribedDocumentsRef: { current: new Set() }, + activeHandleRef: { current: HANDLE }, + pendingActiveTerminalHandleRef: { current: null }, + activeSessionTab: null, + unsubscribeTerminal: vi.fn(), + subscribeToTerminal: vi.fn(), + nativeChatStream: { notifyWebReady: vi.fn() }, + readMarkdownTab: vi.fn(), + readFileTab: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook destructures only the fields built above. + return { fields, scope: fields as unknown as MobileSessionTabSwitchingModel } +} + +let renderer: ReactTestRenderer | undefined +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) + +function renderWebviewHook(scope: MobileSessionTabSwitchingModel) { + let model: ReturnType | undefined + function Probe() { + model = useMobileSessionTerminalWebview(scope) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + if (!model) { + throw new Error('hook did not render') + } + return model +} + +// The gate as subscribeToTerminal calls it once a refit has cleared the measured flag. +function gateWouldReopen(fields: ReturnType['fields']) { + return deferFirstSubscribeUntilViewportMeasured({ + handle: HANDLE, + covered: false, + viewportMeasured: false, + subscribedDocuments: fields.subscribedDocumentsRef.current, + subscribingHandles: new Set(), + subscribeSeq: new Map([[HANDLE, 1]]), + measure: vi.fn(async () => {}), + subscribe: vi.fn() + }) +} + +describe('terminal document lifetime for the first-subscribe gate', () => { + it('keeps a live document marked when its handle re-attaches for a theme or text-size change', () => { + const { fields, scope } = makeScope() + const model = renderWebviewHook(scope) + model.setTerminalWebViewRef(HANDLE, terminalHandle()) + model.handleTerminalWebReady(HANDLE) + fields.subscribedDocumentsRef.current.add(HANDLE) + // The controller's handle memo changes with theme and text scale: null, then the new handle. + model.setTerminalWebViewRef(HANDLE, null) + model.setTerminalWebViewRef(HANDLE, terminalHandle()) + expect(gateWouldReopen(fields)).toBe(false) + }) + + it('forgets the mark when a new document announces itself', () => { + const { fields, scope } = makeScope() + const model = renderWebviewHook(scope) + model.setTerminalWebViewRef(HANDLE, terminalHandle()) + fields.subscribedDocumentsRef.current.add(HANDLE) + model.handleTerminalWebReady(HANDLE) + expect(fields.subscribedDocumentsRef.current.has(HANDLE)).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-webview.ts b/mobile/src/session/use-mobile-session-terminal-webview.ts index 56af08c6309..59ffcccbca6 100644 --- a/mobile/src/session/use-mobile-session-terminal-webview.ts +++ b/mobile/src/session/use-mobile-session-terminal-webview.ts @@ -14,11 +14,11 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching initializedHandlesRef, terminalDiagnosticsRef, webReadyHandlesRef, + subscribedDocumentsRef, activeHandleRef, pendingActiveTerminalHandleRef, activeSessionTab, unsubscribeTerminal, - measureViewportOnce, subscribeToTerminal, nativeChatStream, readMarkdownTab, @@ -44,6 +44,8 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching const handleTerminalWebReady = useCallback( (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) + // Why: web-ready announces a new document; the handle also re-attaches on theme or text-size changes, which keep the document. + subscribedDocumentsRef.current.delete(handle) webReadyHandlesRef.current.add(handle) nativeChatStream.notifyWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( @@ -60,20 +62,15 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching } return } - // Why: first subscribe may skip (no WebView ref); await measure so it carries the viewport, else it races measureViewportOnce and skips. // Why: a just-created tab can lose activeHandleRef to a lagging snapshot; honor the pending marker so its web-ready subscribe still fires. - const isIntendedActive = () => + const isIntendedActive = handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current - if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { - void (async () => { - await measureViewportOnce(handle) - if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { - subscribeToTerminal(handle) - } - })() + // Why: subscribeToTerminal measures the viewport first for a fresh document, so the dims ride this subscribe. + if (isIntendedActive && !terminalUnsubsRef.current.has(handle)) { + subscribeToTerminal(handle) } }, - [measureViewportOnce, nativeChatStream, subscribeToTerminal, unsubscribeTerminal] + [nativeChatStream, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { From 8d21fdbf604f0c459c50ed803893de155b002e83 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:15:30 -0700 Subject: [PATCH 011/387] fix(mobile): update fflate for security advisory (#22961) --- mobile/package.json | 2 +- mobile/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mobile/package.json b/mobile/package.json index cd0d5df409e..8ab89918f99 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -49,7 +49,7 @@ "expo-splash-screen": "^55.0.25", "expo-status-bar": "^55.0.6", "expo-task-manager": "~55.0.20", - "fflate": "0.8.2", + "fflate": "0.8.3", "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", "mermaid": "11.17.2", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index c9834eec892..66f6e872624 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -114,8 +114,8 @@ importers: specifier: ~55.0.20 version: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) fflate: - specifier: 0.8.2 - version: 0.8.2 + specifier: 0.8.3 + version: 0.8.3 lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -4564,8 +4564,8 @@ packages: fetch-nodeshim@0.4.10: resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -12387,7 +12387,7 @@ snapshots: fetch-nodeshim@0.4.10: {} - fflate@0.8.2: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: dependencies: From 600adba9f03a4afd850e910e9065434471290d5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:25:03 -0700 Subject: [PATCH 012/387] fix(native-chat): every journal append reaches the chats that are open (#22811) * refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff ()." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast --------- Co-authored-by: Claude --- .../agent-session-journal/journal-store.ts | 17 +- ...ured-agent-session-append-delivery.test.ts | 231 ++++++++++++++++++ ...ctured-agent-session-conversations.test.ts | 154 ++++++++++++ .../structured-agent-session-conversations.ts | 47 ++++ ...structured-agent-session-host-mutations.ts | 4 +- .../structured-agent-session-host-types.ts | 3 +- .../structured-agent-session-host.ts | 15 +- ...ured-agent-session-journal-handles.test.ts | 10 +- ...ured-agent-session-restart-continuation.ts | 6 +- ...tured-agent-session-restart-resume-host.ts | 1 - ...red-agent-session-restart-resume-wiring.ts | 2 +- ...ructured-agent-session-send-preparation.ts | 3 +- .../structured-agent-session-subscribers.ts | 44 ++-- ...red-agent-session-surface-lifetime.test.ts | 9 +- .../structured-agent-session-thread-goal.ts | 2 - .../structured-agent-session-turns-prompt.ts | 2 - .../structured-agent-session-turns.ts | 8 +- ...ession-unanswered-dispatch-release.test.ts | 11 +- .../structured-conversation-command.ts | 4 - 19 files changed, 514 insertions(+), 59 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index ec445cb7004..568fbe4d39d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -76,6 +76,7 @@ export class AgentSessionJournal { private readOnly = false private malformedRows = 0 private database: OpenJournalDatabase | null = null + private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly closer: JournalConnectionCloser private readonly rowWriter: JournalRowWriter @@ -111,8 +112,14 @@ export class AgentSessionJournal { this.readOnly = readOnly }, cursor: this.cursor, - adopt: (loaded) => this.adoptLoadedJournal(loaded), - commit: (row) => applyJournalRow(this.state, row), + adopt: (loaded) => { + this.adoptLoadedJournal(loaded) + this.onCommitted?.() + }, + commit: (row) => { + applyJournalRow(this.state, row) + this.onCommitted?.() + }, loaded: () => this.loaded, malformedRows: () => this.malformedRows, setMalformedRows: (count) => { @@ -167,6 +174,12 @@ export class AgentSessionJournal { return this.closer.close() } + /** Told of every durable change, epoch replacements included, so a reader learns of a write + * without its writer saying so. One listener: a later call replaces it. It must not throw. */ + observeCommits(listener: () => void): void { + this.onCommitted = listener + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts new file mode 100644 index 00000000000..c8c908aae70 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -0,0 +1,231 @@ +// What an open chat receives, asserted at its subscriber rather than in the journal: a fresh +// subscribe re-reads the journal and hides a write that never reached the readers already open. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let generation = 0 + +/** Everything a live subscriber was sent after it opened. */ +function liveReader() { + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => events.push(event) }) + const opened = events.length + const received = () => { + const items: AgentJournalRenderItem[] = [] + const submissions: AgentJournalSubmission[] = [] + for (const event of events.slice(opened)) { + if (event.type === 'batch') { + items.push(...event.batch.items) + submissions.push(...event.batch.submissions) + } else if (event.type === 'snapshot' || event.type === 'reset') { + items.push(...event.page.items) + submissions.push(...event.page.submissions) + } + } + return { + statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), + submissions, + batches: events.slice(opened).filter((event) => event.type === 'batch').length + } + } + return { received } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +function exitBeforeProof(): Promise { + return host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}`, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) +} + +function providerSink() { + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no acquired provider sink') + } + return events +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-append-delivery-')) + resetHostTestOperationIds() + generation = 0 + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation + 1}`, + now: () => NOW + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('an open chat receives every row its journal commits', () => { + it('shows a failed start whose lease could not be handed back', async () => { + const held = await send('hello') + const pane = liveReader() + // The exit settles the journal, then fails to release the lease: nothing moves the fence. + vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('record store busy')) + + await exitBeforeProof() + + expect(pane.received().statuses).toEqual([ + expect.stringMatching(/stopped before it finished starting: .*not signed in/) + ]) + expect(pane.received().submissions).toContainEqual( + expect.objectContaining({ clientMessageId: held, dispatchState: 'rejected' }) + ) + }) + + it('shows a revision the provider queued with no publish behind it', async () => { + const pane = liveReader() + const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } + const body = { kind: 'status' as const, text: 'context usage answered after the turn' } + + expect(providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }))).toEqual({ + accepted: true + }) + await host.flushStreamedEvents(SESSION) + + expect(pane.received().statuses).toEqual(['context usage answered after the turn']) + }) + + it('shows a row appended straight to the journal', async () => { + const pane = liveReader() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + + await journal.appendItem( + { provider: 'orca', clientMessageId: 'host-note' }, + { kind: 'status', text: 'written by a writer that publishes nothing' }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + ) + + expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) + }) +}) + +describe('an open chat receives each row once', () => { + it('when the provider frame that wrote it also publishes', async () => { + const pane = liveReader() + const sink = providerSink() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + const readSince = vi.spyOn(journal, 'readSince') + + sink.appendItem( + { provider: 'orca', clientMessageId: 'streamed' }, + { kind: 'status', text: 'streamed row' } + ) + sink.publish() + await host.flushStreamedEvents(SESSION) + + expect(pane.received().statuses).toEqual(['streamed row']) + // The frame's publish finds the reader caught up and reads no rows: streaming stays one read. + expect(readSince).toHaveBeenCalledOnce() + }) + + it('when a writer publishes the row it appended', async () => { + const pane = liveReader() + const journal = host['sessions'].get(SESSION)?.journal + if (!journal) { + throw new Error('the attached chat has no journal') + } + + await journal.appendItem( + { provider: 'orca', clientMessageId: 'host-row' }, + { kind: 'status', text: 'host row' }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + ) + host['subscribers'].publish(SESSION, journal) + + expect(pane.received().statuses).toEqual(['host row']) + // The second publish found nothing past the reader's cursor, so it sent nothing at all. + expect(pane.received().batches).toBe(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts new file mode 100644 index 00000000000..202ffe50ae4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -0,0 +1,154 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function openJournal(name: string): Promise { + root ??= await mkdtemp(join(tmpdir(), 'orca-conversations-')) + return journals.open({ identity: IDENTITY, journalDir: join(root, name) }) +} + +function session(journal: AgentSessionJournal) { + return { + journal, + params: hostTestAttachParams(null), + fence: 0, + hasProviderChild: false, + providerChildPhase: 'ready' as const, + acquisitionGeneration: null + } +} + +function appendStatus(journal: AgentSessionJournal, text: string) { + return journal.appendItem( + { provider: 'orca', clientMessageId: text }, + { kind: 'status', text }, + { fence: 0 } + ) +} + +describe('a conversation delivers what its journal commits', () => { + it('delivers an append that no writer published', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await appendStatus(journal, 'written') + + // Already delivered when the writer's await returns, as an explicit publish would have been. + expect(deliver).toHaveBeenCalledOnce() + expect(deliver).toHaveBeenCalledWith('session-1', journal) + }) + + it('binds a handle set through a plain map reference', async () => { + const deliver = vi.fn() + // Collaborators hold the host's map as a plain `Map`; `set` still reaches the binding. + const sessions: Map = + new StructuredAgentSessionConversations({ deliver, onDeliveryError: vi.fn() }) + const journal = await openJournal('a') + sessions.set('session-1', session(journal)) + + await appendStatus(journal, 'through the plain map') + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + + it('delivers an epoch replacement, which readers must reload from', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await journal.replaceEpochItems('handle_forked', 0, []) + + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', journal) + }) + + it('delivers nothing for a handle the conversation has replaced', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const replaced = await openJournal('a') + const current = await openJournal('b') + conversations.set('session-1', session(replaced)) + conversations.set('session-1', session(current)) + + await appendStatus(replaced, 'stale') + expect(deliver).not.toHaveBeenCalled() + + await appendStatus(current, 'live') + expect(deliver).toHaveBeenCalledExactlyOnceWith('session-1', current) + }) + + it('delivers nothing once the conversation is dropped', async () => { + const deliver = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn() + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + conversations.delete('session-1') + + await appendStatus(journal, 'after close') + + expect(deliver).not.toHaveBeenCalled() + }) + + it('reports a reader failure without failing the durable write', async () => { + const failure = new Error('reader failed') + const onDeliveryError = vi.fn() + const conversations = new StructuredAgentSessionConversations({ + deliver: () => { + throw failure + }, + onDeliveryError + }) + const journal = await openJournal('a') + conversations.set('session-1', session(journal)) + + await expect(appendStatus(journal, 'durable')).resolves.toMatchObject({ + itemId: expect.any(String) + }) + + expect(onDeliveryError).toHaveBeenCalledExactlyOnceWith('session-1', failure) + expect(journal.snapshot().items.map((item) => item.body)).toContainEqual({ + kind: 'status', + text: 'durable' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts new file mode 100644 index 00000000000..2f38ddf4da2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -0,0 +1,47 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +/** + * The host's open conversations. A journal handle becomes a conversation's when it is set here, + * and from then on every change it commits reaches that conversation's readers: no writer + * publishes what it appended, so none can forget to. + * + * Delivery runs a microtask after the commit, so a reader that throws cannot fail a write that is + * already durable. A handle this map has since replaced or dropped delivers nothing. + */ +export class StructuredAgentSessionConversations extends Map< + string, + StructuredAgentSessionHostSession +> { + constructor( + private readonly delivery: { + deliver: (sessionId: string, journal: AgentSessionJournal) => void + onDeliveryError: (sessionId: string, error: unknown) => void + } + ) { + super() + } + + override set(sessionId: string, session: StructuredAgentSessionHostSession): this { + const { journal } = session + let queued = false + journal.observeCommits(() => { + if (queued) { + return + } + queued = true + queueMicrotask(() => { + queued = false + if (this.get(sessionId)?.journal !== journal) { + return + } + try { + this.delivery.deliver(sessionId, journal) + } catch (error) { + this.delivery.onDeliveryError(sessionId, error) + } + }) + }) + return super.set(sessionId, session) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index f6a5770cfac..c1ae42e237c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -219,7 +219,6 @@ export async function settleStructuredAgentSessionLateDispatch( fence: session.fence } ) - context.publish(input.sessionId, session.journal) } /** @@ -235,7 +234,7 @@ export async function settleStructuredAgentSessionLateDispatch( * it never makes a send re-deliverable, because the provider may well have run it. */ export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick, + context: Pick, input: { sessionId: string; reason: string } ): Promise { const session = context.sessions.get(input.sessionId) @@ -258,7 +257,6 @@ export async function releaseStructuredAgentSessionUnansweredDispatches( recovered: true }) } - context.publish(input.sessionId, session.journal) } /** The host's thin mutation surface. Each call re-reads the context, so a session diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 574ced3cc38..bf73befc708 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -27,7 +27,8 @@ export type StructuredAgentSessionReveal = { } export type StructuredAgentSessionHostSession = { - journal: AgentSessionJournal + /** Readonly: a new handle enters only through the session map's `set`, which binds its delivery. */ + readonly journal: AgentSessionJournal params: AgentSessionAttachParams fence: number /** Whether THIS host generation is running the provider process behind the session. A journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 4a6c4645e45..91351f05fca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -44,6 +44,7 @@ import type { import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { createStructuredAgentSessionRestartResume, type StructuredAgentSessionRestartResume @@ -56,7 +57,10 @@ export class StructuredAgentSessionHost { () => this.mutationContext(), this ) - private readonly sessions = new Map() + private readonly sessions = new StructuredAgentSessionConversations({ + deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }) + }) private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), @@ -125,10 +129,11 @@ export class StructuredAgentSessionHost { ensureProviderChild: (id, options) => this.holds.ensureProviderChild(id, options), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { - ...structuredAgentSessionRestartResumeSurfaces(this, this.now), - publish: this.subscribers.publish.bind(this.subscribers) - }) + this.restartResume = createStructuredAgentSessionRestartResume( + deps, + this.sessions, + structuredAgentSessionRestartResumeSurfaces(this, this.now) + ) this.runtimeState.startLeaseRenewal() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index 1c9f271004c..ef08e1c7e93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -242,9 +242,13 @@ describe('site 11: host teardown is failure-complete', () => { const failing = sessions.get(SESSION) const closeError = new Error('close rejected') if (failing) { - failing.journal = { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal + sessions.set(SESSION, { + ...failing, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. + journal: { + close: () => Promise.reject(closeError) + } as unknown as AgentSessionJournal + }) } await expect( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 58d104a651c..0dd2337f4ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -55,7 +55,6 @@ export type StructuredAgentSessionContinuationHost = { clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void - publish: (sessionId: string, journal: AgentSessionJournal) => void now: () => number /** Whether the marker is still an offer, with the continuation's own submission set aside. * Re-asked right before dispatch, so a newer user message refuses the send; a provider turn @@ -106,9 +105,9 @@ export function noteRestartReattachFailed( ) } -/** Writes a host-authored status note into the chat and publishes it to open panes. */ +/** Writes a host-authored status note into the chat. */ function restartNoteWriter( - host: Pick + host: Pick ): StructuredAgentSessionContinuationDeps['note'] { return async (sessionId, text, tone) => { const session = host.sessions.get(sessionId) @@ -120,7 +119,6 @@ function restartNoteWriter( { kind: 'status', text, ...(tone ? { tone } : {}) }, { fence: session.fence } ) - host.publish(sessionId, session.journal) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 0770190c2f4..29453e74652 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -45,7 +45,6 @@ import { createStructuredAgentSessionRestartWitnesses } from './structured-agent type LiveSession = { journal: AgentSessionJournal; hasProviderChild: boolean; fence: number } export type StructuredAgentSessionRestartResumeSurfaces = { - publish: (sessionId: string, journal: AgentSessionJournal) => void revealSession: (sessionId: string) => Promise<{ readable: boolean }> hold: (sessionId: string, holderId: string) => Promise release: (sessionId: string, holderId: string) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 4494392fb48..07ec6b81599 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -41,7 +41,7 @@ type RestartResumeHostBindings = { export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): Omit { +): StructuredAgentSessionRestartResumeSurfaces { return { revealSession: host.revealSession, hold: host.hold, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index e4a332f7acf..e277889345f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -114,7 +114,7 @@ export function structuredAgentSessionSendNeedsOwner( type SendPreparationContext = Pick< StructuredAgentSessionMutationContext, - 'deps' | 'sessions' | 'holds' | 'restoreReadable' | 'publish' + 'deps' | 'sessions' | 'holds' | 'restoreReadable' > export async function prepareStructuredAgentSessionSend( @@ -224,7 +224,6 @@ async function recordFailedRestart( } ] }) - context.publish(sessionId, session.journal) } catch (error) { context.deps.onEventSinkError?.({ sessionId, error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index af5e231730c..0b54ea7c430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -15,6 +15,7 @@ import { type AgentSessionSubscribeEvent, type AgentSessionTurnActivity } from '../../../shared/agent-session-wire' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { @@ -213,6 +214,11 @@ export class AgentSessionSubscribers { ? this.activityField(subscriber.sessionId).activity : undefined const publishedActivity = activity !== undefined ? activity : checkpointActivity + // Caught up, so there are no rows to read: every publish behind a commit's own delivery. + if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) + return + } const readPage = createAgentSessionCatchUpReader(journal) while (true) { const result = readPage({ @@ -239,20 +245,7 @@ export class AgentSessionSubscribers { const page = result.page const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence if (!advanced) { - const commandsChanged = - this.hooks.readCommands !== undefined && - (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (emitCheckpoint || publishedActivity !== undefined || commandsChanged) { - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: emptyAgentSessionBatch(page.window.nextCursor), - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - } + this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) return } this.emit(subscriber, { @@ -276,6 +269,29 @@ export class AgentSessionSubscribers { } } + private emitCaughtUp( + subscriber: Subscriber, + hostNow: number, + emitCheckpoint: boolean, + backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, + activity: AgentSessionTurnActivity | null | undefined + ): void { + const commandsChanged = + this.hooks.readCommands !== undefined && + (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands + if (emitCheckpoint || activity !== undefined || commandsChanged) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), + ...(activity !== undefined ? { activity } : {}) + }) + } + } + private now = (): number => this.hooks.now?.() ?? Date.now() private isActive = (subscriber: Subscriber): boolean => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 661da1ed844..09dd5f82197 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -279,7 +279,7 @@ describe('a chat that closes', () => { expect(host.hasSession(SESSION)).toBe(true) }) - it('releases a compatibility wait when the session is evicted', async () => { + it('answers a compatibility wait with what eviction recorded', async () => { await attach() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('pending until close') @@ -298,7 +298,12 @@ describe('a chat that closes', () => { await host.close(SESSION) - await expect(settlement).resolves.toBeUndefined() + // Eviction's settlement is a journal write, so the wait sees it rather than timing out. + await expect(settlement).resolves.toMatchObject({ + value: { + submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } + } + }) }) it('retries teardown after journal close loses its result', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 4e49e00f587..3f63679d55f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -68,13 +68,11 @@ export async function performThreadGoalChange( }, { fence: ctx.fence } ) - ctx.publish() } const withdrawObjective = async (): Promise => { if (change.kind === 'set') { // Nothing was sent as a goal. await ctx.journal.appendTombstone(identity, { fence: ctx.fence }) - ctx.publish() } } let result: Awaited> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 4cacf96ba89..e235ccb4940 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -107,7 +107,6 @@ export async function performPrompt( fence: ctx.fence } ) - ctx.publish() } }) } catch (error) { @@ -131,7 +130,6 @@ export async function performPrompt( }, { fence: ctx.fence } ) - ctx.publish() } const appended = committed.item if (!appended) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index bea393eddbb..e8103dac62f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -42,6 +42,8 @@ export type AgentSessionTurnContext = { persistOptions: (options: Readonly>) => Promise /** Opaque client identity recorded as the resolver of a prompt. */ resolvedBy: string + /** Republishes state kept outside the journal, such as the record's options or rewind phase. + * Journal appends reach readers on their own. */ publish: () => void /** Drains provider lifecycle already accepted by the execution host. */ flushStreamedEvents: () => Promise @@ -100,7 +102,6 @@ async function appendStatus( { kind: 'status', text }, { fence: ctx.fence } ) - ctx.publish() } /** @@ -136,7 +137,6 @@ export async function performSend( } catch { return invalid('The message could not be recorded and was not sent.') } - ctx.publish() // The row just written is the send's instant on the host clock; the turn this // dispatch opens records it so the live counter never re-anchors at turn-open. @@ -161,14 +161,12 @@ export async function performSend( if (!recorded) { console.warn('[structured-agent-session] pre-dispatch refusal persistence failed') } - ctx.publish() } throw error } ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { - ctx.publish() return { ok: true, value: { @@ -206,10 +204,8 @@ export async function performSend( } catch { // Nothing further to record; the pending row is settled on the next attach. } - ctx.publish() throw error } - ctx.publish() return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index fc8440500a4..79f21a349b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -35,9 +35,8 @@ function contextWith(submissions: AgentJournalSubmission[]) { // The mutation reads only `journal.submissions`, `journal.resolveDispatch` and `fence`. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all three are supplied here; the rest of the host session is unreachable from this mutation. const session = { journal, fence: FENCE } as unknown as ReleaseSession - const publish = vi.fn() - const context: ReleaseContext = { sessions: new Map([['s-1', session]]), publish } - return { context, resolved, publish, journal } + const context: ReleaseContext = { sessions: new Map([['s-1', session]]) } + return { context, resolved, journal } } describe('releasing dispatches the provider can no longer answer', () => { @@ -45,7 +44,7 @@ describe('releasing dispatches the provider can no longer answer', () => { const live = [submission({})] // POSITIVE CONTROL: this is the latch being dissolved. expect(projectStructuredAgentSessionStatus([], live, FENCE)).toBe('working') - const { context, resolved, publish } = contextWith(live) + const { context, resolved } = contextWith(live) await releaseStructuredAgentSessionUnansweredDispatches(context, { sessionId: 's-1', @@ -62,14 +61,13 @@ describe('releasing dispatches the provider can no longer answer', () => { recovered: true } ]) - expect(publish).toHaveBeenCalledOnce() expect(projectStructuredAgentSessionStatus([], [submission({ recovered: true })], FENCE)).toBe( 'idle' ) }) it('never touches a pending send, whose dispatch may still be in flight', async () => { - const { context, resolved, publish } = contextWith([ + const { context, resolved } = contextWith([ submission({ clientMessageId: 'm-2', dispatchState: 'pending', reason: null }) ]) @@ -79,7 +77,6 @@ describe('releasing dispatches the provider can no longer answer', () => { }) expect(resolved).toEqual([]) - expect(publish).not.toHaveBeenCalled() }) it('leaves an already recovered unknown alone', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 022343d9407..5894defc483 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -176,7 +176,6 @@ export function runStructuredConversationCommand( }, { fence: ctx.fence } ) - ctx.publish() try { error = ( await ctx.adapter.compact({ @@ -212,7 +211,6 @@ export function runStructuredConversationCommand( conversationCommand: matching()! } }) - ctx.publish() }) }) ).error @@ -223,7 +221,6 @@ export function runStructuredConversationCommand( { kind: 'status', text: 'Compaction completion is unconfirmed.' }, { fence: ctx.fence } ) - ctx.publish() throw cause } await ctx.journal.appendItem( @@ -231,7 +228,6 @@ export function runStructuredConversationCommand( { kind: 'status', text: error ?? 'Conversation compacted.' }, { fence: ctx.fence } ) - ctx.publish() } const completed = { ...prepared, From 19d472fad826790fbc8eb2d33f171bfd1ea1ffc0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:27:58 -0700 Subject: [PATCH 013/387] fix(native-chat): report a chat's owner from its record, not its running agent (#22808) * fix(native-chat): report a chat's owner from its record, not its running agent Released desktop clients gate worktree activation on agentSession.handoffStatus and count a chat tab as claimed only when the owner is `native`. The host answered `native` only for a live lease, and threw not_attached for a chat idle release had forgotten, so a chat at rest (idle-released, or restored after a restart) blocked its whole worktree from activating. The answer now comes from the record store for any record this host supports: the owner is the lease's runtime kind whatever its liveness, and manual recovery still answers `none`. With no map entry needed and nothing to wait for, the serialized read that kept a mid-start chat's answer honest goes too. * test(native-chat): pin the two owner answers that still refuse to vouch With liveness gone, the manual-recovery branch and the unsupported-record refusal are the only paths that keep a chat from answering native; neither had a test. * refactor(native-chat): say plainly why an unsupported chat reports no owner * test(native-chat): say what a blocked activation gate actually skips --- .../structured-agent-session-host.test.ts | 26 ++- .../structured-agent-session-host.ts | 14 +- .../structured-agent-session-owner-status.ts | 19 ++- ...-chat-owner-status-activation.unit.test.ts | 153 ++++++++++++++++++ 4 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/structured-chat-owner-status-activation.unit.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 62f88672342..3c32989a4f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -547,12 +547,13 @@ describe('restart', () => { * them. Every lease loads unreconciled, so this is the state that decides * whether a persisted session is reachable at all. */ async function reboot( - probeOwner: (record: AgentSessionRecord) => Promise + probeOwner: (record: AgentSessionRecord) => Promise, + adapterOverrides: Partial = {} ) { store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, - adapter: adapter(), + adapter: { ...adapter(), ...adapterOverrides }, journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-b', @@ -633,14 +634,14 @@ describe('restart', () => { handoffStage: null, handoffOperationId: null }) - await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle', stage: null }) }) - it('answers the owner status of a starting chat once its start settles', async () => { + it('answers native for a chat whose start is still in flight', async () => { await attach() await reboot(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() @@ -658,11 +659,26 @@ describe('restart', () => { const hold = host.hold(SESSION, 'surface-1') await started.promise + const claimMidStart = store.getRecord(SESSION)?.lease.claimStatus const status = host.handoffStatus(SESSION) release.resolve() await hold - await expect(status).resolves.toMatchObject({ owner: 'native', stage: null }) + // Mid-start the lease is only reserved; ownership does not wait for the agent. + expect(claimMidStart).toBe('reserved') + expect(status).toMatchObject({ owner: 'native' }) + }) + + it('vouches for no owner of a chat in manual recovery or one this host cannot run', async () => { + await attach() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: 'manual-recovery' } + })) + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'none', phase: 'failed' }) + + await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false }) + expect(() => host.handoffStatus(SESSION)).toThrow('structured_agent_session_unsupported') }) it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 91351f05fca..47a6d509ea8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -273,17 +273,9 @@ export class StructuredAgentSessionHost { commands: this.deps.adapter.readCommands?.(sessionId) }) - async handoffStatus(sessionId: string): Promise { - this.requireSession(sessionId) - // Queued behind an in-flight attach, so a starting chat answers with its settled owner. - return this.serialize(sessionId, async () => { - const record = this.deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return structuredAgentSessionOwnerStatus(record) - }) - } + /** From the record store, never the session map: an idle-released chat has no map entry. */ + handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => + structuredAgentSessionOwnerStatus(this.deps, sessionId) history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => this.backgroundTasks.history(request) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index 890b8f34fee..d29c2dacc51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -1,11 +1,22 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' /** The `agentSession.handoffStatus` answer. Released desktop clients gate worktree activation on - * `owner`, so the method outlives the terminal handoff it was named for. */ + * `owner`, so the method outlives the terminal handoff it was named for. It reports ownership, not + * liveness: a chat whose agent is stopped, idle-released or still starting is owned all the same. */ export function structuredAgentSessionOwnerStatus( - record: AgentSessionRecord + deps: Pick, + sessionId: string ): AgentSessionHandoffStatus { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + // Same refusal as reveal: a host that cannot run this chat vouches for no owner. + if (!adapterSupportsRecord(deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } const { handoffStage: stage, handoffOperationId: operationId } = record.lease if (stage === 'manual-recovery') { return { @@ -21,7 +32,7 @@ export function structuredAgentSessionOwnerStatus( } } return { - owner: record.lease.claimStatus === 'live' && record.lease.ownerProcess ? 'native' : 'none', + owner: 'native', direction: stage ? 'to-native' : null, phase: stage ? 'switching' : 'idle', stage, diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts new file mode 100644 index 00000000000..f2c7cbd39d4 --- /dev/null +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -0,0 +1,153 @@ +// A chat whose agent is not running still owns its conversation. Released desktop clients gate +// worktree activation on the host's owner answer, so a chat at rest that answered anything but +// `native` blocked the gate, which then skips adopting and resuming the worktree's paneless agents. +// This drives the real host's answer through the desktop's activation gate for the two ways a chat +// comes to rest. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store' +import { useAppStore } from '../../src/renderer/src/store' +import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/worktree-agent-activation-gate' +import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' + +const WORKTREE = 'repo-1::/workspace/repo' +const SURFACE = 'desktop-chat:1' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let closeSession: Mock> + +function openHost(): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + releaseGraceMs: 5, + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) +} + +/** The desktop's runtime bridge, answering the two reads the gate makes from the real host. */ +function serveDesktopRuntime(): void { + const tabs: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE, + publicationEpoch: 'owner-status-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: `structured-agent-session-${SESSION}`, + title: 'Codex Chat', + sessionId: SESSION, + agent: 'codex', + isActive: false + } + ] + } + const call = async ({ method, params }: { method: string; params: { sessionId?: string } }) => { + if (method === 'session.tabs.list') { + return { ok: true, result: tabs } + } + if (method === 'agentSession.handoffStatus') { + try { + return { ok: true, result: host.handoffStatus(params.sessionId ?? '') } + } catch (error) { + return { ok: false, error: { code: String(error), message: String(error) } } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + } + vi.stubGlobal('window', { api: { runtime: { call } } }) +} + +function activate(): ReturnType { + return runWorktreeAgentActivationGate(WORKTREE, { + getState: () => useAppStore.getState(), + listSessions: async () => [], + listSurfaceOwners: async () => null, + hasStructuredSession: readWorktreeStructuredActivationInventory, + resume: () => 0 + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-owner-status-')) + resetHostTestOperationIds() + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + serveDesktopRuntime() +}) + +afterEach(async () => { + vi.unstubAllGlobals() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat at rest keeps its worktree activatable', () => { + it('after idle release forgot the session', async () => { + await host.hold(SESSION, SURFACE) + host.release(SESSION, SURFACE) + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + expect(await activate()).toBe('structured') + }) + + it('after an app restart restored it for reading', async () => { + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() + await host.restoreReadableSessions() + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null + }) + + expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + expect(await activate()).toBe('structured') + }) +}) From e785cacb67ac0fbc69cc9aa41ddee3af6fa08ce3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:28:22 -0700 Subject: [PATCH 014/387] fix(worktrees): keep failed orphan cleanup retryable (#22409) Co-authored-by: SiinXu --- .../ipc/worktrees-removal-recovery.test.ts | 52 ++++++ .../remove-registered-local-worktree.ts | 36 +--- src/main/local-orphaned-worktree-cleanup.ts | 39 +++++ src/main/local-worktree-filesystem.test.ts | 17 +- src/main/local-worktree-filesystem.ts | 47 ++++-- ...removal-and-reconciliation-part-04.spec.ts | 158 +++++++++++++++++- ...ntime-registered-local-worktree-removal.ts | 41 +---- src/main/worktree-removal-safety.test.ts | 13 +- 8 files changed, 318 insertions(+), 85 deletions(-) create mode 100644 src/main/local-orphaned-worktree-cleanup.ts diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index cfe962cc09a..fc372ddc81f 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -279,6 +279,58 @@ describe('registerWorktreeHandlers', () => { }) }) + it.each(['unproven', 'removal-fails'] as const)( + 'keeps desktop orphan cleanup retryable when the directory is %s', + async (mode) => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-orphan-retention-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const worktreeId = `repo-1::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + if (mode === 'removal-fails') { + const adminPath = join(repoPath, '.git', 'worktrees', 'orphan') + await mkdir(adminPath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminPath}\n`) + await writeFile(join(adminPath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + } + const repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + mockKnownFeatureWorktree(orphanPath, repoPath) + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockRejectedValue( + Object.assign(new Error('Git remove failed'), { + stderr: `fatal: '${orphanPath}' is not a working tree` + }) + ) + const finish = vi.fn().mockResolvedValue(undefined) + runtimeStub.acquireFileWatcherRemoval.mockResolvedValue({ finish }) + const removePath = vi + .spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + .mockRejectedValue(new Error('injected removal failure')) + try { + await expect(handlers['worktrees:remove'](null, { worktreeId })).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['worktree', 'prune'], + expect.anything() + ) + expect(finish).toHaveBeenCalledWith(false) + expect(removePath).toHaveBeenCalledTimes(mode === 'removal-fails' ? 1 : 0) + await rm(orphanPath, { recursive: true, force: true }) + await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({}) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(finish).toHaveBeenLastCalledWith(true) + } finally { + removePath.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + } + ) + it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => { setPlatform('win32') const parentDir = await mkdtemp(join(tmpdir(), 'orca-ipc-long-path-')) diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index c22e6b0ad96..5a36199c8e3 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -11,17 +11,10 @@ import { } from '../../../git/worktree' import { gitExecFileAsync } from '../../../git/runner' import { getWorktreeSharedLinkPaths } from '../../../git/worktree-shared-directories' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../../../local-worktree-filesystem' +import { cleanupLocalOrphanedWorktreeDirectory } from '../../../local-orphaned-worktree-cleanup' import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-removal-recovery' import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' -import { - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree -} from '../../../worktree-removal-safety' +import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../../../worktree-removal-home-guard' import { cleanupUnusedWorktreePushTargetRemote, @@ -162,25 +155,12 @@ export async function removeRegisteredLocalWorktree( console.warn( `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - CLIENT_REMOVAL_HOME, - access.statPath, - access.readPath - ) - ) { - await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } + await cleanupLocalOrphanedWorktreeDirectory( + repo.path, + canonicalWorktreePath, + localWorktreeGitOptions, + (path) => runtime.closeFileWatchersForRemoval(path) + ) // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, diff --git a/src/main/local-orphaned-worktree-cleanup.ts b/src/main/local-orphaned-worktree-cleanup.ts new file mode 100644 index 00000000000..9ed666e5021 --- /dev/null +++ b/src/main/local-orphaned-worktree-cleanup.ts @@ -0,0 +1,39 @@ +import type { LocalWorktreeFilesystemOptions } from './local-worktree-filesystem' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from './local-worktree-filesystem' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + isWorktreePathMissing, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE +} from './worktree-removal-safety' +import { CLIENT_REMOVAL_HOME } from './worktree-removal-home-guard' + +export async function cleanupLocalOrphanedWorktreeDirectory( + repoPath: string, + path: string, + options: LocalWorktreeFilesystemOptions, + closeWatchers: (path: string) => Promise +): Promise { + const access = getLocalWorktreePathAccess(options) + const runtimePath = toLocalWorktreeRuntimePath(path, options) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + runtimePath, + toLocalWorktreeRuntimePath(repoPath, options), + CLIENT_REMOVAL_HOME, + access.statPath, + access.readPath + ) + ) { + await closeWatchers(path) + await removeLocalWorktreePath(path, options).catch(() => {}) + } else { + console.warn(`[worktrees] Refusing recursive cleanup for unproven worktree directory: ${path}`) + } + if (!(await isWorktreePathMissing(runtimePath, access.statPath))) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } +} diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index c9e97f72e90..4bb4f182bdd 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -30,12 +30,12 @@ function completeExecFile(stdout = ''): void { runProcessMock.mockResolvedValue({ code: 0, signal: null, stdout, stderr: '', timedOut: false }) } -function failExecFile(exitCode: number): void { +function failExecFile(exitCode: number, stderr = 'missing'): void { runProcessMock.mockResolvedValue({ code: exitCode, signal: null, stdout: '', - stderr: 'missing', + stderr, timedOut: false }) } @@ -171,7 +171,7 @@ describe('local worktree filesystem runtime access', () => { it('uses the selected WSL distro for stat, read, and removal on Windows', async () => { await withPlatform('win32', async () => { - completeExecFile('file') + completeExecFile('regular file') const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) await expect(access.statPath('/home/me/repo/.git')).resolves.toEqual({ type: 'file' }) @@ -220,7 +220,7 @@ describe('local worktree filesystem runtime access', () => { it('reports missing WSL stat targets with an ENOENT-shaped error', async () => { await withPlatform('win32', async () => { - failExecFile(2) + failExecFile(1, "stat: cannot statx '/mnt/c/repo/missing/.git': No such file or directory\n") const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) await expect(access.statPath('/mnt/c/repo/missing/.git')).rejects.toMatchObject({ @@ -228,4 +228,13 @@ describe('local worktree filesystem runtime access', () => { }) }) }) + + it('does not treat an unreadable WSL stat target as missing', async () => { + await withPlatform('win32', async () => { + failExecFile(1, "stat: cannot statx '/mnt/c/repo/locked': Permission denied\n") + const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) + + await expect(access.statPath('/mnt/c/repo/locked')).rejects.toThrow('Permission denied') + }) + }) }) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 1078b1f50bc..23861c71a87 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -18,8 +18,6 @@ type LocalWorktreePathAccess = { } const WSL_FILE_OPERATION_TIMEOUT_MS = 30_000 -/** The stat probe's explicit "missing path" branch. */ -const WSL_MISSING_PATH_EXIT_CODE = 2 function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolean { return process.platform === 'win32' && !!options.wslDistro?.trim() @@ -48,17 +46,23 @@ async function runWslCommand(distro: string, command: string): Promise { } if (result.code !== 0) { throw Object.assign(new Error(result.stderr.trim() || `wsl.exe exited ${result.code}`), { - exitCode: result.code + exitCode: result.code, + stderr: result.stderr }) } return result.stdout } function isWslMissingPathError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('exitCode' in error)) { + return false + } + const stderr = 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '' return ( - typeof error === 'object' && - error !== null && - (error as { exitCode?: unknown }).exitCode === WSL_MISSING_PATH_EXIT_CODE + error.exitCode === 1 && + /^stat: cannot stat(?:x)? [\s\S]+: (?:No such file or directory|Not a directory)\r?\n?$/.test( + stderr + ) ) } @@ -83,19 +87,26 @@ export function getLocalWorktreePathAccess( return { statPath: async (path) => { const target = quotePosixShell(toLinuxPath(path)) - const stdout = await runWslCommand( - distro, - [ - `target=${target}`, - 'if [ -L "$target" ]; then printf symlink; elif [ -f "$target" ]; then printf file; elif [ -d "$target" ]; then printf directory; else exit 2; fi' - ].join('\n') - ).catch((error) => { - if (isWslMissingPathError(error)) { - throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' }) + // Shell file tests conflate permission failures with absence; stat preserves the reason. + const stdout = await runWslCommand(distro, `LC_ALL=C stat -c %F -- ${target}`).catch( + (error: unknown) => { + if (isWslMissingPathError(error)) { + throw Object.assign(new Error(`missing ${path}`), { code: 'ENOENT' }) + } + throw error } - throw error - }) - return { type: stdout.trim() } + ) + const kind = stdout.trim() + return { + type: + kind === 'symbolic link' + ? 'symlink' + : kind === 'regular file' || kind === 'regular empty file' + ? 'file' + : kind === 'directory' + ? 'directory' + : 'other' + } }, readPath: async (path) => { const target = quotePosixShell(toLinuxPath(path)) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts index 78c4f072bb3..10cf9b4fbb9 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts @@ -14,14 +14,18 @@ import { listWorktrees, listWorktreesStrict, lstat, + localWorktreeFilesystem, + mkdir, mkdtemp, + restoreLocalWatcherAfterFailedRemovalMock, registerSshGitProvider, removeWorktree, removeWorktreeLinkedPathsMock, rm, runHook, tmpdir, - unregisterSshGitProvider + unregisterSshGitProvider, + writeFile } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { @@ -344,6 +348,158 @@ describe('OrcaRuntimeService', () => { expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) }) + it('retains metadata when an unproven orphan directory survives cleanup', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-retention-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const worktreeId = `${TEST_REPO_ID}::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) + const registeredWorktree = { + path: orphanPath, + head: 'abc', + branch: 'feature/orphan', + isBare: false, + isMainWorktree: false + } + vi.mocked(listWorktrees).mockResolvedValue([registeredWorktree]) + vi.mocked(listWorktreesStrict).mockResolvedValue([registeredWorktree]) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${orphanPath}' is not a working tree` + }) + ) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + ) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + const pruneCallsBefore = gitSpy.mock.calls.filter(([args]) => args[0] === 'worktree').length + + try { + await expect(runtime.removeManagedWorktree(worktreeId)).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(removeWorktreeMeta).not.toHaveBeenCalled() + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(orphanPath) + expect(gitSpy.mock.calls.filter(([args]) => args[0] === 'worktree')).toHaveLength( + pruneCallsBefore + ) + + await rm(orphanPath, { recursive: true, force: true }) + vi.mocked(listWorktrees).mockResolvedValue([]) + await expect(runtime.removeManagedWorktree(worktreeId)).resolves.toEqual({}) + expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + } finally { + gitSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('retains metadata when proven orphan cleanup cannot remove the directory', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-removal-failure-')) + const repoPath = join(parentDir, 'repo') + const orphanPath = join(parentDir, 'orphan') + const adminWorktreePath = join(repoPath, '.git', 'worktrees', 'orphan') + const worktreeId = `${TEST_REPO_ID}::${orphanPath}` + await mkdir(orphanPath, { recursive: true }) + await mkdir(adminWorktreePath, { recursive: true }) + await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) + await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(worktreeId) + const runtimeStoreWithRepoPath = { + ...runtimeStore, + getRepos: () => [ + { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ], + getRepo: (id: string) => + id === TEST_REPO_ID + ? { + id: TEST_REPO_ID, + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + : undefined + } + const runtime = createWorktreeRemovalRuntime(runtimeStoreWithRepoPath) + const registeredWorktree = { + path: orphanPath, + head: 'abc', + branch: 'feature/orphan', + isBare: false, + isMainWorktree: false + } + vi.mocked(listWorktrees).mockResolvedValue([registeredWorktree]) + vi.mocked(listWorktreesStrict).mockResolvedValue([registeredWorktree]) + vi.mocked(removeWorktree).mockRejectedValue( + Object.assign(new Error('git worktree remove failed'), { + stderr: `fatal: '${orphanPath}' is not a working tree` + }) + ) + vi.mocked(assertWorktreeCleanForRemoval).mockRejectedValue( + Object.assign(new Error('status failed'), { + stderr: 'fatal: not a git repository (or any of the parent directories): .git\n' + }) + ) + const removePathSpy = vi + .spyOn(localWorktreeFilesystem, 'removeLocalWorktreePath') + .mockRejectedValue(new Error('injected removal failure')) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + + try { + await expect(runtime.removeManagedWorktree(worktreeId)).rejects.toThrow( + 'Worktree is no longer registered with Git but its directory remains.' + ) + await expect(lstat(orphanPath)).resolves.toBeTruthy() + expect(removePathSpy).toHaveBeenCalledWith(orphanPath, {}) + expect(removeWorktreeMeta).not.toHaveBeenCalled() + expect(gitSpy).not.toHaveBeenCalledWith(['worktree', 'prune'], expect.anything()) + } finally { + removePathSpy.mockRestore() + gitSpy.mockRestore() + await rm(parentDir, { recursive: true, force: true }) + } + }) + it('drops the bounded scan cache when orphan-cleanup removal completes', async () => { const runtime = createWorktreeRemovalRuntime() vi.mocked(getEffectiveHooks).mockReturnValue(null) diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index e6658b73ef2..011d30207fe 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -20,16 +20,9 @@ import { isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from '../ipc/worktree-logic' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../local-worktree-filesystem' +import { cleanupLocalOrphanedWorktreeDirectory } from '../local-orphaned-worktree-cleanup' import { recoverLocalWindowsWorktreeRemoval } from '../local-worktree-removal-recovery' -import { - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree -} from '../worktree-removal-safety' +import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' @@ -160,7 +153,12 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { removalResult = recovered completed = true } else if (isOrphanedWorktreeError(error)) { - await cleanupOrphanedDirectory(repo, canonicalPath, localOptions, args.closeWatchers) + await cleanupLocalOrphanedWorktreeDirectory( + repo.path, + canonicalPath, + localOptions, + args.closeWatchers + ) await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, ...localOptions }).catch( () => {} ) @@ -190,29 +188,6 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } } -async function cleanupOrphanedDirectory( - repo: Repo, - path: string, - options: LocalProjectWorktreeGitOptions, - closeWatchers: (path: string) => Promise -): Promise { - const access = getLocalWorktreePathAccess(options) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(path, options), - toLocalWorktreeRuntimePath(repo.path, options), - CLIENT_REMOVAL_HOME, - access.statPath, - access.readPath - ) - ) { - await closeWatchers(path) - await removeLocalWorktreePath(path, options).catch(() => {}) - } else { - console.warn(`[worktrees] Refusing recursive cleanup for unproven worktree directory: ${path}`) - } -} - async function cleanupPushTarget( args: Parameters[0] ): Promise { diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index 9aa9775de44..d32289a5fc7 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -6,7 +6,8 @@ import { canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, getRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath + isDangerousWorktreeRemovalPath, + isWorktreePathMissing } from './worktree-removal-safety' import { CLIENT_REMOVAL_HOME, executionHostRemovalHome } from './worktree-removal-home-guard' @@ -349,6 +350,16 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { }) }) +describe('isWorktreePathMissing', () => { + it('does not treat an unreadable path as absent', async () => { + await expect( + isWorktreePathMissing('/workspaces/locked', async () => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + ).resolves.toBe(false) + }) +}) + describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { const repo = { path: '/repos/main' } const ownedMeta = { orcaCreatedAt: 1, orcaCreationSource: 'runtime' as const } From 16784c1a671f4a84a7993658c1387dade815f362 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:31:29 -0700 Subject: [PATCH 015/387] fix(native-chat): name a chat write by its target, not the owner generation (#22812) * refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff ()." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. --------- Co-authored-by: Claude --- docs/reference/remote-wire-compatibility.md | 6 +- .../agent-launch-executor.test.ts | 2 +- .../agent-launch-surface-factories.ts | 4 +- ...ured-agent-session-attach-orchestration.ts | 11 +-- .../structured-agent-session-host-types.ts | 4 - ...ctured-agent-session-mutation-admission.ts | 19 ++-- ...ctured-agent-session-refusal-retry.test.ts | 21 ++--- ...ured-agent-session-restart-continuation.ts | 3 +- .../structured-agent-session-rewind.test.ts | 8 +- ...red-agent-session-send-preparation.test.ts | 92 ++++++++++++++++++- ...ructured-agent-session-send-preparation.ts | 26 +----- ...session-send-restarts-failed-start.test.ts | 4 +- .../structured-agent-session-send.test.ts | 39 +------- .../structured-agent-session-turns.ts | 2 +- .../structured-conversation-command.test.ts | 9 +- .../agent-session-operation-admission.ts | 2 +- ...gent-session-surface-release-transition.ts | 4 +- ...rca-runtime-get-pty-record-for-pane-key.ts | 2 +- ...tured-worker-direct-mailbox-target.test.ts | 2 +- .../rpc/methods/agent-launch-replay.ts | 4 +- .../agent-session-mutation-envelope.test.ts | 19 ++-- src/shared/agent-session-mutation-envelope.ts | 27 ++---- ...ss-version-agent-session-wire.unit.test.ts | 8 +- 23 files changed, 156 insertions(+), 162 deletions(-) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index d72f93770c5..b2bcb3c40f4 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -181,8 +181,10 @@ hand-written. It covers the three skews that surface can fail on: - a new client against the old dispatcher always gets an answer rather than silence, and `method_not_found` for every method that release does not register, so the absence is visible during negotiation instead of by calling; -- a cursor survives a host restart: the client's fence is refused as stale with the live - one attached, and resuming from the held cursor replays only what it missed. +- a cursor survives a host restart: a reattach at the client's fence is refused as stale + with the live one attached, a write still carrying that fence is delivered (writes are + named by their target, and every released client still sends a fence), and resuming from + the held cursor replays only what it missed. Run it with: diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index a95e71a30ef..e7270287fb5 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -265,7 +265,7 @@ describe('the prompt receipt', () => { 'createStructuredSession', 'deliverStructuredPrompt' ]) - // The send must name the lease the create was admitted under, not one re-read later. + // The send carries the create's own fence; nothing re-reads the session for it. expect(h.deliverStructuredPrompt).toHaveBeenCalledWith({ sessionId: 'sess-1', fence: 4, diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 11d72274bd4..14d2d4b2797 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -64,8 +64,8 @@ export type AgentLaunchSurfaceFactory = { deliverTerminalPrompt?(args: { handle: string; prompt: AgentLaunchPrompt }): Promise } -/** `fence` is carried out of the create because a send must name the lease it was admitted against, - * and re-reading it later would read whatever fence the session has by then. */ +/** `fence` is the lease the create was admitted at, carried so the launch prompt's send can fill its + * envelope without re-reading the session; the host does not check a write's fence. */ export type AgentLaunchStructuredSurface = { sessionId: string handle: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 5482826d072..f39a183b0f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -21,7 +21,6 @@ import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' -import { isResumableStructuredAgentSessionRecord } from './structured-agent-session-resume-eligibility' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' @@ -135,13 +134,6 @@ async function runAttach( // attach makes it the session's; any other exit closes it with whatever the child queued. const attemptSink = context.runtimeState.mintEventSink(sessionId) let attemptSinkAdopted = false - // A lease handed back cleanly is what a resume replaces. A writer current as of that owner is - // rebased onto the fence this attach publishes, since the restart is the only thing that moved it. - const released = context.deps.store.getRecord(sessionId) - const resumedFromFence = - released && isResumableStructuredAgentSessionRecord(released) - ? released.lease.runtimeFence - : undefined const attached = stampFailedCreateOwnerVerdict( context.deps.store, callerKey, @@ -227,8 +219,7 @@ async function runAttach( providerChildPhase: acquiredOwner ? providerChildPhase : (previous?.providerChildPhase ?? 'ready'), - acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null, - resumedFromFence: acquiredOwner ? resumedFromFence : previous?.resumedFromFence + acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null }) await recoverStructuredRewind( context.deps.store, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index bf73befc708..fd2c911c74e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -45,10 +45,6 @@ export type StructuredAgentSessionHostSession = { owesProviderChildWindDown?: boolean /** Exact adapter acquisition behind `hasProviderChild`; retained after exit to fence recovery. */ acquisitionGeneration: string | null - /** The fence of the released owner this child replaced, when it was resumed into a lease handed - * back cleanly. A writer current as of that owner is admitted at `fence`: the restart is the - * only thing that moved it. Absent for a create or a journal restored for reading. */ - resumedFromFence?: number } export type StructuredAgentSessionHostDeps = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 04001450621..62be8900159 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -5,8 +5,8 @@ // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's // answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease and -// fence checked — against the lease as it stands once the owner is there. +// session an owner, and only after that are the row placed and the lease +// checked — against the lease as it stands once the owner is there. import { admitAgentSessionMutation, @@ -44,7 +44,7 @@ export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { } export type AgentSessionMutationSessionPreparation = - | { ok: true; envelope: AgentSessionMutationEnvelope } + | { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } export type AgentSessionMutationRequest = { @@ -56,8 +56,7 @@ export type AgentSessionMutationRequest = { /** Journal of the attached session, read after `prepareSession`; absent when this host holds none. */ journal: () => AgentSessionJournal | undefined /** Between the ledger's answer and the lease check, for a call that may first have to make the - * session ready for itself. Answers with the envelope to admit — the caller's, or one moved - * onto a fence the preparation itself published — or with the refusal that ends the call. */ + * session ready for itself. Answers with the refusal that ends the call, if any. */ prepareSession?: ( ledger: Exclude, record: AgentSessionRecord @@ -71,8 +70,7 @@ export type AgentSessionMutationRequest = { export async function admitAndRunAgentSessionMutation( request: AgentSessionMutationRequest ): Promise> { - const { plan } = request - let { envelope } = request + const { plan, envelope } = request const hostFingerprint = computeAgentSessionPayloadFingerprint({ method: plan.method, sessionId: envelope.sessionId, @@ -98,7 +96,6 @@ export async function admitAndRunAgentSessionMutation( if (!prepared.ok) { return prepared } - envelope = prepared.envelope } } const journal = request.journal() @@ -137,9 +134,9 @@ export async function admitAndRunAgentSessionMutation( return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } } // Nothing durable landed, so this id is about to run for the first time. A - // refused call leaves its ledger row behind, and replaying past the lease and - // the fence would let a resend act under an owner that has since changed — so - // a first run pays the full admission price either way. + // refused call leaves its ledger row behind, and replaying past the lease + // would let a resend act with no live owner — so a first run pays the full + // admission price either way. const rerun = admitAgentSessionMutation({ envelope, hostFingerprint, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 566156e096f..87f4f9e3d65 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -231,7 +231,10 @@ const UNREACHABLE = new Set([ // Send reconstructs doubt from its global tombstone instead of refusing it. 'agentSession.send:agent_session_operation_unknown', // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed' + 'agentSession.setOption:agent_session_owner_restart_failed', + // A write names its target, not an owner generation; only an attach compares fences. + 'agentSession.setOption:agent_session_checkpoint_stale', + 'agentSession.send:agent_session_checkpoint_stale' ]) describe('agentSessionRefusalOperationState host oracle', () => { @@ -242,20 +245,8 @@ describe('agentSessionRefusalOperationState host oracle', () => { const stale = await createHarness() for (const method of METHODS) { - const spec = { - method, - operationId: operationId(), - expectedRuntimeFence: 99 - } - record( - await assertHostAgreement(stale, spec, 'agent_session_checkpoint_stale', async () => ({ - harness: stale, - spec: { - ...spec, - expectedRuntimeFence: stale.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - } - })) - ) + const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } + await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) } expect(stale.setOption).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 0dd2337f4ab..c025c0dc46a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -146,8 +146,7 @@ export function restartContinuationBody(marker: AgentSessionResumeMarker): Agent } } -/** The fence is read AFTER the reconnect: reattaching mints a new one, and the pre-reconnect value - * would be refused by the mutation admission. */ +/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ export function restartContinuationEnvelope( sessionId: string, fence: number, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 19ae18dd5f4..b82248cb06f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -229,14 +229,8 @@ describe('host rewind', () => { expect(recoverRewind).toHaveBeenCalledTimes(2) expect(rewind).toHaveBeenCalledTimes(1) }) - it('fences stale owners and the second of two concurrent rewinds', async () => { + it('refuses the second of two concurrent rewinds by the epoch it targets', async () => { const target = await seed() - const stale = params(target) - stale.envelope.expectedRuntimeFence++ - expect(await host.rewind(caller, stale)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) let finish!: () => void rewind.mockImplementation( () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f48af3b4d4b..c23083fa687 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -6,7 +6,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -317,7 +320,7 @@ describe('a send with no live owner', () => { expect(dispatch).not.toHaveBeenCalled() }) - it('rebases a send that arrives after the restart has already claimed the lease', async () => { + it('admits a send that arrives after the restart has already claimed the lease', async () => { await loseOwner() const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 let claimed = () => {} @@ -676,3 +679,88 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() }) }) + +// A pane keeps the fence of the last frame it read. An idle release and the restart after it +// each move the lease, so that fence can be several generations behind the one a write lands on. +describe('a write fenced to an owner the pane has not seen replaced', () => { + it('delivers a send fenced to the owner an idle release retired', async () => { + const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + await loseOwner() + const params = sendParams('after the release') + params.envelope.expectedRuntimeFence = seenFence + + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + + expect(acquire).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) + }) + + // Clients resend a refused message when the fence they hold moves, and a refused restart leaves + // no ledger row, so a frame carrying each failed attempt's fence would resend it forever. + it("keeps the pane's fence on the row a failed restart publishes", async () => { + const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const frames: AgentSessionSubscribeEvent[] = [] + host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) + await loseOwner() + acquire.mockRejectedValueOnce(new Error('Not signed in')) + const subscribed = frames.length + + expect(await host.send(CALLER, sendParams('while signed out'))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_owner_restart_failed' } + }) + + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) + const published = frames.slice(subscribed) + expect(published.length).toBeGreaterThan(0) + for (const frame of published) { + expect(frame).toMatchObject({ fence: seenFence }) + } + }) + + it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { + await loseOwner() + const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + let claimed = () => {} + let release = () => {} + const claim = new Promise((resolve) => (claimed = resolve)) + const spawn = new Promise((resolve) => (release = resolve)) + acquire.mockImplementationOnce(async (input) => { + claimed() + await spawn + return spawnChild(input) + }) + + const first = host.send(CALLER, sendParams('starts the agent')) + await claim + const cancelFields = { turnId: 'turn-1' } + const stop = host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: lostFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: cancelFields + }) + }, + ...cancelFields + }) + const late = sendParams('typed during the start') + late.envelope.expectedRuntimeFence = lostFence - 1 + const second = host.send(CALLER, late) + release() + + expect(await first).toMatchObject({ ok: true }) + expect(await stop).toMatchObject({ ok: true, replayed: false }) + expect(await second).toMatchObject({ ok: true, replayed: false }) + expect(acquire).toHaveBeenCalledOnce() + expect(dispatch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index e277889345f..09986f2e129 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -12,13 +12,8 @@ // // The ledger's answer comes first, so a send it already holds a row for restarts nothing: // admission replays or refuses it whoever owns the session now, and a closed session is made -// readable for that, never given a child. Otherwise a child that dies at startup moves the fence, -// the client resends the same message against the new fence, and each replay spawns another -// child that dies the same way. -// -// Running inside the send's serialize is what makes "no child" exact and the fence bookkeeping -// simple: the owner this send (or a hold just ahead of it) replaced is the one the client was -// current as of, so the send is admitted at the fence the restart published. +// readable for that, never given a child. Otherwise each resend of a message whose child died at +// startup would spawn another child that dies the same way. // // A child that has not proven its start is still the owner: the send is admitted against it and // the adapter holds the message until startup lands, or rejects it with the child's own reason @@ -128,7 +123,7 @@ export async function prepareStructuredAgentSessionSend( if (!context.sessions.has(sessionId)) { await context.restoreReadable(sessionId) } - return { ok: true, envelope } + return { ok: true } } if (structuredAgentSessionSendNeedsOwner(context.sessions.get(sessionId), record)) { const refusal = await restartOwnerForSend(context, envelope, record) @@ -136,7 +131,7 @@ export async function prepareStructuredAgentSessionSend( return { ok: false, refusal } } } - return { ok: true, envelope: admitAtResumedFence(context.sessions.get(sessionId), envelope) } + return { ok: true } } /** One restart attempt. Answers with the refusal that ends the send, or null when the send goes @@ -228,16 +223,3 @@ async function recordFailedRestart( context.deps.onEventSinkError?.({ sessionId, error }) } } - -/** A writer current as of the owner this child replaced is current now: the restart was the only - * thing that moved the fence, whether this send ran it or one just ahead of it did. */ -function admitAtResumedFence( - session: StructuredAgentSessionHostSession | undefined, - envelope: AgentSessionMutationEnvelope -): AgentSessionMutationEnvelope { - return session?.hasProviderChild && - session.resumedFromFence !== undefined && - envelope.expectedRuntimeFence === session.resumedFromFence - ? { ...envelope, expectedRuntimeFence: session.fence } - : envelope -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 181b40660f8..e2e2fb81102 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -166,8 +166,8 @@ describe('a send into a published session whose child ended before startup', () await send('hello again', releasedFence) - // The client was current as of the lost owner, so the send is rebased onto the fence the - // resume published and admitted once, with no stale round trip. + // A send still fenced to the lost owner is admitted once against the restarted one, with no + // stale round trip. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).toHaveBeenCalledOnce() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f2d1ab12230..638d8a8679f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -446,24 +446,7 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('refuses a stale fence and hands back the current one', async () => { - const record = await attach() - const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope( - 'agentSession.send', - { body }, - { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } - ), - body - }) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } - }) - }) - - it('reuses a pending send admission after the client refreshes its fence', async () => { + it('admits a send fenced to another generation, and replays it by id once the fence catches up', async () => { const record = await attach() const body = hostTestMessage('add a retry') const params = { @@ -475,26 +458,14 @@ describe('send', () => { body } expect(await host.send(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - expect( - store - .listOperationRows() - .filter((row) => row.operationId === params.envelope.clientOperationId) - ).toEqual([]) - const retry = { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: record?.lease.runtimeFence ?? 1 - } - } - expect(await host.send(CALLER, retry)).toMatchObject({ ok: true, replayed: false, value: { submission: { dispatchState: 'accepted' } } }) + const retry = { + ...params, + envelope: { ...params.envelope, expectedRuntimeFence: record?.lease.runtimeFence ?? 1 } + } expect(await host.send(CALLER, retry)).toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index e8103dac62f..794b4fe8df9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -1,6 +1,6 @@ // The effects behind send / cancel / respond / setOption. // -// Admission (lease, fence, idempotency) has already passed by the time anything +// Admission (writer lease, idempotency) has already passed by the time anything // here runs; these functions own only the journal writes and the adapter call, // in that order. Journal first is deliberate: a crash between the two leaves a // row the next attach settles as `unknown`, whereas the reverse would lose a diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index a261c6dd151..37d8d4ee83d 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -245,14 +245,11 @@ describe('host conversation commands', () => { }) }) - it('rejects stale fences before provider execution', async () => { + it('runs a command whose fence the client has not caught up to', async () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - expect(compact).not.toHaveBeenCalled() + expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) + expect(compact).toHaveBeenCalledTimes(1) }) it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { let finish!: (value: {}) => void diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 49dbab1c90d..e7a4165120e 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -132,7 +132,7 @@ function mutationOperation( } } -/** Admit the ledger row and its lease/fence preconditions in one durable transaction. */ +/** Admit the ledger row and its writer-lease precondition in one durable transaction. */ export function admitAgentSessionMutationOperation( state: AgentSessionStoreState, args: AgentSessionMutationOperationAdmission diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index a6c90d1e01f..b7851709586 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -5,8 +5,8 @@ // lease-owning provider root through the adapter. Its observed exit is sufficient because the // lease follows that root, even when descendants remain `unverifiable`. // -// The fence still moves. A released lease at the old fence would let a mutation a client queued -// against the dead generation land on the next one. +// The fence still moves, so the next owner is a new generation: an attach or settlement still +// holding the stopped owner's fence is refused as stale rather than acting on its successor. import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 56455551349..e26301e379a 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -246,7 +246,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM * The worker's ACTIVE dispatch is preferred when it has one, so peer and coordinator nudges share * one operation-ledger budget and one set of retain rules. A worker BETWEEN dispatches is still * nudged, under a session-scoped budget: the mail is durable, the session is live, and a dispatch - * says nothing about whether delivery is safe — the idle gate and the lease fence do that. + * says nothing about whether delivery is safe — the idle gate and the writer lease do that. */ protected resolveStructuredWorkerDirectMailboxTarget( handle: string diff --git a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts index 4fff2d387d0..cc32156cc87 100644 --- a/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts +++ b/src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts @@ -93,7 +93,7 @@ describe('the mailbox target for direct peer mail to a structured worker', () => // claimed the mailbox — the PTY lane refuses a structured handle outright and this resolver // answered only `dispatch:` addresses. The worker never reacted and the peer waiting on a // reply hung, with nothing logged. A dispatch says nothing about whether delivery is safe; - // the idle gate and the lease fence do, and both still run downstream. + // the idle gate and the writer lease do, and both still run downstream. const handle = registerWorker() installRecord({ runtimeKind: 'native', claimStatus: 'live' }) expect(probe(undefined).instance.probeResolveTarget(handle)).toEqual({ diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index 223d4a56c9e..ec9269f4efe 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -11,8 +11,8 @@ * resolving the caller's worktree selector, because a selector resolution is a live precondition * and a replay must not be able to fail on one: an operation that already ran has an answer, and * re-deciding it against today's world is how a recorded success becomes a fresh refusal the client - * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the lease - * and the fence for that same reason. + * then retries as a second effect. `admitAgentSessionMutation` puts the ledger ahead of the writer + * lease for that same reason. */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index e929d628de5..306450f8a63 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -109,16 +109,15 @@ describe('admitAgentSessionMutation', () => { expect(admission.decision).toBe('replay') }) - it('refuses a stale fence and hands back the current one', () => { - const admission = admitAgentSessionMutation({ - ...base, - envelope: envelope({ expectedRuntimeFence: 3 }), - ledger: ADMIT('f'.repeat(64)) - }) - expect(admission).toMatchObject({ - decision: 'refused', - refusal: { code: 'agent_session_checkpoint_stale', currentFence: 4 } - }) + it('admits a writer whatever fence the client last saw', () => { + for (const expectedRuntimeFence of [3, 5, null]) { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence }), + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission, String(expectedRuntimeFence)).toMatchObject({ decision: 'admit' }) + } }) it('refuses a writer while the lease is unreconciled', () => { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 83fdb65e9ae..fe439c20b08 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -11,10 +11,7 @@ import type { AgentSessionOperationDecision, AgentSessionOperationRow } from './agent-session-operation-ledger' -import { - agentSessionLeaseAdmitsWriter, - isAgentSessionFenceCurrent -} from './agent-session-lease-adjudication' +import { agentSessionLeaseAdmitsWriter } from './agent-session-lease-adjudication' import type { AgentSessionLease } from './agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' @@ -81,10 +78,11 @@ export type AgentSessionMutationAdmission = /** * Fixed order: fingerprint agreement, then the ledger (so a retry replays - * before anything else can refuse it), then the lease, then the fence. Putting - * the ledger ahead of the fence is deliberate — a retry that crossed an owner - * change must still return its recorded answer instead of a stale-checkpoint - * refusal the client would then resend as a second effect. + * before anything else can refuse it), then the lease. + * + * `expectedRuntimeFence` is not checked: each write names its own target (a + * turn, an item revision, an epoch) or is last-writer-wins, so an owner restart + * the client has not seen yet refuses nothing. Older hosts still check it. */ export function admitAgentSessionMutation(input: { envelope: AgentSessionMutationEnvelope @@ -115,19 +113,6 @@ export function admitAgentSessionMutation(input: { if (leaseRefusal) { return { decision: 'refused', refusal: leaseRefusal } } - if ( - envelope.expectedRuntimeFence === null || - !isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence) - ) { - return { - decision: 'refused', - refusal: { - code: 'agent_session_checkpoint_stale', - message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`, - currentFence: lease.runtimeFence - } - } - } return { decision: 'admit', row: ledger.row } } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 0e3a6ae06c9..b038a558517 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -763,15 +763,17 @@ describe('cross-version structured agent sessions', () => { expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) }) - it('refuses a write still fenced to the host generation that died', async () => { + // Every released client still sends the fence it last saw; this host names a write by its + // target and ignores that fence. Only the attach keeps comparing one, which `reattach` pins. + it('delivers a write still fenced to the host generation that died', async () => { const created = await answer('agentSession.create', createIntentParams()) await bootHost('b') const reattached = await reattach(created.fence) expect(reattached.fence).toBeGreaterThan(created.fence) expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } + ok: true, + fence: reattached.fence }) }) }) From 1c2cf120e32fca765b8313def90647c013c9c113 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:35:48 -0700 Subject: [PATCH 016/387] fix: stop process-tree loops (#22411) Based on the report and proposal by @brynnclaw. Co-authored-by: brynnclaw Co-authored-by: Claude Fable 5.1 --- src/shared/process-table-index.test.ts | 160 +++++++++++++++++++++++++ src/shared/process-table-index.ts | 16 ++- 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/shared/process-table-index.test.ts diff --git a/src/shared/process-table-index.test.ts b/src/shared/process-table-index.test.ts new file mode 100644 index 00000000000..61f59ee1c99 --- /dev/null +++ b/src/shared/process-table-index.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildProcessTableIndex, + collectDescendantsFromIndex, + type ProcessIdentityRow +} from './process-table-index' +import { parseStrictProcessTableRows } from './process-table-snapshot' + +function boundedWalk(rows: readonly Row[], rootPid: number) { + const index = buildProcessTableIndex(rows) + const get = index.childrenByPpid.get.bind(index.childrenByPpid) + let lookups = 0 + // A missing cycle guard must fail the test before it can hang or exhaust memory. + const spy = vi.spyOn(index.childrenByPpid, 'get').mockImplementation((pid) => { + if (++lookups > rows.length + 1) { + throw new Error('Descendant walk exceeded the row budget') + } + return get(pid) + }) + try { + const result = collectDescendantsFromIndex(index, rootPid) + expect(result.some((row) => row.pid === rootPid)).toBe(false) + expect(new Set(result.map((row) => row.pid)).size).toBe(result.length) + return result + } finally { + spy.mockRestore() + } +} + +describe('collectDescendantsFromIndex', () => { + it('preserves capture-order stack traversal, depth, row fields and input ownership', () => { + const rows = Object.freeze([ + Object.freeze({ pid: 100, ppid: 1, command: 'shell' }), + Object.freeze({ pid: 200, ppid: 100, command: 'left' }), + Object.freeze({ pid: 201, ppid: 100, command: 'right' }), + Object.freeze({ pid: 300, ppid: 200, command: 'left-first' }), + Object.freeze({ pid: 301, ppid: 200, command: 'left-last' }), + Object.freeze({ pid: 400, ppid: 201, command: 'right-child' }) + ]) + const result = boundedWalk(rows, 100) + expect(result).toEqual([ + { ...rows[2], depth: 1 }, + { ...rows[5], depth: 2 }, + { ...rows[1], depth: 1 }, + { ...rows[4], depth: 2 }, + { ...rows[3], depth: 2 } + ]) + expect(result[0]).not.toBe(rows[2]) + expect(Object.isFrozen(result[0])).toBe(false) + }) + + it('excludes a self-parented root', () => { + expect( + boundedWalk( + [ + { pid: 5, ppid: 5 }, + { pid: 6, ppid: 5 } + ], + 5 + ) + ).toEqual([{ pid: 6, ppid: 5, depth: 1 }]) + }) + + it('excludes the root from a two-node cycle with unique PIDs', () => { + expect( + boundedWalk( + [ + { pid: 5, ppid: 6 }, + { pid: 6, ppid: 5 } + ], + 5 + ) + ).toEqual([{ pid: 6, ppid: 5, depth: 1 }]) + }) + + it('terminates on a reachable two-node cycle with duplicate rows', () => { + const rows = [ + { pid: 9, ppid: 1 }, + { pid: 3, ppid: 9 }, + { pid: 2, ppid: 3 }, + { pid: 3, ppid: 2 } + ] + expect(boundedWalk(rows, 9)).toEqual([ + { pid: 3, ppid: 9, depth: 1 }, + { pid: 2, ppid: 3, depth: 2 } + ]) + }) + + it('terminates on a reachable self-cycle', () => { + expect( + boundedWalk( + [ + { pid: 2, ppid: 1 }, + { pid: 2, ppid: 2 } + ], + 1 + ) + ).toEqual([{ pid: 2, ppid: 1, depth: 1 }]) + }) + + it('never returns a duplicate row that reuses the root PID', () => { + const rows = [ + { pid: 50, ppid: 1 }, + { pid: 60, ppid: 50 }, + { pid: 50, ppid: 60 } + ] + expect(boundedWalk(rows, 50)).toEqual([{ pid: 60, ppid: 50, depth: 1 }]) + }) + + it('ignores disconnected cycles and unrelated components', () => { + const rows = [ + { pid: 2, ppid: 1 }, + { pid: 8, ppid: 9 }, + { pid: 9, ppid: 8 }, + { pid: 20, ppid: 19 } + ] + expect(boundedWalk(rows, 1)).toEqual([{ pid: 2, ppid: 1, depth: 1 }]) + expect(boundedWalk(rows, 99)).toEqual([]) + expect(boundedWalk([], 1)).toEqual([]) + }) + + it('queues each PID once, retaining the first queued duplicate', () => { + const rows = [ + { pid: 2, ppid: 1, marker: 'first' }, + { pid: 2, ppid: 1, marker: 'duplicate' }, + { pid: 3, ppid: 1, marker: 'sibling' }, + { pid: 2, ppid: 3, marker: 'other-parent' }, + { pid: 4, ppid: 2, marker: 'child' } + ] + expect(boundedWalk(rows, 1)).toEqual([ + { ...rows[2], depth: 1 }, + { ...rows[0], depth: 1 }, + { ...rows[4], depth: 2 } + ]) + expect(buildProcessTableIndex(rows).byPid.get(2)).toBe(rows[0]) + }) + + it('walks a deep valid tree without recursion or changing depth', () => { + const rows = Array.from({ length: 10_000 }, (_, index) => ({ pid: index + 2, ppid: index + 1 })) + expect(boundedWalk(rows, 1)).toEqual(rows.map((row, index) => ({ ...row, depth: index + 1 }))) + }) + + it('preserves reverse capture order for a broad valid tree', () => { + const rows = Array.from({ length: 10_000 }, (_, index) => ({ pid: index + 2, ppid: 1 })) + expect(boundedWalk(rows, 1)).toEqual(rows.toReversed().map((row) => ({ ...row, depth: 1 }))) + }) + + it('retains synthetic POSIX host identity fields through a cycle', () => { + const rows = parseStrictProcessTableRows('5 6 5 6 S pts/1 100 bash\n6 5 6 6 S+ pts/1 200 codex') + expect(boundedWalk(rows, 5)).toEqual([{ ...rows[1], depth: 1 }]) + }) + + it('retains synthetic Windows creation markers without treating a PID as an incarnation', () => { + const rows = [ + { pid: 5, ppid: 6, creationTimeMs: 100, name: 'pwsh.exe' }, + { pid: 6, ppid: 5, creationTimeMs: 200, name: 'node.exe' } + ] + expect(boundedWalk(rows, 5)).toEqual([{ ...rows[1], depth: 1 }]) + }) +}) diff --git a/src/shared/process-table-index.ts b/src/shared/process-table-index.ts index dd4d29195af..e8dc308b80b 100644 --- a/src/shared/process-table-index.ts +++ b/src/shared/process-table-index.ts @@ -70,12 +70,24 @@ export function collectDescendantsFromIndex( rootPid: number ): (Row & { depth: number })[] { const descendants: (Row & { depth: number })[] = [] - const stack = (index.childrenByPpid.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) + // PID reuse can make a process snapshot cyclic; queue each PID at most once. + const visited = new Set([rootPid]) + const stack: { row: Row; depth: number }[] = [] + const pushUnvisited = (row: Row, depth: number): void => { + if (visited.has(row.pid)) { + return + } + visited.add(row.pid) + stack.push({ row, depth }) + } + for (const child of index.childrenByPpid.get(rootPid) ?? []) { + pushUnvisited(child, 1) + } while (stack.length > 0) { const { row, depth } = stack.pop()! descendants.push({ ...row, depth }) for (const child of index.childrenByPpid.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) + pushUnvisited(child, depth + 1) } } return descendants From 8846987c99d1e9478a4207484a76fa9db09f149b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:54:11 -0700 Subject: [PATCH 017/387] feat(rate-limits): add Cursor usage tracking (#22633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rate-limits): add Cursor usage tracking ## ELI5 If you use Cursor, Orca now shows how much of your monthly Cursor plan you have used, next to the Claude, Codex and Grok meters, and in Settings → Accounts. It reads the sign-in Cursor already saved on this computer and never changes it. ## What changed Cursor becomes a rate-limit provider like Grok: a status-bar meter (default-on, with its own toggle), a row in the usage roster, and a Settings → Accounts section naming the signed-in account. The credential is read from whichever of three stores has it, first match wins, all read-only: - the macOS login keychain item `cursor-access-token` / `cursor-user`, which is where `cursor-agent` 2026.06+ keeps the session; - `~/.cursor/auth.json` and its platform variants, used by older CLIs; - the Cursor IDE's `state.vscdb` (`cursorAuth/accessToken`), for people who never run the CLI. The keychain entry is the one current CLIs use, and reading only `auth.json` finds nothing on an up-to-date macOS install. A locked keychain cannot mask a readable `auth.json`, and a locked `state.vscdb` cannot mask either. `~/.cursor/cli-config.json` supplies the account's email and display name; it never holds a token. Usage comes from the dashboard route the Cursor web dashboard itself reads, because Cursor documents no individual-user usage API — every documented API is team- or Enterprise-scoped. Per Cursor's pricing docs an individual plan has two pools, Cursor Models and Other Models, both resetting with the billing cycle, plus optional on-demand spend; each becomes a named bucket. The headline percentage prefers `used / limit` over the sibling percentage fields, which are pre-rounded for the dashboard's own copy. Because the route is undocumented the mapping is defensive: an unrecognised payload resolves to `unavailable` and hides the bar rather than publishing a zero that reads as "no usage". Orca never runs `cursor-agent login` and never writes, refreshes or rotates a Cursor credential. An expired token short-circuits to an actionable "run cursor-agent login" instead of spending a request that can only 401 — not a rare case, since `cursor-agent status` still reports `isAuthenticated: true` against a token that expired months ago. ## Why this shape Six open PRs implement this feature and none reads the keychain, so each finds nothing for a large share of users; this takes the auth layer further and keeps what those PRs verified live. The bar is not gated on `cursor-agent` being on PATH, unlike other CLI providers, because an IDE-only session is real usage with no CLI to detect. `readKeychainPassword` moved out of the Claude keychain reader into `src/main/macos-keychain/generic-password.ts` so both providers share one `security(1)` wrapper. It is a byte-for-byte relocation, so Claude's credential path is unchanged; the two child_process allowlists move the entry with it and neither ratchet count changes. Co-authored-by: Preschian Febryantara Co-authored-by: Qwesdy Co-authored-by: ivo922 Co-authored-by: Mihail Vratchanski Co-authored-by: Tauri-EPO Co-authored-by: Raajik <44516546+Raajik@users.noreply.github.com> * test(rate-limits): name the JWT helper's segment type in the Cursor tests The anti-slop gate rejects a bare `object` parameter; the fixtures build a claims record, so say that. * fix(rate-limits): render Cursor's pools and keep its plan total visible Review of the first commit found the meter effectively blank for a healthy account, which the screenshots missed because the only Cursor session on hand had expired and never reached the success path. - The verbose status-bar segment filtered buckets through an allowlist written for Gemini's experimental models, so both Cursor pools were dropped and the fallback needed a `session` window Cursor never reports. A signed-in account rendered an icon and no number. The allowlist now admits Cursor's pools, and the fallback accepts a monthly window. - `getWindowSections` dropped `monthly` whenever buckets existed. Cursor puts the plan total there and its sub-pools in buckets, so a plan at 92% showed as 50% in the roster, the tooltip, and the tightest-usage pick. - A plan reporting `enabled: false` still published its 0% pools, painting a healthy meter for a pool the account does not own and skipping the request-quota fallback. - `redirect: 'error'` turned the dashboard's bounce to /login into a generic network failure, hiding the actionable sign-in message. - A busy `state.vscdb` (the IDE holds it open) surfaced as a provider error, which would pin an alert bar on Cursor IDE users who never set Cursor up in Orca. It falls through to "no credential" instead. - Refreshing the Accounts section read the keychain twice for one update. * fix(rate-limits): pin the platform in the Cursor keychain tests Review caught three cases that assumed macOS: the keychain source is behind an explicit `process.platform` check, so on the Linux CI runner the mocked read was never reached and the tests read the CLI file instead. They now set the platform they mean, and two new cases assert the off-macOS fall-through. Also track the credentials reference doc (docs/** is ignored by default, so a new reference needs its own allowlist entry) and give the visibility fixtures their own provider id instead of Grok's. * fix(rate-limits): prefer a live Cursor session and report a failed refresh Review round two, from CodeRabbit and Pullfrog. - Credential precedence returned the first token that parsed, so an expired keychain token in front of a fresh Cursor IDE session reported "sign-in expired" on every poll while a usable session sat one source below. A live session now wins; the expired one is returned only when nothing live exists, so the actionable message still appears in that case. - The usage schema took `.optional()` where the route sends `null` for an absent sub-object, so one null pool failed the parse for the whole body and threw away valid pools and the billing cycle with it. - Cursor usage could survive an account switch: a failed refresh for account B kept account A's figures beside B's name in Accounts. The snapshot now carries a hashed account fingerprint, and a known-and-changed identity clears the previous reading. A refresh that names no account still keeps its own. - The Accounts section rendered nothing at all when a signed-in account's fetch failed, and could repaint an older account when two status reads overlapped. It now states the failure — beside the numbers when a stale snapshot remains — and ignores superseded reads. - A web client claimed "not signed in" for a host it cannot read, contradicting the meter beside it; it now says the detail is host-only. - Signed-out copy named `cursor-agent login` as the only way in, though an IDE sign-in works just as well. - The census comment ended at 4219 after the pacer squash without naming the two modules #22616 added; recorded them, re-measured on a clean origin/main. - Narrowed the docs claim: Cursor documents all-plan APIs, but no individual usage endpoint. * fix(i18n): localize the web client's Cursor host-only notice It reaches the Accounts pane like any other string, so the coverage gate is right to want it in the catalog rather than allowlisted. * fix(rate-limits): name the Cursor account on failed refreshes, and ship the reworded copy Review round three. Both findings say an earlier fix did not actually take. - The account-switch guard reads `authProvenance` off the fresh result, but the fetcher stamped it only on success and network failures. The `stale-token`, 429, 5xx and parse results omitted it, and so did the expired-session branch — so a switch whose first refresh failed, which is precisely the case the guard exists for, still rendered the previous account's figures under the new name. Every failure holding a readable session now names its account; a missing or unreadable credential still names none. The service test also fed a result shape the fetcher never produces, so it proved nothing; it now uses the real stale-token shape, and the fetcher test asserts provenance across 401/429/5xx and expiry. - The reworded signed-out copy never rendered: a present catalog value beats the `translate()` fallback, and `sync:localization-catalog` only adds missing keys rather than updating changed defaults. Updated both strings in en.json, which also prunes them from the runtime-required catalog now that they match. * docs: keep the Cursor credentials reference out of the tree Its content lives in the PR description instead; docs/** stays ignored rather than gaining an allowlist entry for this branch. * test(mobile): drop the census note main no longer pins main removed `SESSION_ROUTE_MODULES` and re-pinned this lane on a different count, so the paragraph this branch added documents a number series that is gone. The branch touches nothing in this file now. --------- Co-authored-by: Preschian Febryantara Co-authored-by: Qwesdy Co-authored-by: ivo922 Co-authored-by: Mihail Vratchanski Co-authored-by: Tauri-EPO Co-authored-by: Raajik <44516546+Raajik@users.noreply.github.com> --- config/tsconfig.cli.json | 1 + mobile/src/components/accounts-snapshot.ts | 3 +- src/main/claude-accounts/keychain.ts | 146 +-------- src/main/cursor-accounts/status.ts | 32 ++ src/main/ipc/cursor-accounts.ts | 6 + .../register-core-handlers.test.ts | 7 + .../register-core-handlers.ts | 2 + .../macos-keychain/generic-password.test.ts | 101 +++++++ src/main/macos-keychain/generic-password.ts | 147 +++++++++ src/main/rate-limits/cursor-auth-paths.ts | 34 +++ src/main/rate-limits/cursor-auth.test.ts | 230 ++++++++++++++ src/main/rate-limits/cursor-auth.ts | 211 +++++++++++++ .../rate-limits/cursor-desktop-state-db.ts | 73 +++++ src/main/rate-limits/cursor-fetcher.test.ts | 217 ++++++++++++++ src/main/rate-limits/cursor-fetcher.ts | 251 ++++++++++++++++ .../rate-limits/cursor-session-token.test.ts | 64 ++++ src/main/rate-limits/cursor-session-token.ts | 57 ++++ .../rate-limits/cursor-usage-mapping.test.ts | 182 ++++++++++++ src/main/rate-limits/cursor-usage-mapping.ts | 234 +++++++++++++++ .../rate-limit-service-test-harness.ts | 5 + .../service-account-target-selection.test.ts | 8 + .../service-antigravity-usage.test.ts | 8 + .../rate-limits/service-cursor-usage.test.ts | 163 ++++++++++ .../service-inactive-account-previews.test.ts | 8 + .../service-live-claude-usage.test.ts | 8 + .../rate-limits/service-minimax-usage.test.ts | 8 + .../service-refresh-orchestration.test.ts | 8 + .../service-window-activation.test.ts | 8 + .../service/service-configuration.ts | 1 + .../service/service-full-cycle-application.ts | 34 ++- .../service/service-full-cycle-preparation.ts | 26 +- .../rate-limits/service/service-polling.ts | 3 +- .../service/service-result-policy.ts | 10 +- .../service-sibling-provider-result.ts | 26 ++ src/main/rate-limits/service/service-state.ts | 12 +- src/main/rate-limits/service/service-types.ts | 1 + src/preload/api-types.ts | 2 + src/preload/api/agent-account-api.ts | 6 +- src/preload/api/cursor-accounts-bridge.ts | 7 + src/preload/index.ts | 2 + .../src/components/settings/AccountsPane.tsx | 5 + .../AppearanceWindowSidebarSection.tsx | 3 +- .../settings/CursorAccountsSection.tsx | 281 ++++++++++++++++++ .../components/settings/accounts-search.ts | 35 ++- ...earance-status-bar-cursor-toggle-search.ts | 39 +++ ...arance-status-bar-minimax-toggle-search.ts | 41 +++ .../settings/appearance-status-bar-search.ts | 39 +-- .../status-bar/StatusBarProviderSegment.tsx | 20 +- .../status-bar/StatusBarVisibilityMenu.tsx | 10 + .../status-bar-cursor-segment.test.tsx | 96 ++++++ .../status-bar-provider-visibility.test.ts | 48 ++- .../status-bar-provider-visibility.ts | 16 +- .../src/components/status-bar/tooltip.tsx | 16 + .../components/status-bar/usage-error-copy.ts | 14 + .../usage-provider-settings-target.ts | 2 + .../status-bar/use-status-bar-controller.ts | 20 +- src/renderer/src/i18n/locales/en.json | 65 +++- ...ui-hydration-workspace-preferences.test.ts | 12 +- .../slices/ui/ui-slice-hydration-actions.ts | 32 +- .../ui/ui-slice-hydration-status-bar-items.ts | 33 ++ .../web/preload-api/web-agent-accounts-api.ts | 23 ++ .../web/web-preload-api-composition.test.ts | 1 + src/renderer/src/web/web-preload-api.ts | 2 + .../child-process-import-allowlist.txt | 2 +- .../windows-console-visibility-allowlist.txt | 2 +- src/shared/cursor-usage-buckets.ts | 18 ++ src/shared/persisted-ui-state-types.ts | 2 + src/shared/rate-limit-state-factory.ts | 2 + src/shared/rate-limit-types.test.ts | 4 + src/shared/rate-limit-types.ts | 23 +- src/shared/rpc-contract/client-ui-params.ts | 2 + src/shared/status-bar-defaults.ts | 1 + src/shared/ui-chrome-types.ts | 1 + 73 files changed, 2988 insertions(+), 274 deletions(-) create mode 100644 src/main/cursor-accounts/status.ts create mode 100644 src/main/ipc/cursor-accounts.ts create mode 100644 src/main/macos-keychain/generic-password.test.ts create mode 100644 src/main/macos-keychain/generic-password.ts create mode 100644 src/main/rate-limits/cursor-auth-paths.ts create mode 100644 src/main/rate-limits/cursor-auth.test.ts create mode 100644 src/main/rate-limits/cursor-auth.ts create mode 100644 src/main/rate-limits/cursor-desktop-state-db.ts create mode 100644 src/main/rate-limits/cursor-fetcher.test.ts create mode 100644 src/main/rate-limits/cursor-fetcher.ts create mode 100644 src/main/rate-limits/cursor-session-token.test.ts create mode 100644 src/main/rate-limits/cursor-session-token.ts create mode 100644 src/main/rate-limits/cursor-usage-mapping.test.ts create mode 100644 src/main/rate-limits/cursor-usage-mapping.ts create mode 100644 src/main/rate-limits/service-cursor-usage.test.ts create mode 100644 src/main/rate-limits/service/service-sibling-provider-result.ts create mode 100644 src/preload/api/cursor-accounts-bridge.ts create mode 100644 src/renderer/src/components/settings/CursorAccountsSection.tsx create mode 100644 src/renderer/src/components/settings/appearance-status-bar-cursor-toggle-search.ts create mode 100644 src/renderer/src/components/settings/appearance-status-bar-minimax-toggle-search.ts create mode 100644 src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx create mode 100644 src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts create mode 100644 src/shared/cursor-usage-buckets.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 9c77c8c595f..f9bcf82c52d 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -33,6 +33,7 @@ "../src/main/claude/hook-service.ts", "../src/main/claude/statusline-script.ts", "../src/main/claude-accounts/keychain.ts", + "../src/main/macos-keychain/generic-password.ts", "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", diff --git a/mobile/src/components/accounts-snapshot.ts b/mobile/src/components/accounts-snapshot.ts index 8baf0213623..f95f291da9d 100644 --- a/mobile/src/components/accounts-snapshot.ts +++ b/mobile/src/components/accounts-snapshot.ts @@ -39,7 +39,8 @@ export const ProviderRateLimitsSchema = z 'kimi', 'minimax', 'grok', - 'antigravity' + 'antigravity', + 'cursor' ]), session: RateLimitWindowSchema.nullable(), weekly: RateLimitWindowSchema.nullable(), diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index eca0e9af44d..aa4dcfa64db 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -1,18 +1,15 @@ -import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' import { basename, dirname, join } from 'node:path' +import { + deleteKeychainPassword, + readKeychainPassword, + writeKeychainPassword +} from '../macos-keychain/generic-password' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' -const KEYCHAIN_COMMAND_TIMEOUT_MS = 3_000 - -type SecurityCommandResult = { - stdout: string - stderr: string -} - export async function readActiveClaudeKeychainCredentials( configDir?: string ): Promise { @@ -183,136 +180,3 @@ function getActiveClaudeServices(configDir?: string): string[] { const scoped = claudeConfigDirKeychainAliases(configDir).map((dir) => getActiveClaudeService(dir)) return [...new Set([...scoped, ACTIVE_CLAUDE_SERVICE])] } - -async function readKeychainPassword(service: string, account: string): Promise { - if (process.platform !== 'darwin') { - return null - } - try { - const { stdout } = await execSecurityCommand([ - 'find-generic-password', - '-s', - service, - '-a', - account, - '-w' - ]) - if (stdout.trim()) { - return stdout.trim() - } - throw new Error(`Could not read macOS Keychain item ${service}/${account}.`) - } catch (error) { - if (isKeychainNotFoundError(error)) { - return null - } - throw error - } -} - -async function writeKeychainPassword( - service: string, - account: string, - contents: string -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) -} - -async function deleteKeychainPassword( - service: string, - account: string, - options?: { failOnAccessError?: boolean } -): Promise { - if (process.platform !== 'darwin') { - return - } - await execSecurity(['delete-generic-password', '-s', service, '-a', account], { - ignoreNotFound: true, - ignoreFailure: !options?.failOnAccessError - }) -} - -function execSecurity( - args: string[], - options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean } -): Promise { - return execSecurityCommand(args).then(undefined, (error: unknown) => { - if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { - return - } - if (!options?.ignoreFailure) { - throw error - } - }) -} - -function isKeychainNotFoundError(error: unknown): boolean { - const code = - error && typeof error === 'object' && 'code' in error - ? (error as { code?: unknown }).code - : undefined - const message = - error && typeof error === 'object' - ? `${String((error as { stderr?: unknown }).stderr ?? '')} ${String( - (error as { message?: unknown }).message ?? '' - )}`.toLowerCase() - : String(error).toLowerCase() - return code === 44 || message.includes('could not be found') || message.includes('not be found') -} - -function execSecurityCommand(args: string[]): Promise { - return new Promise((resolve, reject) => { - let settled = false - let child: ReturnType | undefined - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - child?.kill() - reject( - Object.assign(new Error(`security timed out after ${KEYCHAIN_COMMAND_TIMEOUT_MS}ms`), { - code: 'ETIMEDOUT', - stderr: '' - }) - ) - }, KEYCHAIN_COMMAND_TIMEOUT_MS) - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - callback() - } - - // Why: Node's execFile timeout only signals the `security` process; a - // stuck callback would otherwise leave auth/keychain operations pending. - try { - child = execFile( - 'security', - args, - { timeout: KEYCHAIN_COMMAND_TIMEOUT_MS }, - (error, stdout, stderr) => { - if (error) { - settle(() => - reject( - Object.assign(error, { - stdout: String(stdout), - stderr: String(stderr) - }) - ) - ) - return - } - settle(() => resolve({ stdout: String(stdout), stderr: String(stderr) })) - } - ) - } catch (error) { - settle(() => reject(error)) - } - }) -} diff --git a/src/main/cursor-accounts/status.ts b/src/main/cursor-accounts/status.ts new file mode 100644 index 00000000000..e2104c36c38 --- /dev/null +++ b/src/main/cursor-accounts/status.ts @@ -0,0 +1,32 @@ +import type { CursorAccountStatus } from '../../shared/rate-limit-types' +import { readCursorAuthSession } from '../rate-limits/cursor-auth' +import { isCursorSessionTokenExpired } from '../rate-limits/cursor-session-token' + +function signedOut(error: string | null): CursorAccountStatus { + return { + signedIn: false, + email: null, + displayName: null, + credentialSource: null, + planType: null, + tokenFresh: false, + error + } +} + +export async function getCursorAccountStatus(): Promise { + const readResult = await readCursorAuthSession() + if (readResult.status !== 'ok') { + return signedOut(readResult.status === 'error' ? readResult.error : null) + } + const session = readResult.session + return { + signedIn: true, + email: session.email, + displayName: session.displayName, + credentialSource: session.source, + planType: session.membershipType, + tokenFresh: !isCursorSessionTokenExpired(session.token), + error: null + } +} diff --git a/src/main/ipc/cursor-accounts.ts b/src/main/ipc/cursor-accounts.ts new file mode 100644 index 00000000000..78273dfb1cb --- /dev/null +++ b/src/main/ipc/cursor-accounts.ts @@ -0,0 +1,6 @@ +import { ipcMain } from 'electron' +import { getCursorAccountStatus } from '../cursor-accounts/status' + +export function registerCursorAccountHandlers(): void { + ipcMain.handle('cursorAccounts:getStatus', () => getCursorAccountStatus()) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 0d4f04046b0..57f4eaa2dbe 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -38,6 +38,7 @@ const { registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, + registerCursorAccountHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -105,6 +106,7 @@ const { registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), + registerCursorAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -344,6 +346,10 @@ vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) +vi.mock('../cursor-accounts', () => ({ + registerCursorAccountHandlers: registerCursorAccountHandlersMock +})) + vi.mock('../../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -528,6 +534,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() + expect(registerCursorAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 0b329335755..f9e46352557 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -64,6 +64,7 @@ import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' +import { registerCursorAccountHandlers } from '../cursor-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' import { registerClipboardHandlers, @@ -152,6 +153,7 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() + registerCursorAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/macos-keychain/generic-password.test.ts b/src/main/macos-keychain/generic-password.test.ts new file mode 100644 index 00000000000..38ec1b05a72 --- /dev/null +++ b/src/main/macos-keychain/generic-password.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { + deleteKeychainPassword, + isKeychainNotFoundError, + readKeychainPassword, + writeKeychainPassword +} from './generic-password' + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function respond(stdout: string): void { + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(null, stdout, '') + return { kill: vi.fn() } + }) +} + +function fail(error: unknown): void { + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(error, '', '') + return { kill: vi.fn() } + }) +} + +beforeEach(() => { + execFileMock.mockReset() + setPlatform('darwin') +}) + +afterEach(() => { + setPlatform(originalPlatform) +}) + +describe('readKeychainPassword', () => { + it('returns the trimmed secret for a stored item', async () => { + respond(' secret-value\n') + await expect(readKeychainPassword('cursor-access-token', 'cursor-user')).resolves.toBe( + 'secret-value' + ) + expect(execFileMock.mock.calls[0]?.[1]).toEqual([ + 'find-generic-password', + '-s', + 'cursor-access-token', + '-a', + 'cursor-user', + '-w' + ]) + }) + + it('returns null rather than throwing when the item does not exist', async () => { + fail(Object.assign(new Error('The specified item could not be found'), { code: 44 })) + await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() + }) + + it('rethrows a denied or locked keychain so callers can report it', async () => { + fail(new Error('User interaction is not allowed')) + await expect(readKeychainPassword('svc', 'acct')).rejects.toThrow( + 'User interaction is not allowed' + ) + }) + + it('never shells out off macOS', async () => { + setPlatform('win32') + await expect(readKeychainPassword('svc', 'acct')).resolves.toBeNull() + await writeKeychainPassword('svc', 'acct', 'value') + await deleteKeychainPassword('svc', 'acct') + expect(execFileMock).not.toHaveBeenCalled() + }) +}) + +describe('deleteKeychainPassword', () => { + it('swallows a missing item', async () => { + fail(Object.assign(new Error('could not be found'), { code: 44 })) + await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() + }) + + it('surfaces an access failure only when the caller asks for it', async () => { + fail(new Error('User interaction is not allowed')) + await expect(deleteKeychainPassword('svc', 'acct')).resolves.toBeUndefined() + await expect( + deleteKeychainPassword('svc', 'acct', { failOnAccessError: true }) + ).rejects.toThrow('User interaction is not allowed') + }) +}) + +describe('isKeychainNotFoundError', () => { + it('recognizes the security(1) not-found signals', () => { + expect(isKeychainNotFoundError({ code: 44 })).toBe(true) + expect(isKeychainNotFoundError(new Error('The specified item could not be found'))).toBe(true) + expect(isKeychainNotFoundError(new Error('User interaction is not allowed'))).toBe(false) + }) +}) diff --git a/src/main/macos-keychain/generic-password.ts b/src/main/macos-keychain/generic-password.ts new file mode 100644 index 00000000000..8a805edd809 --- /dev/null +++ b/src/main/macos-keychain/generic-password.ts @@ -0,0 +1,147 @@ +// Why execFile and not runProcess: this is a relocation of the Claude keychain +// reader's existing exception, kept byte-for-byte so Claude's credential path and +// its tests are unchanged. See the child_process import allowlist. +import { execFile } from 'node:child_process' + +const KEYCHAIN_COMMAND_TIMEOUT_MS = 3_000 + +type SecurityCommandResult = { + stdout: string + stderr: string +} + +export function isKeychainNotFoundError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined + const message = + error && typeof error === 'object' + ? `${String((error as { stderr?: unknown }).stderr ?? '')} ${String( + (error as { message?: unknown }).message ?? '' + )}`.toLowerCase() + : String(error).toLowerCase() + return code === 44 || message.includes('could not be found') || message.includes('not be found') +} + +export function execSecurityCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject( + Object.assign(new Error(`security timed out after ${KEYCHAIN_COMMAND_TIMEOUT_MS}ms`), { + code: 'ETIMEDOUT', + stderr: '' + }) + ) + }, KEYCHAIN_COMMAND_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: Node's execFile timeout only signals the `security` process; a + // stuck callback would otherwise leave auth/keychain operations pending. + try { + child = execFile( + 'security', + args, + { timeout: KEYCHAIN_COMMAND_TIMEOUT_MS }, + (error, stdout, stderr) => { + if (error) { + settle(() => + reject( + Object.assign(error, { + stdout: String(stdout), + stderr: String(stderr) + }) + ) + ) + return + } + settle(() => resolve({ stdout: String(stdout), stderr: String(stderr) })) + } + ) + } catch (error) { + settle(() => reject(error)) + } + }) +} + +export function execSecurity( + args: string[], + options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean } +): Promise { + return execSecurityCommand(args).then(undefined, (error: unknown) => { + if (options?.ignoreNotFound && isKeychainNotFoundError(error)) { + return + } + if (!options?.ignoreFailure) { + throw error + } + }) +} + +export async function readKeychainPassword( + service: string, + account: string +): Promise { + if (process.platform !== 'darwin') { + return null + } + try { + const { stdout } = await execSecurityCommand([ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w' + ]) + if (stdout.trim()) { + return stdout.trim() + } + throw new Error(`Could not read macOS Keychain item ${service}/${account}.`) + } catch (error) { + if (isKeychainNotFoundError(error)) { + return null + } + throw error + } +} + +export async function writeKeychainPassword( + service: string, + account: string, + contents: string +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) +} + +export async function deleteKeychainPassword( + service: string, + account: string, + options?: { failOnAccessError?: boolean } +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['delete-generic-password', '-s', service, '-a', account], { + ignoreNotFound: true, + ignoreFailure: !options?.failOnAccessError + }) +} diff --git a/src/main/rate-limits/cursor-auth-paths.ts b/src/main/rate-limits/cursor-auth-paths.ts new file mode 100644 index 00000000000..72a3006a8ae --- /dev/null +++ b/src/main/rate-limits/cursor-auth-paths.ts @@ -0,0 +1,34 @@ +import os from 'node:os' +import { join } from 'node:path' + +/** Where a Cursor credential was found. */ +export type CursorAuthSource = 'keychain' | 'cli' | 'desktop' + +function cursorConfigRoot(source: Exclude): string { + if (process.platform === 'darwin') { + return source === 'desktop' + ? join(os.homedir(), 'Library', 'Application Support', 'Cursor') + : join(os.homedir(), '.cursor') + } + if (process.platform === 'win32') { + const root = process.env.APPDATA?.trim() || join(os.homedir(), 'AppData', 'Roaming') + return join(root, 'Cursor') + } + const root = process.env.XDG_CONFIG_HOME?.trim() || join(os.homedir(), '.config') + return join(root, source === 'desktop' ? 'Cursor' : 'cursor') +} + +/** Cursor IDE global storage; holds `cursorAuth/*` keys in an `ItemTable` row. */ +export function getCursorDesktopStateDbPath(): string { + return join(cursorConfigRoot('desktop'), 'User', 'globalStorage', 'state.vscdb') +} + +/** Pre-2026.06 `cursor-agent` token file; newer CLIs keep the token in the OS keychain. */ +export function getCursorCliAuthPath(): string { + return join(cursorConfigRoot('cli'), 'auth.json') +} + +/** `cursor-agent` settings file; its `authInfo` block carries the signed-in identity, never a token. */ +export function getCursorCliConfigPath(): string { + return join(cursorConfigRoot('cli'), 'cli-config.json') +} diff --git a/src/main/rate-limits/cursor-auth.test.ts b/src/main/rate-limits/cursor-auth.test.ts new file mode 100644 index 00000000000..8280bf984cd --- /dev/null +++ b/src/main/rate-limits/cursor-auth.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const keychainState = vi.hoisted<{ token: string | null; error: Error | null }>(() => ({ + token: null, + error: null +})) +const files = vi.hoisted<() => Map>(() => { + const map = new Map() + return () => map +}) +const desktopState = vi.hoisted<{ + result: { status: string; profile?: unknown; error?: string } +}>(() => ({ result: { status: 'missing' } })) + +vi.mock('../macos-keychain/generic-password', () => ({ + readKeychainPassword: async () => { + if (keychainState.error) { + throw keychainState.error + } + return keychainState.token + } +})) + +vi.mock('node:fs', () => ({ + existsSync: (path: string) => files().has(path), + readFileSync: (path: string) => { + const entry = files().get(path) + if (entry instanceof Error) { + throw entry + } + if (entry === undefined) { + throw new Error('ENOENT') + } + return entry + } +})) + +vi.mock('./cursor-desktop-state-db', () => ({ + readCursorDesktopProfile: () => desktopState.result +})) + +import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth' + +type JwtSegment = Record + +function expiredJwt(sub: string): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 1_000 })}.signature` +} + +function jwt(sub: string): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 2_000_000_000 })}.signature` +} + +const CLI_AUTH = '/cli/auth.json' +const CLI_CONFIG = '/cli/cli-config.json' +const DESKTOP_DB = '/desktop/state.vscdb' +const options = { + cliAuthPath: CLI_AUTH, + cliConfigPath: CLI_CONFIG, + desktopStateDbPath: DESKTOP_DB +} + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +beforeEach(() => { + // Why: the keychain source is macOS-only by an explicit platform check, so + // these cases must pin the platform rather than inherit the CI runner's. + setPlatform('darwin') + keychainState.token = null + keychainState.error = null + files().clear() + desktopState.result = { status: 'missing' } +}) + +afterEach(() => { + setPlatform(originalPlatform) + vi.restoreAllMocks() +}) + +describe('readCursorAuthSession', () => { + it('prefers the keychain session cursor-agent 2026.06+ writes', async () => { + keychainState.token = jwt('auth0|user_keychain') + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status).toBe('ok') + expect(result.status === 'ok' && result.session.source).toBe('keychain') + expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_keychain') + }) + + it('names the signed-in account from cli-config.json, which holds no token', async () => { + keychainState.token = jwt('auth0|user_1') + files().set( + CLI_CONFIG, + JSON.stringify({ authInfo: { email: 'dev@example.com', displayName: 'Dev' } }) + ) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.email).toBe('dev@example.com') + expect(result.status === 'ok' && result.session.displayName).toBe('Dev') + }) + + it('falls back to the legacy CLI auth file when the keychain holds nothing', async () => { + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('cli') + }) + + it('falls back to Cursor IDE when no CLI session exists', async () => { + desktopState.result = { + status: 'ok', + profile: { + accessToken: jwt('auth0|user_ide'), + email: 'ide@example.com', + membershipType: 'pro', + subscriptionStatus: 'active' + } + } + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('desktop') + expect(result.status === 'ok' && result.session.membershipType).toBe('pro') + }) + + it('does not let a locked keychain hide a readable CLI session', async () => { + keychainState.error = new Error('User interaction is not allowed') + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('cli') + }) + + it('reports the first read failure when no source yields a session', async () => { + keychainState.error = new Error('User interaction is not allowed') + const result = await readCursorAuthSession(options) + expect(result).toEqual({ + status: 'error', + error: 'Unable to read the Cursor login from the macOS Keychain' + }) + }) + + it('reports signed out when nothing is stored anywhere', async () => { + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) + + it('treats a signed-out CLI auth file as missing, not as an error', async () => { + files().set(CLI_AUTH, JSON.stringify({})) + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) + + it('keeps the local auth path out of the surfaced error', async () => { + files().set( + CLI_AUTH, + new Error('EACCES: permission denied, open /Users/someone/.cursor/auth.json') + ) + const result = await readCursorAuthSession(options) + expect(result).toEqual({ status: 'error', error: 'Unable to read the Cursor CLI auth file' }) + }) + + it('reports invalid JSON distinctly from an unreadable file', async () => { + files().set(CLI_AUTH, '{ not json') + const result = await readCursorAuthSession(options) + expect(result).toEqual({ status: 'error', error: 'Cursor CLI auth file is invalid' }) + }) + + it('prefers a live desktop session over an expired keychain one', async () => { + // Why: a user who signed the CLI in once and now works only in the IDE would + // otherwise be told "sign-in expired" forever while a usable session sat below. + keychainState.token = expiredJwt('auth0|user_stale') + desktopState.result = { + status: 'ok', + profile: { + accessToken: jwt('auth0|user_ide'), + email: 'ide@example.com', + membershipType: 'pro', + subscriptionStatus: 'active' + } + } + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('desktop') + expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_ide') + }) + + it('still returns the expired session when no live one exists anywhere', async () => { + // Why: the expiry message is the actionable answer in that case. + keychainState.token = expiredJwt('auth0|user_stale') + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('keychain') + }) + + it('never reads the keychain off macOS and falls through to the CLI file', async () => { + setPlatform('linux') + keychainState.token = jwt('auth0|user_keychain') + files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') })) + const result = await readCursorAuthSession(options) + expect(result.status === 'ok' && result.session.source).toBe('cli') + }) + + it('reports signed out off macOS when only a keychain session exists', async () => { + setPlatform('win32') + keychainState.token = jwt('auth0|user_keychain') + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) + + it('skips a stored token that carries no subject', async () => { + keychainState.token = 'not-a-jwt' + expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' }) + }) +}) + +describe('readCursorCliIdentity', () => { + it('returns empty identity for a config with no authInfo', () => { + files().set(CLI_CONFIG, JSON.stringify({ version: 1 })) + expect(readCursorCliIdentity(CLI_CONFIG)).toEqual({ + email: null, + displayName: null, + membershipType: null, + subscriptionStatus: null + }) + }) + + it('survives a corrupt config file', () => { + files().set(CLI_CONFIG, '{{{') + expect(readCursorCliIdentity(CLI_CONFIG).email).toBeNull() + }) +}) diff --git a/src/main/rate-limits/cursor-auth.ts b/src/main/rate-limits/cursor-auth.ts new file mode 100644 index 00000000000..8ef86f0ba73 --- /dev/null +++ b/src/main/rate-limits/cursor-auth.ts @@ -0,0 +1,211 @@ +import { existsSync, readFileSync } from 'node:fs' +import { z } from 'zod' +import { readKeychainPassword } from '../macos-keychain/generic-password' +import { + getCursorCliAuthPath, + getCursorCliConfigPath, + getCursorDesktopStateDbPath, + type CursorAuthSource +} from './cursor-auth-paths' +import { readCursorDesktopProfile, type CursorDesktopProfile } from './cursor-desktop-state-db' +import { + isCursorSessionTokenExpired, + parseCursorSessionToken, + type CursorSessionToken +} from './cursor-session-token' + +// Why: cursor-agent 2026.06+ stores the session in the login keychain, not auth.json. +const KEYCHAIN_SERVICE = 'cursor-access-token' +const KEYCHAIN_ACCOUNT = 'cursor-user' + +export type CursorIdentity = { + email: string | null + displayName: string | null + membershipType: string | null + subscriptionStatus: string | null +} + +export type CursorAuthSession = CursorIdentity & { + token: CursorSessionToken + source: CursorAuthSource +} + +export type CursorAuthReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; session: CursorAuthSession } + +function emptyIdentity(): CursorIdentity { + return { email: null, displayName: null, membershipType: null, subscriptionStatus: null } +} + +function nonEmpty(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +const cliConfigSchema = z.object({ + authInfo: z.object({ email: z.unknown(), displayName: z.unknown() }).partial().optional() +}) + +const cliAuthFileSchema = z.object({ accessToken: z.unknown() }).partial() + +/** `cli-config.json` carries the signed-in identity for keychain-backed CLI sessions. */ +export function readCursorCliIdentity(configPath = getCursorCliConfigPath()): CursorIdentity { + if (!existsSync(configPath)) { + return emptyIdentity() + } + try { + const parsed = cliConfigSchema.safeParse(JSON.parse(readFileSync(configPath, 'utf8'))) + if (!parsed.success) { + return emptyIdentity() + } + return { + ...emptyIdentity(), + email: nonEmpty(parsed.data.authInfo?.email), + displayName: nonEmpty(parsed.data.authInfo?.displayName) + } + } catch { + return emptyIdentity() + } +} + +type TokenReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; token: string } + +async function readKeychainToken(): Promise { + if (process.platform !== 'darwin') { + return { status: 'missing' } + } + try { + const token = await readKeychainPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + return token ? { status: 'ok', token } : { status: 'missing' } + } catch { + // Why: a denied or locked keychain must not mask a readable auth.json below it. + return { status: 'error', error: 'Unable to read the Cursor login from the macOS Keychain' } + } +} + +function readCliFileToken(authPath: string): TokenReadResult { + if (!existsSync(authPath)) { + return { status: 'missing' } + } + try { + const parsed = cliAuthFileSchema.safeParse(JSON.parse(readFileSync(authPath, 'utf8'))) + const token = parsed.success ? parsed.data.accessToken : null + return typeof token === 'string' && token.length > 0 + ? { status: 'ok', token } + : { status: 'missing' } + } catch (error) { + // Why: filesystem errors quote the full path; account state must not leak local usernames. + return { + status: 'error', + error: + error instanceof SyntaxError + ? 'Cursor CLI auth file is invalid' + : 'Unable to read the Cursor CLI auth file' + } + } +} + +function sessionFrom( + raw: string, + source: CursorAuthSource, + identity: CursorIdentity +): CursorAuthSession | null { + const token = parseCursorSessionToken(raw) + return token ? { ...identity, token, source } : null +} + +function desktopIdentity(profile: CursorDesktopProfile): CursorIdentity { + return { + email: profile.email, + displayName: null, + membershipType: profile.membershipType, + subscriptionStatus: profile.subscriptionStatus + } +} + +export type CursorAuthReadOptions = { + cliAuthPath?: string + cliConfigPath?: string + desktopStateDbPath?: string +} + +/** + * Resolve the Cursor session Orca should poll with, preferring the CLI login + * (keychain, then legacy auth.json) over the Cursor IDE's own session. Read-only: + * Orca never writes, refreshes, or rotates the user's Cursor credentials. + */ +export async function readCursorAuthSession( + options: CursorAuthReadOptions = {} +): Promise { + const cliAuthPath = options.cliAuthPath ?? getCursorCliAuthPath() + const cliConfigPath = options.cliConfigPath ?? getCursorCliConfigPath() + const desktopDbPath = options.desktopStateDbPath ?? getCursorDesktopStateDbPath() + const errors: string[] = [] + // Why a live session wins over precedence: a user who signed the CLI in once and + // now works only in the IDE has an expired keychain token in front of a fresh + // desktop one. Returning the first token that parses would report "sign-in + // expired" forever while a usable session sat one source below. + let expired: CursorAuthSession | null = null + const takeLive = (session: CursorAuthSession | null): CursorAuthSession | null => { + if (!session) { + return null + } + if (isCursorSessionTokenExpired(session.token)) { + expired ??= session + return null + } + return session + } + + const keychainRead = await readKeychainToken() + if (keychainRead.status === 'error') { + errors.push(keychainRead.error) + } + if (keychainRead.status === 'ok') { + const session = takeLive( + sessionFrom(keychainRead.token, 'keychain', readCursorCliIdentity(cliConfigPath)) + ) + if (session) { + return { status: 'ok', session } + } + } + + const cliRead = readCliFileToken(cliAuthPath) + if (cliRead.status === 'error') { + errors.push(cliRead.error) + } + if (cliRead.status === 'ok') { + const session = takeLive( + sessionFrom(cliRead.token, 'cli', readCursorCliIdentity(cliConfigPath)) + ) + if (session) { + return { status: 'ok', session } + } + } + + // Why not pushed to `errors`: the IDE holds a lock on state.vscdb while it runs, + // so a busy open is transient. Reporting it would pin an alert-triangle bar on + // every Cursor IDE user who never set Cursor up in Orca. + const desktopRead = readCursorDesktopProfile(desktopDbPath) + if (desktopRead.status === 'ok' && desktopRead.profile.accessToken) { + const session = takeLive( + sessionFrom(desktopRead.profile.accessToken, 'desktop', desktopIdentity(desktopRead.profile)) + ) + if (session) { + return { status: 'ok', session } + } + } + + // Why still returned: with no live session anywhere, the expired one is what the + // user must act on, and the fetcher turns it into "run cursor-agent login". + if (expired) { + return { status: 'ok', session: expired } + } + + const firstError = errors[0] + return firstError === undefined ? { status: 'missing' } : { status: 'error', error: firstError } +} diff --git a/src/main/rate-limits/cursor-desktop-state-db.ts b/src/main/rate-limits/cursor-desktop-state-db.ts new file mode 100644 index 00000000000..bdc50c7390d --- /dev/null +++ b/src/main/rate-limits/cursor-desktop-state-db.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs' +import { z } from 'zod' +import SyncDatabase from '../sqlite/sync-database' + +const TOKEN_KEY = 'cursorAuth/accessToken' +const EMAIL_KEY = 'cursorAuth/cachedEmail' +const MEMBERSHIP_KEY = 'cursorAuth/stripeMembershipType' +const SUBSCRIPTION_KEY = 'cursorAuth/stripeSubscriptionStatus' +const OPEN_TIMEOUT_MS = 250 + +export type CursorDesktopProfile = { + accessToken: string | null + email: string | null + membershipType: string | null + subscriptionStatus: string | null +} + +export type CursorDesktopProfileReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; profile: CursorDesktopProfile } + +const rowsSchema = z.array(z.object({ key: z.unknown(), value: z.unknown() }).partial()) + +function valueAsString(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value + } + if (value instanceof Uint8Array) { + return Buffer.from(value).toString('utf8').trim() || null + } + return null +} + +/** Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB. */ +export function readCursorDesktopProfile(dbPath: string): CursorDesktopProfileReadResult { + if (!existsSync(dbPath)) { + return { status: 'missing' } + } + let db: SyncDatabase | null = null + try { + db = new SyncDatabase(dbPath, { + readonly: true, + fileMustExist: true, + timeout: OPEN_TIMEOUT_MS + }) + const rows = rowsSchema.parse( + db + .prepare('SELECT key, value FROM ItemTable WHERE key IN (?, ?, ?, ?)') + .all(TOKEN_KEY, EMAIL_KEY, MEMBERSHIP_KEY, SUBSCRIPTION_KEY) + ) + const byKey = new Map() + for (const row of rows) { + const value = valueAsString(row.value) + if (typeof row.key === 'string' && value) { + byKey.set(row.key, value) + } + } + return { + status: 'ok', + profile: { + accessToken: byKey.get(TOKEN_KEY) ?? null, + email: byKey.get(EMAIL_KEY) ?? null, + membershipType: byKey.get(MEMBERSHIP_KEY) ?? null, + subscriptionStatus: byKey.get(SUBSCRIPTION_KEY) ?? null + } + } + } catch { + return { status: 'error', error: 'Unable to read the Cursor desktop login' } + } finally { + db?.close() + } +} diff --git a/src/main/rate-limits/cursor-fetcher.test.ts b/src/main/rate-limits/cursor-fetcher.test.ts new file mode 100644 index 00000000000..b334713a9af --- /dev/null +++ b/src/main/rate-limits/cursor-fetcher.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { fetchCursorRateLimits } from './cursor-fetcher' +import type { CursorAuthReadResult } from './cursor-auth' +import { parseCursorSessionToken } from './cursor-session-token' + +type JwtSegment = Record + +function jwt(exp: number): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp })}.signature` +} + +function session(expSeconds = 4_000_000_000): CursorAuthReadResult { + return { + status: 'ok', + session: { + token: parseCursorSessionToken(jwt(expSeconds))!, + source: 'keychain', + email: 'dev@example.com', + displayName: 'Dev', + membershipType: null, + subscriptionStatus: null + } + } +} + +type FakeResponse = Pick & { + headers: Pick + json: () => Promise +} + +function fakeResponse(response: FakeResponse): Response { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fetcher only reads ok/status/headers.get/json, all of which FakeResponse provides. + return response as unknown as Response +} + +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { + return fakeResponse({ + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + json: async () => body + }) +} + +const SUMMARY = { + billingCycleStart: '2026-09-01T00:00:00.000Z', + billingCycleEnd: '2026-10-01T00:00:00.000Z', + membershipType: 'pro', + individualUsage: { + plan: { enabled: true, used: 2_500, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 12 } + } +} + +beforeEach(() => { + netFetchMock.mockReset() +}) + +describe('fetchCursorRateLimits', () => { + it('publishes both plan pools and the cycle reset', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY)) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('ok') + expect(limits.monthly?.usedPercent).toBe(50) + expect(limits.buckets?.map((bucket) => bucket.name)).toEqual(['Cursor Models', 'Other Models']) + expect(limits.planType).toBe('pro') + expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' }) + }) + + it('sends the origin headers the dashboard checks as CSRF defence', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY)) + await fetchCursorRateLimits({ authReadResult: session() }) + const [url, init] = netFetchMock.mock.calls[0] ?? [] + expect(url).toBe('https://cursor.com/api/usage-summary') + expect(init?.headers).toMatchObject({ + Origin: 'https://cursor.com', + Referer: 'https://cursor.com/dashboard', + Cookie: expect.stringContaining('WorkosCursorSessionToken=auth0%7Cuser_1%3A%3A') + }) + }) + + it('reports an expired session without spending a request that must 401', async () => { + const limits = await fetchCursorRateLimits({ authReadResult: session(1_000) }) + expect(netFetchMock).not.toHaveBeenCalled() + expect(limits.status).toBe('error') + expect(limits.usageMetadata?.failureKind).toBe('stale-token') + expect(limits.error).toContain('cursor-agent login') + }) + + it('tells a signed-out user how to sign in, without painting an error bar', async () => { + const limits = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } }) + expect(limits.status).toBe('unavailable') + expect(limits.usageMetadata?.failureKind).toBe('missing-credentials') + }) + + it('surfaces a credential read failure', async () => { + const limits = await fetchCursorRateLimits({ + authReadResult: { status: 'error', error: 'Unable to read the Cursor CLI auth file' } + }) + expect(limits.status).toBe('error') + expect(limits.error).toBe('Unable to read the Cursor CLI auth file') + }) + + it('treats a rejected session as expired sign-in and records Retry-After on 429', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 401)) + expect( + (await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind + ).toBe('stale-token') + + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' })) + const limited = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limited.usageMetadata?.failureKind).toBe('rate-limited') + expect(limited.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now()) + }) + + it('treats a redirect to the login page as an expired sign-in', async () => { + // Why: the dashboard bounces an unusable session to /login. With + // redirect:'error' that surfaced as a generic network failure, hiding the + // one message that tells the user what to do. + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 302, { location: '/login' })) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.usageMetadata?.failureKind).toBe('stale-token') + expect(limits.error).toContain('cursor-agent login') + }) + + it('names the account on failures too, so an account switch can clear stale figures', async () => { + // Why: the service drops a previous account's numbers only when the fresh + // result names an account. A switch whose first refresh fails is exactly when + // that matters, so every failure holding a readable session carries it. + const ok = jsonResponse(SUMMARY) + netFetchMock.mockResolvedValueOnce(ok) + const success = await fetchCursorRateLimits({ authReadResult: session() }) + const fingerprint = success.usageMetadata?.authProvenance + expect(fingerprint).toBeTruthy() + + for (const [status, headers] of [ + [401, {}], + [429, { 'retry-after': '60' }], + [503, {}] + ] as const) { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, status, headers)) + const failure = await fetchCursorRateLimits({ authReadResult: session() }) + expect(failure.status).toBe('error') + expect(failure.usageMetadata?.authProvenance).toBe(fingerprint) + } + + const expired = await fetchCursorRateLimits({ authReadResult: session(1_000) }) + expect(expired.usageMetadata?.authProvenance).toBe(fingerprint) + }) + + it('leaves the account unnamed when no session could be read', async () => { + const missing = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } }) + expect(missing.usageMetadata?.authProvenance).toBeUndefined() + }) + + it('reports a server failure with its status code', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse({}, 503)) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.error).toBe('Cursor usage request failed (HTTP 503)') + expect(limits.usageMetadata?.failureKind).toBe('server') + }) + + it('classifies an unparseable body as a parse failure, not as usage', async () => { + netFetchMock.mockResolvedValueOnce( + fakeResponse({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => { + throw new Error('invalid json') + } + }) + ) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.usageMetadata?.failureKind).toBe('parse') + }) + + it('publishes an unlimited plan with no misleading bar', async () => { + netFetchMock.mockResolvedValueOnce(jsonResponse({ isUnlimited: true, membershipType: 'ultra' })) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('ok') + expect(limits.monthly).toBeUndefined() + expect(limits.planType).toBe('ultra') + }) + + it('falls back to the request-quota endpoint before declaring no allowance', async () => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) + .mockResolvedValueOnce(jsonResponse({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } })) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('ok') + expect(limits.monthly?.usedPercent).toBe(50) + expect(netFetchMock.mock.calls[1]?.[0]).toBe('https://cursor.com/api/usage?user=auth0%7Cuser_1') + }) + + it('hides the bar for an account with no quota rather than alerting forever', async () => { + netFetchMock + .mockResolvedValueOnce(jsonResponse({ membershipType: 'free' })) + .mockResolvedValueOnce(jsonResponse({})) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.status).toBe('unavailable') + expect(limits.usageMetadata?.failureKind).toBe('usage-unavailable') + }) + + it('reports a network failure without leaking the request internals', async () => { + netFetchMock.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND cursor.com')) + const limits = await fetchCursorRateLimits({ authReadResult: session() }) + expect(limits.error).toBe('Cursor usage request failed') + expect(limits.usageMetadata?.failureKind).toBe('network') + }) +}) diff --git a/src/main/rate-limits/cursor-fetcher.ts b/src/main/rate-limits/cursor-fetcher.ts new file mode 100644 index 00000000000..d188127a19a --- /dev/null +++ b/src/main/rate-limits/cursor-fetcher.ts @@ -0,0 +1,251 @@ +import { createHash } from 'node:crypto' +import { net } from 'electron' +import type { ProviderRateLimits, UsageRateLimitSource } from '../../shared/rate-limit-types' +import { + readCursorAuthSession, + type CursorAuthReadResult, + type CursorAuthSession +} from './cursor-auth' +import { cursorSessionCookie, isCursorSessionTokenExpired } from './cursor-session-token' +import { + mapCursorLegacyRequestQuota, + mapCursorUsageSummary, + parseCursorUsageSummary +} from './cursor-usage-mapping' + +const DASHBOARD_ORIGIN = 'https://cursor.com' +const USAGE_SUMMARY_URL = `${DASHBOARD_ORIGIN}/api/usage-summary` +const LEGACY_USAGE_URL = `${DASHBOARD_ORIGIN}/api/usage` +const API_TIMEOUT_MS = 10_000 + +const SIGNED_OUT_MESSAGE = + 'No Cursor sign-in on this computer — sign in with Cursor IDE or `cursor-agent login`' +const EXPIRED_MESSAGE = 'Cursor sign-in expired — run `cursor-agent login` again' + +function usageSource(session: CursorAuthSession): UsageRateLimitSource { + return session.source === 'desktop' ? 'web' : 'cli' +} + +/** + * Stable, non-secret fingerprint of the signed-in account. Hashed because this + * rides `usageMetadata` into the renderer and mobile snapshots, where the raw + * WorkOS subject would be an account identifier nobody needs to see. Readers + * only compare it, so a digest is enough. + */ +function accountFingerprint(session: CursorAuthSession): string { + return createHash('sha256').update(session.token.subject).digest('hex').slice(0, 12) +} + +function result( + status: ProviderRateLimits['status'], + error: string | null, + extra: Partial = {} +): ProviderRateLimits { + return { + provider: 'cursor', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...extra + } +} + +/** + * Dashboard routes answer 403 to a bare session cookie — they check the request + * origin as CSRF defence. Sending what the dashboard itself sends is what they + * expect; without these headers every /api route fails on a valid session. + */ +function requestHeaders(session: CursorAuthSession): Record { + return { + Cookie: cursorSessionCookie(session.token), + Accept: 'application/json', + Origin: DASHBOARD_ORIGIN, + Referer: `${DASHBOARD_ORIGIN}/dashboard` + } +} + +type FetchOutcome = { kind: 'data'; data: unknown } | { kind: 'result'; result: ProviderRateLimits } + +async function fetchDashboardJson( + url: string, + session: CursorAuthSession, + signal?: AbortSignal +): Promise { + const source = usageSource(session) + // Why on failures too: the account-switch guard downstream can only drop a + // previous account's figures when the fresh result names an account, and a + // switch whose first refresh 401s or 5xxs is exactly when it must. + const provenance = accountFingerprint(session) + const requestSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + const res = await net.fetch(url, { + // Why manual: the dashboard bounces an unusable session to /login, and + // 'error' would surface that as a generic network failure instead of the + // actionable sign-in message below. + redirect: 'manual', + headers: requestHeaders(session), + signal: requestSignal + }) + if (res.status === 401 || res.status === 403 || (res.status >= 300 && res.status < 400)) { + return { + kind: 'result', + result: result('error', EXPIRED_MESSAGE, { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'stale-token' + } + }) + } + } + if (res.status === 429) { + const retryAfterSeconds = Number(res.headers.get('retry-after')) + return { + kind: 'result', + result: result('error', 'Cursor usage is rate limited right now', { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'rate-limited', + ...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? { retryAtMs: Date.now() + retryAfterSeconds * 1000 } + : {}) + } + }) + } + } + if (!res.ok) { + return { + kind: 'result', + result: result('error', `Cursor usage request failed (HTTP ${res.status})`, { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'server' + } + }) + } + } + try { + return { kind: 'data', data: await res.json() } + } catch { + return { + kind: 'result', + result: result('error', 'Cursor usage response could not be parsed', { + usageMetadata: { + source, + credentialSource: session.source, + authProvenance: provenance, + failureKind: 'parse' + } + }) + } + } +} + +function credentialFailure(readResult: CursorAuthReadResult): ProviderRateLimits | null { + if (readResult.status === 'missing') { + return result('unavailable', SIGNED_OUT_MESSAGE, { + usageMetadata: { failureKind: 'missing-credentials' } + }) + } + if (readResult.status === 'error') { + return result('error', readResult.error, { + usageMetadata: { failureKind: 'missing-credentials' } + }) + } + // Why: cursor-agent refreshes its own token on use; Orca only reads, so a + // lapsed session is reported instead of spending a request that must 401. + if (isCursorSessionTokenExpired(readResult.session.token)) { + return result('error', EXPIRED_MESSAGE, { + usageMetadata: { + source: usageSource(readResult.session), + credentialSource: readResult.session.source, + authProvenance: accountFingerprint(readResult.session), + failureKind: 'stale-token' + } + }) + } + return null +} + +/** + * Reads the Cursor plan allowance for the account this machine is signed into. + * Orca never runs `cursor-agent login` and never writes Cursor's credentials. + */ +export async function fetchCursorRateLimits( + options: { signal?: AbortSignal; authReadResult?: CursorAuthReadResult } = {} +): Promise { + const readResult = options.authReadResult ?? (await readCursorAuthSession()) + const failure = credentialFailure(readResult) + if (failure || readResult.status !== 'ok') { + return failure ?? result('unavailable', SIGNED_OUT_MESSAGE) + } + const session = readResult.session + const source = usageSource(session) + const metadata = { + source, + credentialSource: session.source, + authProvenance: accountFingerprint(session) + } + + try { + const outcome = await fetchDashboardJson(USAGE_SUMMARY_URL, session, options.signal) + if (outcome.kind === 'result') { + return outcome.result + } + const mapped = mapCursorUsageSummary(parseCursorUsageSummary(outcome.data)) + + // Why: an unlimited plan has no ceiling to divide by. Publish the plan so the + // roster still lists the account, with no misleading bar. + if (mapped.isUnlimited) { + return result('ok', null, { planType: mapped.planType, usageMetadata: metadata }) + } + if (mapped.monthly || mapped.buckets.length > 0) { + return result('ok', null, { + ...(mapped.monthly ? { monthly: mapped.monthly } : {}), + ...(mapped.buckets.length > 0 ? { buckets: mapped.buckets } : {}), + planType: mapped.planType, + usageMetadata: metadata + }) + } + + // Why: accounts still on request-quota billing report nothing under + // individualUsage, so try the older per-model endpoint before concluding + // the account has no visible quota. + const legacy = await fetchDashboardJson( + `${LEGACY_USAGE_URL}?user=${encodeURIComponent(session.token.subject)}`, + session, + options.signal + ) + if (legacy.kind === 'result') { + return legacy.result + } + const legacyWindow = mapCursorLegacyRequestQuota(legacy.data) + if (legacyWindow) { + return result('ok', null, { + monthly: legacyWindow, + planType: mapped.planType, + usageMetadata: metadata + }) + } + + // Why: a 200 with no allowance means the plan exposes no quota (API-key or + // team-billed accounts). 'unavailable' hides the bar the way Claude does on + // API-key billing; 'error' would paint a permanent alert for a healthy account. + return result('unavailable', 'Cursor reported no usage allowance for this account', { + planType: mapped.planType, + usageMetadata: { ...metadata, failureKind: 'usage-unavailable' } + }) + } catch { + return result('error', 'Cursor usage request failed', { + usageMetadata: { ...metadata, failureKind: 'network' } + }) + } +} diff --git a/src/main/rate-limits/cursor-session-token.test.ts b/src/main/rate-limits/cursor-session-token.test.ts new file mode 100644 index 00000000000..61bb7d8221a --- /dev/null +++ b/src/main/rate-limits/cursor-session-token.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + cursorSessionCookie, + isCursorSessionTokenExpired, + parseCursorSessionToken +} from './cursor-session-token' + +type JwtSegment = Record + +function jwt(payload: Record): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode(payload)}.signature` +} + +describe('parseCursorSessionToken', () => { + it('reads the WorkOS subject and expiry from a cursor-agent session token', () => { + const token = parseCursorSessionToken( + jwt({ sub: 'auth0|user_01ABC', exp: 1_800_000_000, aud: 'https://cursor.com' }) + ) + expect(token?.subject).toBe('auth0|user_01ABC') + expect(token?.expiresAtMs).toBe(1_800_000_000_000) + }) + + it('rejects a token without a subject, which cannot address the dashboard', () => { + expect(parseCursorSessionToken(jwt({ exp: 1_800_000_000 }))).toBeNull() + }) + + it('rejects blank and malformed tokens instead of throwing', () => { + expect(parseCursorSessionToken(' ')).toBeNull() + expect(parseCursorSessionToken('not-a-jwt')).toBeNull() + expect(parseCursorSessionToken('a.!!!.c')).toBeNull() + }) + + it('leaves expiry null when the token omits exp', () => { + expect(parseCursorSessionToken(jwt({ sub: 'auth0|user_1' }))?.expiresAtMs).toBeNull() + }) +}) + +describe('isCursorSessionTokenExpired', () => { + const token = parseCursorSessionToken(jwt({ sub: 'auth0|user_1', exp: 2_000 })) + + it('reports a lapsed session', () => { + expect(isCursorSessionTokenExpired(token!, 2_000_001)).toBe(true) + }) + + it('reports a live session', () => { + expect(isCursorSessionTokenExpired(token!, 1_999_999)).toBe(false) + }) + + it('never expires a token with no exp claim', () => { + const noExpiry = parseCursorSessionToken(jwt({ sub: 'auth0|user_1' })) + expect(isCursorSessionTokenExpired(noExpiry!, Number.MAX_SAFE_INTEGER)).toBe(false) + }) +}) + +describe('cursorSessionCookie', () => { + it('escapes the subject and joins it to the token the way the dashboard does', () => { + const token = parseCursorSessionToken(jwt({ sub: 'auth0|user_01ABC' })) + expect(cursorSessionCookie(token!)).toBe( + `WorkosCursorSessionToken=auth0%7Cuser_01ABC%3A%3A${token!.raw}` + ) + }) +}) diff --git a/src/main/rate-limits/cursor-session-token.ts b/src/main/rate-limits/cursor-session-token.ts new file mode 100644 index 00000000000..8db148c61ff --- /dev/null +++ b/src/main/rate-limits/cursor-session-token.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' + +const jwtPayloadSchema = z.object({ sub: z.unknown(), exp: z.unknown() }).partial() + +export type CursorSessionToken = { + raw: string + /** WorkOS subject (`auth0|user_…`), the first half of the dashboard session cookie. */ + subject: string + expiresAtMs: number | null +} + +function decodeJwtPayload(token: string): z.infer | null { + const parts = token.split('.') + if (parts.length < 2 || !parts[1]) { + return null + } + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) + const parsed = jwtPayloadSchema.safeParse( + JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) + ) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + +export function parseCursorSessionToken(raw: string): CursorSessionToken | null { + const trimmed = raw.trim() + if (!trimmed) { + return null + } + const payload = decodeJwtPayload(trimmed) + const subject = payload?.sub + if (typeof subject !== 'string' || subject.trim().length === 0) { + return null + } + const exp = payload?.exp + return { + raw: trimmed, + subject: subject.trim(), + expiresAtMs: typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : null + } +} + +export function isCursorSessionTokenExpired( + token: CursorSessionToken, + nowMs: number = Date.now() +): boolean { + return token.expiresAtMs !== null && token.expiresAtMs <= nowMs +} + +/** Cookie the cursor.com dashboard sends: `::`, URL-escaped. */ +export function cursorSessionCookie(token: CursorSessionToken): string { + return `WorkosCursorSessionToken=${encodeURIComponent(token.subject)}%3A%3A${token.raw}` +} diff --git a/src/main/rate-limits/cursor-usage-mapping.test.ts b/src/main/rate-limits/cursor-usage-mapping.test.ts new file mode 100644 index 00000000000..8e1b5192fde --- /dev/null +++ b/src/main/rate-limits/cursor-usage-mapping.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { + mapCursorLegacyRequestQuota, + mapCursorUsageSummary, + parseCursorUsageSummary +} from './cursor-usage-mapping' + +const CYCLE = { + billingCycleStart: '2026-09-01T00:00:00.000Z', + billingCycleEnd: '2026-10-01T00:00:00.000Z' +} + +describe('mapCursorUsageSummary', () => { + it('maps both plan pools Cursor bills individual accounts from', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + membershipType: 'pro', + individualUsage: { + plan: { enabled: true, used: 1_250, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 10 } + } + }) + expect(mapped.planType).toBe('pro') + expect(mapped.buckets.map((bucket) => [bucket.name, bucket.usedPercent])).toEqual([ + ['Cursor Models', 40], + ['Other Models', 10] + ]) + expect(mapped.monthly?.usedPercent).toBe(25) + expect(mapped.monthly?.resetsAt).toBe(Date.parse(CYCLE.billingCycleEnd)) + }) + + it('prefers the used/limit pair over the rounded percentage Cursor renders', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, used: 1_000, limit: 3_000, totalPercentUsed: 33 } } + }) + expect(mapped.monthly?.usedPercent).toBeCloseTo(33.333, 3) + }) + + it('falls back to the percentage when no cents allowance is reported', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, totalPercentUsed: 62 } } + }) + expect(mapped.monthly?.usedPercent).toBe(62) + }) + + it('adds an on-demand bucket only once the user has enabled on-demand spend', () => { + const disabled = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: true, totalPercentUsed: 5 }, + onDemand: { used: 900, limit: 1_000 } + } + }) + expect(disabled.buckets).toHaveLength(0) + + const enabled = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: true, totalPercentUsed: 5 }, + onDemand: { enabled: true, used: 900, limit: 1_000 } + } + }) + expect(enabled.buckets).toEqual([ + expect.objectContaining({ name: 'On-demand', usedPercent: 90 }) + ]) + }) + + it('clamps an over-consumed pool to 100% instead of overflowing the bar', () => { + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { plan: { enabled: true, used: 7_000, limit: 5_000 } } + }) + expect(mapped.monthly?.usedPercent).toBe(100) + }) + + it('publishes no pools for a plan the account does not own', () => { + // Why: a team-billed account still reports 0% pools. Publishing them would + // paint a healthy 0% meter and skip the request-quota fallback. + const mapped = mapCursorUsageSummary({ + ...CYCLE, + individualUsage: { + plan: { enabled: false, autoPercentUsed: 0, apiPercentUsed: 0, totalPercentUsed: 0 } + } + }) + expect(mapped.buckets).toHaveLength(0) + expect(mapped.monthly).toBeNull() + }) + + it('reports an unlimited plan without inventing a percentage', () => { + const mapped = mapCursorUsageSummary({ ...CYCLE, isUnlimited: true, membershipType: 'ultra' }) + expect(mapped.isUnlimited).toBe(true) + expect(mapped.monthly).toBeNull() + expect(mapped.buckets).toHaveLength(0) + }) + + it('accepts epoch seconds and milliseconds for the billing cycle', () => { + const seconds = mapCursorUsageSummary({ + billingCycleEnd: 1_790_000_000, + individualUsage: { plan: { enabled: true, totalPercentUsed: 1 } } + }) + const millis = mapCursorUsageSummary({ + billingCycleEnd: 1_790_000_000_000, + individualUsage: { plan: { enabled: true, totalPercentUsed: 1 } } + }) + expect(seconds.monthly?.resetsAt).toBe(1_790_000_000_000) + expect(millis.monthly?.resetsAt).toBe(1_790_000_000_000) + }) + + it('keeps the plan when a sibling pool arrives as null', () => { + // Why: the route sends `null` for an absent sub-object, and a stricter schema + // would drop the whole body — plan pools and billing cycle included. + const mapped = mapCursorUsageSummary( + parseCursorUsageSummary({ + ...CYCLE, + membershipType: 'pro', + individualUsage: { plan: { enabled: true, totalPercentUsed: 40 }, onDemand: null } + }) + ) + expect(mapped.monthly?.usedPercent).toBe(40) + expect(mapped.planType).toBe('pro') + }) + + it('survives a null individualUsage block', () => { + const mapped = mapCursorUsageSummary( + parseCursorUsageSummary({ ...CYCLE, membershipType: 'free', individualUsage: null }) + ) + expect(mapped.planType).toBe('free') + expect(mapped.monthly).toBeNull() + }) + + it('returns nothing to publish for an empty payload', () => { + const mapped = mapCursorUsageSummary({}) + expect(mapped.monthly).toBeNull() + expect(mapped.buckets).toHaveLength(0) + expect(mapped.planType).toBeNull() + }) + + it('derives the window from the reported cycle rather than assuming 30 days', () => { + const mapped = mapCursorUsageSummary({ + billingCycleStart: '2026-09-01T00:00:00.000Z', + billingCycleEnd: '2026-09-08T00:00:00.000Z', + individualUsage: { plan: { enabled: true, totalPercentUsed: 10 } } + }) + expect(mapped.monthly?.windowMinutes).toBe(7 * 24 * 60) + }) +}) + +describe('mapCursorLegacyRequestQuota', () => { + it('uses the premium gpt-4 bucket on request-quota plans', () => { + const window = mapCursorLegacyRequestQuota({ + 'gpt-4': { numRequests: 250, maxRequestUsage: 500 }, + 'gpt-3.5-turbo': { numRequests: 10, maxRequestUsage: 9_999 }, + startOfMonth: '2026-09-01T00:00:00.000Z' + }) + expect(window?.usedPercent).toBe(50) + expect(window?.resetsAt).toBe(new Date('2026-10-01T00:00:00.000Z').getTime()) + }) + + it('falls back to the largest ceiling when no gpt-4 bucket is present', () => { + const window = mapCursorLegacyRequestQuota({ + small: { numRequests: 1, maxRequestUsage: 10 }, + large: { numRequests: 20, maxRequestUsage: 100 } + }) + expect(window?.usedPercent).toBe(20) + }) + + it('clamps a month-end cycle start instead of overflowing into the next month', () => { + const window = mapCursorLegacyRequestQuota({ + 'gpt-4': { numRequests: 1, maxRequestUsage: 10 }, + startOfMonth: '2026-01-31T00:00:00.000Z' + }) + expect(window?.resetsAt).toBe(Date.parse('2026-02-28T00:00:00.000Z')) + }) + + it('ignores unmetered buckets and returns null when nothing is metered', () => { + expect( + mapCursorLegacyRequestQuota({ free: { numRequests: 3, maxRequestUsage: null } }) + ).toBeNull() + expect(mapCursorLegacyRequestQuota(null)).toBeNull() + }) +}) diff --git a/src/main/rate-limits/cursor-usage-mapping.ts b/src/main/rate-limits/cursor-usage-mapping.ts new file mode 100644 index 00000000000..66a29d46469 --- /dev/null +++ b/src/main/rate-limits/cursor-usage-mapping.ts @@ -0,0 +1,234 @@ +import { z } from 'zod' +import { + CURSOR_MODELS_BUCKET_NAME, + CURSOR_ON_DEMAND_BUCKET_NAME, + CURSOR_OTHER_MODELS_BUCKET_NAME +} from '../../shared/cursor-usage-buckets' +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' + +const MONTHLY_WINDOW_MINUTES = 43_200 + +const cursorPoolSchema = z + .object({ + enabled: z.unknown(), + used: z.unknown(), + limit: z.unknown(), + totalPercentUsed: z.unknown(), + autoPercentUsed: z.unknown(), + apiPercentUsed: z.unknown() + }) + .partial() + +const cursorUsageSummarySchema = z + .object({ + billingCycleStart: z.unknown(), + billingCycleEnd: z.unknown(), + membershipType: z.unknown(), + isUnlimited: z.unknown(), + // Why nullish and not optional: this route is undocumented and sends `null` + // for an absent sub-object. With `.optional()` one null pool fails the parse + // for the whole body, discarding valid pools and the billing cycle with it. + individualUsage: z + .object({ plan: cursorPoolSchema.nullish(), onDemand: cursorPoolSchema.nullish() }) + .partial() + .nullish() + }) + .partial() + +type CursorPool = z.infer +export type CursorUsageSummary = z.infer + +/** Accepts the raw dashboard body; an unrecognised shape maps to "nothing reported". */ +export function parseCursorUsageSummary(data: unknown): CursorUsageSummary { + const parsed = cursorUsageSummarySchema.safeParse(data) + return parsed.success ? parsed.data : {} +} + +export type CursorUsageMapping = { + monthly: RateLimitWindow | null + buckets: RateLimitBucket[] + planType: string | null + isUnlimited: boolean +} + +function finiteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +export function parseCursorTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + // Why: Cursor mixes epoch seconds and milliseconds across billing fields. + return value > 1e12 ? value : value * 1000 + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const trimmed = value.trim() + if (/^\d+$/.test(trimmed)) { + return parseCursorTimestampMs(Number(trimmed)) + } + const parsed = Date.parse(trimmed) + return Number.isFinite(parsed) ? parsed : null +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function resetDescription(resetsAtMs: number | null): string | null { + if (resetsAtMs === null) { + return null + } + const date = new Date(resetsAtMs) + if (Number.isNaN(date.getTime())) { + return null + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function windowMinutesFor(startMs: number | null, endMs: number | null): number { + if (startMs === null || endMs === null || endMs <= startMs) { + return MONTHLY_WINDOW_MINUTES + } + return Math.max(1, Math.round((endMs - startMs) / 60_000)) +} + +/** + * Percent consumed for one pool. `used / limit` wins over the sibling percentage + * fields: the raw pair is internally consistent, while the percentages are + * pre-rounded for the dashboard's own copy and disagree with it on real accounts. + */ +function poolPercent( + pool: CursorPool | null | undefined, + percentField: keyof CursorPool +): number | null { + if (!pool) { + return null + } + const used = finiteNumber(pool.used) + const limit = finiteNumber(pool.limit) + if (used !== null && limit !== null && limit > 0) { + return (used / limit) * 100 + } + return finiteNumber(pool[percentField]) +} + +export function mapCursorUsageSummary(summary: CursorUsageSummary): CursorUsageMapping { + const startMs = parseCursorTimestampMs(summary.billingCycleStart) + const endMs = parseCursorTimestampMs(summary.billingCycleEnd) + const windowMinutes = windowMinutesFor(startMs, endMs) + const description = resetDescription(endMs) + const toWindow = (percent: number): RateLimitWindow => ({ + usedPercent: clampPercent(percent), + windowMinutes, + resetsAt: endMs, + resetDescription: description + }) + + const plan = summary.individualUsage?.plan + // Why: a team-billed account still reports 0% pools it does not own. Publishing + // them as buckets would paint a healthy 0% meter and skip the legacy fallback. + const planEnabled = plan?.enabled !== false + const buckets: RateLimitBucket[] = [] + const cursorModels = planEnabled ? finiteNumber(plan?.autoPercentUsed) : null + if (cursorModels !== null) { + buckets.push({ name: CURSOR_MODELS_BUCKET_NAME, ...toWindow(cursorModels) }) + } + const otherModels = planEnabled ? finiteNumber(plan?.apiPercentUsed) : null + if (otherModels !== null) { + buckets.push({ name: CURSOR_OTHER_MODELS_BUCKET_NAME, ...toWindow(otherModels) }) + } + + const onDemand = summary.individualUsage?.onDemand + const onDemandPercent = + onDemand?.enabled === true ? poolPercent(onDemand, 'totalPercentUsed') : null + if (onDemandPercent !== null) { + buckets.push({ name: CURSOR_ON_DEMAND_BUCKET_NAME, ...toWindow(onDemandPercent) }) + } + + const planPercent = planEnabled ? poolPercent(plan, 'totalPercentUsed') : null + const membership = summary.membershipType + return { + monthly: planPercent === null ? null : toWindow(planPercent), + buckets, + planType: typeof membership === 'string' && membership.trim() ? membership.trim() : null, + isUnlimited: summary.isUnlimited === true + } +} + +/** + * Advances a cycle start by one month in UTC, clamping the day so a Jan 31 start + * lands on Feb 28/29 instead of overflowing into March. + */ +function addOneMonthUtc(startMs: number): number { + const start = new Date(startMs) + const year = start.getUTCFullYear() + const month = start.getUTCMonth() + const lastDayOfNextMonth = new Date(Date.UTC(year, month + 2, 0)).getUTCDate() + return Date.UTC( + year, + month + 1, + Math.min(start.getUTCDate(), lastDayOfNextMonth), + start.getUTCHours(), + start.getUTCMinutes(), + start.getUTCSeconds(), + start.getUTCMilliseconds() + ) +} + +const legacyBucketSchema = z + .object({ numRequests: z.unknown(), maxRequestUsage: z.unknown() }) + .partial() + +const legacyQuotaSchema = z.record(z.string(), z.union([legacyBucketSchema, z.unknown()])) + +/** + * Request-quota plans predate spend-based billing and report a per-model ceiling + * instead of a cents allowance, so those accounts still get a bar. + */ +export function mapCursorLegacyRequestQuota(payload: unknown): RateLimitWindow | null { + const parsed = legacyQuotaSchema.safeParse(payload) + if (!parsed.success) { + return null + } + const quotas: { name: string; used: number; limit: number }[] = [] + for (const [name, value] of Object.entries(parsed.data)) { + const entry = legacyBucketSchema.safeParse(value) + if (!entry.success) { + continue + } + const limit = finiteNumber(entry.data.maxRequestUsage) + const used = finiteNumber(entry.data.numRequests) + if (limit === null || limit <= 0 || used === null) { + continue + } + quotas.push({ name, used, limit }) + } + if (quotas.length === 0) { + return null + } + // Why: 'gpt-4' is the premium bucket on legacy plans; otherwise the largest + // ceiling is the headline quota on every shape seen so far. + const quota = + quotas.find((entry) => entry.name === 'gpt-4') ?? + quotas.sort((left, right) => right.limit - left.limit)[0] + if (!quota) { + return null + } + + const startOfMonth = parseCursorTimestampMs(parsed.data.startOfMonth) + const resetsAt = startOfMonth === null ? null : addOneMonthUtc(startOfMonth) + return { + usedPercent: clampPercent((quota.used / quota.limit) * 100), + windowMinutes: MONTHLY_WINDOW_MINUTES, + resetsAt, + resetDescription: resetDescription(resetsAt) + } +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 25f7baeb78d..021de01108d 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -8,6 +8,8 @@ import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' +import { fetchCursorRateLimits } from './cursor-fetcher' +import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -89,6 +91,7 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,8 +109,10 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) + vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) } type RateLimitWindow = Parameters[0] diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 16373024614..2deeadfcdaa 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -40,6 +40,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f5afe9423d..9f2fc2c8244 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -39,6 +39,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts new file mode 100644 index 00000000000..37984b89a52 --- /dev/null +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RateLimitService } from './service' +import { fetchCursorRateLimits } from './cursor-fetcher' +import { readCursorAuthSession } from './cursor-auth' +import { parseCursorSessionToken } from './cursor-session-token' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchCodexRateLimits } from './codex-fetcher' +import { okProvider, resetRateLimitProviderMocks } from './rate-limit-service-test-harness' + +vi.mock('./claude-fetcher', () => ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) +vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) +vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) + +type JwtSegment = Record + +function jwt(): string { + const encode = (value: JwtSegment): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp: 4_000_000_000 })}.sig` +} + +function signedInResult(): Awaited> { + return { + status: 'ok', + session: { + token: parseCursorSessionToken(jwt())!, + source: 'keychain', + email: 'dev@example.com', + displayName: 'Dev', + membershipType: 'pro', + subscriptionStatus: 'active' + } + } +} + +describe('RateLimitService Cursor usage', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0)) + }) + + it('does not probe the Cursor keychain while callers read state snapshots', () => { + const service = new RateLimitService() + service.getState() + service.getState() + expect(readCursorAuthSession).not.toHaveBeenCalled() + }) + + it('reports Cursor as unconfigured until a fetch cycle finds a session', async () => { + const service = new RateLimitService() + expect(service.getState().cursorAuthConfigured).toBe(false) + + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(okProvider('cursor', 42)) + await service.refresh() + + expect(service.getState().cursorAuthConfigured).toBe(true) + expect(service.getState().cursor?.status).toBe('ok') + }) + + it('passes the resolved session to the fetcher instead of reading it twice', async () => { + const authReadResult = signedInResult() + vi.mocked(readCursorAuthSession).mockResolvedValue(authReadResult) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(okProvider('cursor', 7)) + + await new RateLimitService().refresh() + + expect(readCursorAuthSession).toHaveBeenCalledTimes(1) + expect(fetchCursorRateLimits).toHaveBeenCalledWith(expect.objectContaining({ authReadResult })) + }) + + it('publishes an error snapshot instead of dropping the cycle when the fetch throws', async () => { + vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) + vi.mocked(fetchCursorRateLimits).mockRejectedValue(new Error('boom')) + + const service = new RateLimitService() + await service.refresh() + + const state = service.getState() + expect(state.cursor).toMatchObject({ provider: 'cursor', status: 'error', error: 'boom' }) + // Why: a Cursor failure must not take the rest of the cycle down with it. + expect(state.gemini?.status).toBe('ok') + }) + + it("drops a previous account's usage when the signed-in account changes", async () => { + // Why: the stale policy keeps a recent snapshot through a failed refresh. Across + // an account switch that would name the new account beside the old one's figures. + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + ...okProvider('cursor', 80), + usageMetadata: { source: 'cli', authProvenance: 'account-a' } + }) + const service = new RateLimitService() + await service.refresh() + expect(service.getState().cursor?.session?.usedPercent).toBe(80) + + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + provider: 'cursor', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Cursor sign-in expired — run `cursor-agent login` again', + status: 'error', + // Why this exact shape: it is what cursor-fetcher returns for a readable but + // rejected session, which is the failure an account switch actually hits. + usageMetadata: { source: 'cli', failureKind: 'stale-token', authProvenance: 'account-b' } + }) + await service.refresh() + expect(service.getState().cursor?.session).toBeNull() + expect(service.getState().cursor?.status).toBe('error') + }) + + it('keeps the last reading when a refresh fails without naming an account', async () => { + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + ...okProvider('cursor', 60), + usageMetadata: { source: 'cli', authProvenance: 'account-a' } + }) + const service = new RateLimitService() + await service.refresh() + + vi.mocked(fetchCursorRateLimits).mockResolvedValue({ + provider: 'cursor', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Cursor usage request failed', + status: 'error', + usageMetadata: { source: 'cli' } + }) + await service.refresh() + expect(service.getState().cursor?.session?.usedPercent).toBe(60) + }) + + it('clears cursorAuthConfigured once the local session goes away', async () => { + vi.mocked(readCursorAuthSession).mockResolvedValue(signedInResult()) + vi.mocked(fetchCursorRateLimits).mockResolvedValue(okProvider('cursor', 5)) + const service = new RateLimitService() + await service.refresh() + expect(service.getState().cursorAuthConfigured).toBe(true) + + vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) + await service.refresh() + expect(service.getState().cursorAuthConfigured).toBe(false) + }) +}) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 1dee94e8c99..300292efcc2 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -47,6 +47,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 9b9137568fb..3c51a09aa5f 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -44,6 +44,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 89b311827b6..8b225336472 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -41,6 +41,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 9b70fa0ea51..b184d51eda6 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -48,6 +48,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 2b53c0919a1..7c83bd7d026 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -49,6 +49,14 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./cursor-fetcher', () => ({ + fetchCursorRateLimits: vi.fn() +})) + +vi.mock('./cursor-auth', () => ({ + readCursorAuthSession: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index f2629aa4fea..2a788b1cf93 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -127,6 +127,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco minimaxApiKeyConfigured: hasMiniMaxApiKey(), opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured, grokAuthConfigured: this.grokAuthConfigured, + cursorAuthConfigured: this.cursorAuthConfigured, claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38f..9e9d9506a05 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,6 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -34,7 +35,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + cursorResultPromise } = prepared if (signal.aborted) { return @@ -191,25 +193,29 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) - const grokResult = await grokResultPromise + const [grokSettled, cursorSettled] = await Promise.all([grokResultPromise, cursorResultPromise]) if (signal.aborted) { return } - const grok = - grokResult.status === 'fulfilled' - ? grokResult.value - : ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) + const grok = settleSiblingProviderResult('grok', grokSettled) + const cursor = settleSiblingProviderResult('cursor', cursorSettled) + // Why: the stale policy keeps a recent snapshot through a failed refresh, but + // a snapshot belonging to a different Cursor account must not survive the + // switch — the Accounts pane would name the new account beside the old + // account's figures. Only a known-and-changed identity clears it, so an + // errored refresh that reports no account still keeps its own last reading. + const previousCursorAccount = previousState.cursor?.usageMetadata?.authProvenance + const cursorAccount = cursor.usageMetadata?.authProvenance + const cursorAccountChanged = + previousCursorAccount !== undefined && + cursorAccount !== undefined && + previousCursorAccount !== cursorAccount this.trackActiveFailureStreak('grok', grok) + this.trackActiveFailureStreak('cursor', cursor) this.updateState({ ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) + grok: this.applyStalePolicy(grok, previousState.grok), + cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 3e4f77b906d..df36011ab31 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -3,10 +3,13 @@ import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' +import { fetchCursorRateLimits } from '../cursor-fetcher' +import { readCursorAuthSession } from '../cursor-auth' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' import { RateLimitServiceFetchPolicy } from './service-fetch-policy' +import type { SettledProviderResult } from './service-sibling-provider-result' import type { ClaudeRuntimeAuthPreparation, InternalRateLimitState, @@ -39,9 +42,8 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult ] - grokResultPromise: Promise< - { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } - > + grokResultPromise: Promise + cursorResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -127,9 +129,22 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') + grok: this.withFetchingStatus(previousState.grok, 'grok'), + cursor: this.withFetchingStatus(previousState.cursor, 'cursor') }) + // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the + // provider's own promise instead of blocking the rest of the cycle on it. + const cursorResultPromise = readCursorAuthSession() + .then((authReadResult) => { + this.cursorAuthConfigured = authReadResult.status === 'ok' + return fetchCursorRateLimits({ signal, authReadResult }) + }) + .then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + const missingWslCodexHome = codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ @@ -215,7 +230,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + cursorResultPromise } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index c861726cc52..e49c003cbb1 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -78,7 +78,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue kimi: this.state.kimi, minimax: this.state.minimax, grok: this.state.grok, - antigravity: this.state.antigravity + antigravity: this.state.antigravity, + cursor: this.state.cursor } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts index db00053fdcf..75f032159fa 100644 --- a/src/main/rate-limits/service/service-result-policy.ts +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -83,15 +83,7 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch protected withFetchingStatus( current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' + provider: ActiveRateLimitProvider ): ProviderRateLimits { if (!current) { return { diff --git a/src/main/rate-limits/service/service-sibling-provider-result.ts b/src/main/rate-limits/service/service-sibling-provider-result.ts new file mode 100644 index 00000000000..ff5337c7f70 --- /dev/null +++ b/src/main/rate-limits/service/service-sibling-provider-result.ts @@ -0,0 +1,26 @@ +import type { ProviderRateLimits } from './service-types' + +export type SettledProviderResult = + | { status: 'fulfilled'; value: ProviderRateLimits } + | { status: 'rejected'; reason: unknown } + +/** + * Collapses a provider that resolves on its own promise (outside the main + * `Promise.allSettled` tuple) into a publishable snapshot. + */ +export function settleSiblingProviderResult( + provider: ProviderRateLimits['provider'], + settled: SettledProviderResult +): ProviderRateLimits { + if (settled.status === 'fulfilled') { + return settled.value + } + return { + provider, + session: null, + weekly: null, + updatedAt: Date.now(), + error: settled.reason instanceof Error ? settled.reason.message : 'Unknown error', + status: 'error' + } +} diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 5cea2a26338..a10bfa3f3c0 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -31,9 +31,13 @@ export abstract class RateLimitServiceState { kimi: null, antigravity: null, minimax: null, - grok: null + grok: null, + cursor: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + // Why: the Cursor probe reads the macOS Keychain, so it cannot run synchronously + // at construction the way Grok's auth-file probe does; each fetch cycle sets it. + protected cursorAuthConfigured = false protected openCodeGoApiKeyConfigured = false protected pollInterval: number = DEFAULT_POLL_MS protected timer: ReturnType | null = null @@ -47,7 +51,8 @@ export abstract class RateLimitServiceState { kimi: 0, minimax: 0, grok: 0, - antigravity: 0 + antigravity: 0, + cursor: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -58,7 +63,8 @@ export abstract class RateLimitServiceState { kimi: 0, minimax: 0, grok: 0, - antigravity: 0 + antigravity: 0, + cursor: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 92204150d9a..9783f37f77d 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -109,6 +109,7 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null + cursor: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 26527b9a1e5..7e4b2def434 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2,6 +2,7 @@ import type { ClaudeAccountsApi, CodexAccountsApi, CodexConfigSyncApi, + CursorAccountsApi, GrokAccountsApi, MinimaxCredentialsApi } from './api/agent-account-api' @@ -143,6 +144,7 @@ export type PreloadApi = { rateLimits: RateLimitsApi minimaxCredentials: MinimaxCredentialsApi grokAccounts: GrokAccountsApi + cursorAccounts: CursorAccountsApi ssh: SshApi automations: AutomationsApi wsl: RuntimeApi['wsl'] diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index f041e41f8b7..e8d574c285b 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -3,7 +3,7 @@ import type { CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' -import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { CursorAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' export type CodexAccountsApi = { list: () => Promise @@ -62,6 +62,10 @@ export type GrokAccountsApi = { getStatus: () => Promise } +export type CursorAccountsApi = { + getStatus: () => Promise +} + export type MinimaxCredentialsApi = { // Why: cookie + API key each live in their own safeStorage file, so the // status separates them. 'configured' stays as the OR so existing callers diff --git a/src/preload/api/cursor-accounts-bridge.ts b/src/preload/api/cursor-accounts-bridge.ts new file mode 100644 index 00000000000..20f8d5d6a00 --- /dev/null +++ b/src/preload/api/cursor-accounts-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { CursorAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const cursorAccountsApi = { + getStatus: (): Promise => ipcRenderer.invoke('cursorAccounts:getStatus') +} satisfies PreloadApi['cursorAccounts'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 3267b981775..742a8168196 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -78,6 +78,7 @@ import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' +import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -177,6 +178,7 @@ const api = { rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, grokAccounts: grokAccountsApi, + cursorAccounts: cursorAccountsApi, ssh: sshApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 3af24a986fd..7e7bea38d14 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -18,6 +18,7 @@ import { getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, + getAccountsCursorSearchEntries, getAccountsGrokSearchEntries, getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, @@ -36,6 +37,7 @@ import { } from './provider-account-visibility' import { Separator } from '../ui/separator' import { GrokAccountsSection } from './GrokAccountsSection' +import { CursorAccountsSection } from './CursorAccountsSection' import type { AccountsPaneProps, AccountsPaneSectionModel, @@ -379,6 +381,9 @@ export function AccountsPane({ : null, matchesSettingsSearch(searchQuery, getAccountsGrokSearchEntries()) ? ( + ) : null, + matchesSettingsSearch(searchQuery, getAccountsCursorSearchEntries()) ? ( + ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 1d82af8ed7d..03c7912a772 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -53,7 +53,8 @@ function recordStatusBarToggleInteraction( id === 'kimi' || id === 'antigravity' || id === 'minimax' || - id === 'grok' + id === 'grok' || + id === 'cursor' ) { recordFeatureInteraction('usage-tracking') } diff --git a/src/renderer/src/components/settings/CursorAccountsSection.tsx b/src/renderer/src/components/settings/CursorAccountsSection.tsx new file mode 100644 index 00000000000..705e7a6e33b --- /dev/null +++ b/src/renderer/src/components/settings/CursorAccountsSection.tsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ExternalLink, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import type { CursorAccountStatus } from '../../../../shared/rate-limit-types' +import { SearchableSetting } from './SearchableSetting' + +const CURSOR_USAGE_DASHBOARD_URL = 'https://cursor.com/dashboard/spending' +const SEARCH_KEYWORDS = ['cursor', 'usage', 'plan', 'spend', 'billing', 'rate limit'] + +function credentialSourceLabel(source: CursorAccountStatus['credentialSource']): string | null { + if (source === 'keychain') { + return translate( + 'auto.components.settings.CursorAccountsSection.source.keychain', + 'macOS Keychain (cursor-agent)' + ) + } + if (source === 'cli') { + return translate( + 'auto.components.settings.CursorAccountsSection.source.cli', + 'Cursor CLI auth file' + ) + } + if (source === 'desktop') { + return translate('auto.components.settings.CursorAccountsSection.source.desktop', 'Cursor IDE') + } + return null +} + +export function CursorAccountsSection(): React.JSX.Element { + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const cursorUsage = useAppStore((s) => s.rateLimits.cursor) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + // Why a generation counter: two usage updates can overlap, and the slower read + // would otherwise land last and repaint the pane with the older account. + const latestLoad = useRef(0) + + const loadStatus = useCallback(async (): Promise => { + const generation = latestLoad.current + 1 + latestLoad.current = generation + const apply = (next: CursorAccountStatus): void => { + if (latestLoad.current !== generation) { + return + } + setStatus(next) + setLoading(false) + } + try { + apply(await window.api.cursorAccounts.getStatus()) + } catch (error) { + console.error('Failed to load Cursor account status:', error) + apply({ + signedIn: false, + email: null, + displayName: null, + credentialSource: null, + planType: null, + tokenFresh: false, + error: error instanceof Error ? error.message : 'Unable to read Cursor sign-in' + }) + } + }, []) + + // Why: after a background usage fetch, sign-in state may change — reload status then. + useEffect(() => { + void loadStatus() + }, [loadStatus, cursorUsage?.updatedAt]) + + // Why: the effect below already reloads status when the refresh lands a new + // snapshot, and each load is a keychain read for an item Orca does not own. + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + await refreshRateLimits() + } finally { + setRefreshing(false) + } + } + + const signedIn = status?.signedIn === true + const tokenFresh = status?.tokenFresh === true + const sourceLabel = credentialSourceLabel(status?.credentialSource ?? null) + const pools = cursorUsage?.buckets ?? [] + const monthly = cursorUsage?.monthly ?? null + // Why: hiding the row entirely leaves signed-in users with no explanation when + // Cursor reports no allowance or the refresh failed — never let unknown usage + // read as healthy, and never let a failed fetch render as an empty section. + const unavailableReason = + signedIn && pools.length === 0 && !monthly + ? cursorUsage?.status === 'unavailable' || cursorUsage?.status === 'error' + ? (cursorUsage.error ?? null) + : null + : null + // Why separate from the row above: a stale snapshot still renders its numbers, + // so the failure has to be said beside them rather than instead of them. + const staleUsageError = + signedIn && cursorUsage?.status === 'error' && (pools.length > 0 || monthly) + ? (cursorUsage.error ?? null) + : null + + return ( +
+
+
+

+ + {translate('auto.components.settings.CursorAccountsSection.title', 'Cursor')} +

+

+ {translate( + 'auto.components.settings.CursorAccountsSection.subtitle', + 'Shows your monthly Cursor plan usage from the sign-in already on this computer. Orca only reads it — it never changes your Cursor login.' + )} +

+
+ + {translate( + 'auto.components.settings.CursorAccountsSection.dashboardLink', + 'Cursor dashboard' + )} + + +
+ +
+ +
+ {loading ? ( +

+ {translate('auto.components.settings.CursorAccountsSection.loading', 'Loading…')} +

+ ) : signedIn ? ( + <> +

+ {status?.email ?? + status?.displayName ?? + translate('auto.components.settings.CursorAccountsSection.signedIn', 'Signed in')} +

+

+ {tokenFresh + ? sourceLabel + ? translate( + 'auto.components.settings.CursorAccountsSection.signedInFrom', + 'Signed in. Orca reads the session stored in {{source}}.', + { source: sourceLabel } + ) + : translate( + 'auto.components.settings.CursorAccountsSection.signedInGeneric', + 'Signed in. Orca reads the Cursor session stored on this computer.' + ) + : translate( + 'auto.components.settings.CursorAccountsSection.expired', + 'Sign-in expired — run cursor-agent login on the computer running Orca, then click Refresh usage.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.CursorAccountsSection.signedOut', + 'No Cursor sign-in found on this computer' + )} +

+

+ {translate( + 'auto.components.settings.CursorAccountsSection.signedOutHelp', + 'Sign in with Cursor IDE, or run cursor-agent login in a terminal, then click Refresh usage here.' + )} +

+ + )} + {status?.error ?

{status.error}

: null} +
+ +
+ + {pools.length > 0 || monthly ? ( + +
+ {pools.map((pool) => ( +
+ + {Math.round(pool.usedPercent)}% + + {pool.name} +
+ ))} + {monthly ? ( +
+ + {Math.round(monthly.usedPercent)}% + + + {translate('auto.components.settings.CursorAccountsSection.planTotal', 'Plan')} + +
+ ) : null} + {(monthly ?? pools[0])?.resetDescription ? ( +

+ {translate( + 'auto.components.settings.CursorAccountsSection.resets', + 'Resets {{when}}', + { when: (monthly ?? pools[0])?.resetDescription ?? '' } + )} +

+ ) : null} + {staleUsageError ? ( +

+ {translate( + 'auto.components.settings.CursorAccountsSection.staleUsage', + 'Last known usage — the latest refresh failed: {{reason}}', + { reason: staleUsageError } + )} +

+ ) : null} +
+
+ ) : unavailableReason ? ( + +

{unavailableReason}

+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 856e22213ca..60fa795caa9 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -217,6 +217,38 @@ export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.cursor.title', 'Cursor Usage'), + description: translate( + 'auto.components.settings.accounts.search.cursor.description', + 'Monthly plan usage read from the Cursor sign-in already on this computer (cursor-agent login).' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.cursor.kw.cursor', + 'cursor' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.cursor.kw.usage', + 'usage' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.cursor.kw.spend', + 'spend' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.cursor.kw.rateLimit', + 'rate limit' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.cursor.kw.statusBar', + 'status bar' + ) + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), @@ -224,5 +256,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsGeminiSearchEntries(), ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), - ...getAccountsGrokSearchEntries() + ...getAccountsGrokSearchEntries(), + ...getAccountsCursorSearchEntries() ]) diff --git a/src/renderer/src/components/settings/appearance-status-bar-cursor-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-cursor-toggle-search.ts new file mode 100644 index 00000000000..efff55c67f6 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-cursor-toggle-search.ts @@ -0,0 +1,39 @@ +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getCursorStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'cursor', + title: translate('auto.components.settings.appearance.search.cursor.title', 'Cursor Usage'), + description: translate( + 'auto.components.settings.appearance.search.cursor.description', + 'Show monthly Cursor plan usage from the Cursor sign-in on this computer.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.cursor.kw.cursor', + 'cursor' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.cursorToggleDescription', + 'Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-minimax-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-minimax-toggle-search.ts new file mode 100644 index 00000000000..4b514acd2eb --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-minimax-toggle-search.ts @@ -0,0 +1,41 @@ +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getMiniMaxStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'minimax', + title: translate('auto.components.settings.appearance.search.0f08f6b483', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.appearance.search.e46178eb1b', + 'Show MiniMax subscription usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.d16378a88f', 'minimax'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.d9e7cef86f', 'cookie'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.25e51b62ee', + 'rate limit' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.minimaxToggleDescription', + 'Show MiniMax subscription usage for the active workspace.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 9be73da6a91..8a4d65f21fd 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -3,7 +3,9 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-bar-antigravity-toggle-search' +import { getMiniMaxStatusBarToggleSearchEntry } from './appearance-status-bar-minimax-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' +import { getCursorStatusBarToggleSearchEntry } from './appearance-status-bar-cursor-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -164,42 +166,9 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Kimi subscription usage for the active workspace.' ) }, - { - id: 'minimax', - title: translate('auto.components.settings.appearance.search.0f08f6b483', 'MiniMax Usage'), - description: translate( - 'auto.components.settings.appearance.search.e46178eb1b', - 'Show MiniMax subscription usage in the status bar.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.896eb53fd4', - 'status bar' - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.d16378a88f', - 'minimax' - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.de586def95', - 'subscription' - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.d9e7cef86f', - 'cookie' - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.25e51b62ee', - 'rate limit' - ) - ], - toggleDescription: translate( - 'settings.appearance.statusBar.minimaxToggleDescription', - 'Show MiniMax subscription usage for the active workspace.' - ) - }, + getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), + getCursorStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 24de1094534..3a9bc0cba48 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -11,6 +11,7 @@ import { getTightestUsageSection } from './UsageRosterPanel' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' +import { isCursorUsageBucket } from '../../../../shared/cursor-usage-buckets' function MiniBar({ usedPct, @@ -82,6 +83,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'M' case 'grok': return 'R' + case 'cursor': + return 'U' case 'codex': return 'X' } @@ -94,6 +97,12 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { // Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) +// Why: the allowlist above is Gemini's. Cursor's pools are its whole meter — filtering +// them out leaves a signed-in account with an icon and no number at all. +function isVisibleStatusBarBucket(name: string): boolean { + return STATUS_BAR_BUCKET_NAMES.has(name) || isCursorUsageBucket(name) +} + function VerboseProviderUsage({ p, display @@ -102,7 +111,10 @@ function VerboseProviderUsage({ display: UsagePercentageDisplay }): React.JSX.Element { if (p.buckets && p.buckets.length > 0) { - const visibleBuckets = p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name)) + const visibleBuckets = p.buckets.filter((bucket) => isVisibleStatusBarBucket(bucket.name)) + // Why: a provider whose buckets are all filtered out still has a headline + // window worth showing rather than rendering an empty segment. + const fallbackWindow = p.session ?? p.monthly ?? null return ( <> {visibleBuckets.map((bucket, index) => ( @@ -113,10 +125,10 @@ function VerboseProviderUsage({
))} - {visibleBuckets.length === 0 && p.session ? ( + {visibleBuckets.length === 0 && fallbackWindow ? ( ) : null} diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index 7b47b6bb6d2..a2dab03d9c0 100644 --- a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx +++ b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx @@ -133,6 +133,16 @@ export function StatusBarVisibilityMenu({ {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} )} + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('cursor') + }} + > + + {translate('auto.components.status.bar.StatusBar.cursorUsageMenu', 'Cursor Usage')} + { diff --git a/src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx b/src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx new file mode 100644 index 00000000000..eb3781af391 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-cursor-segment.test.tsx @@ -0,0 +1,96 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { + CURSOR_MODELS_BUCKET_NAME, + CURSOR_OTHER_MODELS_BUCKET_NAME +} from '../../../../shared/cursor-usage-buckets' +import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: 'used' }) +})) + +function windowOf(usedPercent: number): RateLimitWindow { + return { usedPercent, windowMinutes: 43_200, resetsAt: null, resetDescription: null } +} + +function cursorLimits(overrides: Partial = {}): ProviderRateLimits { + return { + provider: 'cursor', + session: null, + weekly: null, + monthly: windowOf(92), + buckets: [ + { name: CURSOR_MODELS_BUCKET_NAME, ...windowOf(45) }, + { name: CURSOR_OTHER_MODELS_BUCKET_NAME, ...windowOf(50) } + ], + updatedAt: Date.now(), + error: null, + status: 'ok', + ...overrides + } +} + +describe('Cursor status-bar segment', () => { + it('renders both plan pools instead of filtering them out as unknown buckets', async () => { + // Why: the verbose bucket allowlist was written for Gemini's experimental + // models. Cursor's pools are its whole meter, so a signed-in account + // rendered an icon with no percentage at all. + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain(CURSOR_MODELS_BUCKET_NAME) + expect(markup).toContain(CURSOR_OTHER_MODELS_BUCKET_NAME) + expect(markup).toContain('45%') + expect(markup).toContain('50%') + }) + + it('falls back to the plan total when no bucket is renderable', async () => { + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + expect(markup).not.toContain('Unknown pool') + expect(markup).toContain('92%') + }) +}) + +describe('getWindowSections for Cursor', () => { + it('keeps the plan total beside the pools', async () => { + // Why: Cursor puts the headline number in `monthly` and the sub-pools in + // buckets. Dropping `monthly` hid the figure closest to the user's cap — a + // plan at 92% read as 50% in the roster and the tooltip. + const { getWindowSections } = await import('./tooltip') + const sections = getWindowSections(cursorLimits()) + expect(sections.find((section) => section.label === 'Plan')?.window?.usedPercent).toBe(92) + expect(sections.map((section) => section.label)).toContain(CURSOR_MODELS_BUCKET_NAME) + }) + + it('leaves other providers unchanged when they report no monthly window', async () => { + const { getWindowSections } = await import('./tooltip') + const sections = getWindowSections(cursorLimits({ monthly: null, provider: 'gemini' })) + expect(sections.some((section) => section.label === 'Plan')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 239c3314eb3..b144ddbdeae 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -77,6 +77,7 @@ function usageSettings(overrides: Partial = {}): UsagePro minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, + cursorAuthConfigured: false, ...overrides } } @@ -394,6 +395,35 @@ describe('getVisibleUsageProvider', () => { }) describe('isUsageEmptyState', () => { + it('keeps the Cursor bar visible on a local session before the first snapshot', () => { + // Why: the credential lives on disk, not in settings, so main's flag is the + // only durable signal that the bar has an account behind it. + const pending = getVisibleUsageProvider( + 'cursor', + null, + usageSettings({ cursorAuthConfigured: true }) + ) + expect(pending).toMatchObject({ provider: 'cursor', status: 'fetching' }) + expect(getVisibleUsageProvider('cursor', null, usageSettings())).toBeNull() + }) + + it('hides the Cursor bar when no local session exists, even on an unavailable snapshot', () => { + // Why: 'unavailable' is how a signed-out host reports Cursor; without the + // durable flag there is no account to show a bar for. + const unavailable = provider('unavailable', { provider: 'cursor' }) + expect(getVisibleUsageProvider('cursor', unavailable, usageSettings())).toBeNull() + // With a session on disk the row stays, so "no allowance" is explained + // rather than silently vanishing. + expect( + getVisibleUsageProvider('cursor', unavailable, usageSettings({ cursorAuthConfigured: true })) + ).toBe(unavailable) + }) + + it('keeps a failing Cursor refresh visible so the error is not silently hidden', () => { + const failing = provider('error', { provider: 'cursor' }) + expect(getVisibleUsageProvider('cursor', failing, usageSettings())).toBe(failing) + }) + it('waits for provider snapshots before showing the setup CTA', () => { expect(isUsageEmptyState(createEmptyRateLimitState(), usageSettings())).toBe(false) }) @@ -409,7 +439,8 @@ describe('isUsageEmptyState', () => { kimi: provider('unavailable', { provider: 'kimi' }), antigravity: undefined, minimax: undefined, - grok: undefined + grok: undefined, + cursor: undefined }, usageSettings() ) @@ -427,7 +458,8 @@ describe('isUsageEmptyState', () => { kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), - grok: provider('unavailable', { provider: 'grok' }) + grok: provider('unavailable', { provider: 'grok' }), + cursor: provider('unavailable', { provider: 'cursor' }) }, usageSettings() ) @@ -445,7 +477,8 @@ describe('isUsageEmptyState', () => { kimi: provider('unavailable', { provider: 'kimi' }), antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), - grok: provider('unavailable', { provider: 'grok' }) + grok: provider('unavailable', { provider: 'grok' }), + cursor: provider('unavailable', { provider: 'cursor' }) }, usageSettings({ codexManagedAccounts: [ @@ -478,7 +511,8 @@ describe('isUsageEmptyState', () => { kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, minimax: provider('unavailable', { provider: 'minimax' }), - grok: provider('unavailable', { provider: 'grok' }) + grok: provider('unavailable', { provider: 'grok' }), + cursor: provider('unavailable', { provider: 'cursor' }) }, usageSettings() ) @@ -496,7 +530,8 @@ describe('isUsageEmptyState', () => { kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, grok: provider('unavailable', { provider: 'grok' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + cursor: provider('unavailable', { provider: 'cursor' }) }, usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) @@ -516,7 +551,8 @@ describe('isUsageEmptyState', () => { kimi: provider('unavailable', { provider: 'kimi' }), antigravity: null, grok: provider('unavailable', { provider: 'grok' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + cursor: provider('unavailable', { provider: 'cursor' }) }, usageSettings({ antigravityUsageConfigured: true }) ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index c14f0a23a39..086540781d7 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -21,6 +21,7 @@ export type UsageProviderSettings = Pick< // store, neither of which the renderer can see; main reports presence. opencodeGoApiKeyConfigured: boolean grokAuthConfigured: boolean + cursorAuthConfigured: boolean } type UsageProviderSnapshots = { @@ -32,6 +33,7 @@ type UsageProviderSnapshots = { antigravity: ProviderRateLimits | null | undefined minimax: ProviderRateLimits | null | undefined grok: ProviderRateLimits | null | undefined + cursor: ProviderRateLimits | null | undefined } type UsageProviderId = ProviderRateLimits['provider'] @@ -83,7 +85,8 @@ export function hasUsageProviderSettings( // already covered by the gemini term above. settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || - settings?.grokAuthConfigured === true + settings?.grokAuthConfigured === true || + settings?.cursorAuthConfigured === true ) } @@ -121,6 +124,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'grok') { return settings.grokAuthConfigured === true } + if (providerId === 'cursor') { + return settings.cursorAuthConfigured === true + } return false } @@ -130,7 +136,7 @@ function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRat session: null, weekly: null, ...(providerId === 'opencode-go' ? { monthly: null } : {}), - ...(providerId === 'gemini' ? { buckets: [] } : {}), + ...(providerId === 'gemini' || providerId === 'cursor' ? { buckets: [] } : {}), updatedAt: 0, error: null, status: 'fetching' @@ -174,7 +180,8 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.kimi) || antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || - isProviderSnapshotPending(providers.grok) + isProviderSnapshotPending(providers.grok) || + isProviderSnapshotPending(providers.cursor) ) { return false } @@ -187,6 +194,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.kimi) && !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && - !isProviderConfigured(providers.grok) + !isProviderConfigured(providers.grok) && + !isProviderConfigured(providers.cursor) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 2a82f7318dd..be15ae18c02 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -97,6 +97,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'grok') { return } + if (provider === 'cursor') { + return + } return } @@ -146,6 +149,19 @@ export function getWindowSections( const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow })) return [ ...bucketSections, + // Why: Cursor reports the plan total in `monthly` and its pools as buckets, + // so dropping it here would hide the number closest to the user's cap. + ...(p.monthly + ? [ + { + label: + p.provider === 'cursor' + ? translate('auto.components.status.bar.tooltip.cursor.plan', 'Plan') + : translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), + window: p.monthly + } + ] + : []), { label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'), window: p.weekly diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 8dd8ec7f2aa..a0a6cabf1a9 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -26,6 +26,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'grok') { return 'Grok' } + if (provider === 'cursor') { + return 'Cursor' + } return provider } @@ -117,6 +120,11 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { if (p.provider === 'minimax' && p.usageMetadata?.failureKind === 'stale-token') { return translate('auto.components.status.bar.tooltip.minimax.expired.label', 'Sign-in expired') } + // Why: cursor-agent owns its own token rotation, so a lapsed Cursor session is + // fixed by signing in to the CLI, not by Orca retrying the fetch. + if (p.provider === 'cursor' && p.usageMetadata?.failureKind === 'stale-token') { + return translate('auto.components.status.bar.tooltip.cursor.expired.label', 'Sign-in expired') + } // Why: an unsubscribed account is a settled answer about the account, not a // failed refresh; "Refresh failed" sends the user hunting a bug that is not there. if (p.usageMetadata?.failureKind === 'no-subscription') { @@ -152,6 +160,12 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' ) } + if (p.provider === 'cursor' && p.usageMetadata?.failureKind === 'stale-token') { + return translate( + 'auto.components.status.bar.tooltip.cursor.expired.message', + 'Run cursor-agent login in a terminal on the computer running Orca, then retry usage.' + ) + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 59ea7104249..39380e13595 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -18,6 +18,8 @@ export function getUsageProviderAccountsSectionId( return 'accounts-minimax' case 'grok': return 'accounts-grok' + case 'cursor': + return 'accounts-cursor' case 'kimi': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. return null diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index d2f9a21ea18..b7f57c7f1e7 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -99,7 +99,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor } = rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. @@ -114,7 +114,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured, opencodeGoApiKeyConfigured: rateLimits.opencodeGoApiKeyConfigured, - grokAuthConfigured: rateLimits.grokAuthConfigured + grokAuthConfigured: rateLimits.grokAuthConfigured, + cursorAuthConfigured: rateLimits.cursorAuthConfigured } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) @@ -123,6 +124,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) + const visibleCursor = getVisibleUsageProvider('cursor', cursor, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -149,6 +151,9 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { visibleGrok !== null && statusBarItems.includes('grok') && isStatusBarItemAvailable('grok', detectedAgentIds) + // Why: a Cursor session can come from the IDE alone, so PATH detection of + // cursor-agent would hide a real meter from IDE-only users. + const showCursor = visibleCursor !== null && statusBarItems.includes('cursor') // Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') @@ -166,11 +171,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showKimi || showAntigravity || showMiniMax || - showGrok + showGrok || + showCursor const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -183,7 +189,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { kimi?.status === 'fetching' || antigravity?.status === 'fetching' || minimax?.status === 'fetching' || - grok?.status === 'fetching' + grok?.status === 'fetching' || + cursor?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -202,7 +209,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showOpencodeGo ? visibleOpencodeGo : null, showKimi ? visibleKimi : null, showMiniMax ? visibleMiniMax : null, - showGrok ? visibleGrok : null + showGrok ? visibleGrok : null, + showCursor ? visibleCursor : null ].filter((p): p is ProviderRateLimits => p !== null) const handleManageAccounts = (): void => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2c9969e8b07..91a5bdbb9e5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -150,7 +150,8 @@ "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", - "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." + "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.", + "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE." }, "menuBarIcon": { "title": "Show Menu Bar Icon", @@ -3815,7 +3816,8 @@ "grokUsageMenu": "Grok Usage", "floatingTerminalNewActivity": "{{label}}, new activity", "codexSignInSuccess": "Signed in to Codex", - "codexSignInError": "Codex sign-in failed. Please try again." + "codexSignInError": "Codex sign-in failed. Please try again.", + "cursorUsageMenu": "Cursor Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -4006,6 +4008,13 @@ "noSubscription": { "label": "No subscription" } + }, + "cursor": { + "expired": { + "label": "Sign-in expired", + "message": "Run cursor-agent login in a terminal on the computer running Orca, then retry usage." + }, + "plan": "Plan" } }, "SshTargetStatusRow": { @@ -9085,7 +9094,18 @@ "b2c4e7f1a8": "endpoint", "3a9b6d2c4e": "api key", "5d8f1a3b7c": "china", - "7e2a4b8c1d": "overseas" + "7e2a4b8c1d": "overseas", + "cursor": { + "title": "Cursor Usage", + "description": "Monthly plan usage read from the Cursor sign-in already on this computer (cursor-agent login).", + "kw": { + "cursor": "cursor", + "usage": "usage", + "spend": "spend", + "rateLimit": "rate limit", + "statusBar": "status bar" + } + } } }, "advanced": { @@ -9341,6 +9361,13 @@ "close": "close", "notification": "notification area", "background": "background" + }, + "cursor": { + "title": "Cursor Usage", + "description": "Show monthly Cursor plan usage from the Cursor sign-in on this computer.", + "kw": { + "cursor": "cursor" + } } } }, @@ -12152,6 +12179,31 @@ "TerminalRenderingSection": { "6d4c55bacc": "Inline Images", "fffab5890b": "Display images directly in the terminal using SIXEL, iTerm2 (IIP), and Kitty graphics protocols." + }, + "CursorAccountsSection": { + "source": { + "keychain": "macOS Keychain (cursor-agent)", + "cli": "Cursor CLI auth file", + "desktop": "Cursor IDE" + }, + "title": "Cursor", + "subtitle": "Shows your monthly Cursor plan usage from the sign-in already on this computer. Orca only reads it — it never changes your Cursor login.", + "dashboardLink": "Cursor dashboard", + "loading": "Loading…", + "signedIn": "Signed in", + "signedInFrom": "Signed in. Orca reads the session stored in {{source}}.", + "signedInGeneric": "Signed in. Orca reads the Cursor session stored on this computer.", + "expired": "Sign-in expired — run cursor-agent login on the computer running Orca, then click Refresh usage.", + "signedOut": "No Cursor sign-in found on this computer", + "signedOutHelp": "Sign in with Cursor IDE, or run cursor-agent login in a terminal, then click Refresh usage here.", + "refreshUsage": "Refresh usage", + "usageTitle": "Monthly plan usage", + "usageDescription": "Cursor bills two pools that reset with your billing cycle, plus on-demand spend once they run out.", + "planTotal": "Plan", + "resets": "Resets {{when}}", + "usageLabel": "Usage", + "noAllowance": "Cursor reported no usage allowance for this account.", + "staleUsage": "Last known usage — the latest refresh failed: {{reason}}" } }, "right": { @@ -17290,6 +17342,13 @@ "dispatchRejected": "The agent refused the “continue” message. Open the chat and send one yourself.", "unsupported": "This chat can’t be resumed by Orca. Open it to see where it stopped.", "fallback": "Orca couldn’t resume this chat. Open it to continue manually." + }, + "web": { + "preloadApi": { + "cursorAccounts": { + "hostOnly": "Cursor sign-in details are only readable on the computer running Orca." + } + } } }, "i18n": { diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 586dbe7c683..0a380a0625a 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -139,7 +139,8 @@ describe('createUISlice hydratePersistedUI', () => { 'kimi', 'minimax', 'antigravity', - 'grok' + 'grok', + 'cursor' ]) expect(setUI).toHaveBeenCalledWith({ statusBarItems: [ @@ -149,13 +150,15 @@ describe('createUISlice hydratePersistedUI', () => { 'kimi', 'minimax', 'antigravity', - 'grok' + 'grok', + 'cursor' ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, - _grokStatusBarDefaultAdded: true + _grokStatusBarDefaultAdded: true, + _cursorStatusBarDefaultAdded: true }) }) @@ -171,7 +174,8 @@ describe('createUISlice hydratePersistedUI', () => { _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, - _grokStatusBarDefaultAdded: true + _grokStatusBarDefaultAdded: true, + _cursorStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 6ca8d702bd3..5961133d966 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -1,6 +1,5 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' import type { AppState } from '../../types' -import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' import { normalizeRightSidebarRoute } from '../../right-sidebar-route' import { applyManualRepoOrder, @@ -38,7 +37,6 @@ import { normalizeStatusBarUsageMode } from '../../../../../shared/status-bar-us import { normalizeBrowserPageZoomLevel } from '../../../../../shared/browser-page-zoom' import { normalizeKagiSessionLink } from '../../../../../shared/browser-url' import { isReleaseChannel } from '../../../../../shared/release-channel' -import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' import { filterSetupScriptPromptDismissalsToValidRepos, sanitizeSetupScriptPromptDismissals @@ -61,41 +59,13 @@ import { sanitizeWorkspaceCleanupDismissals, sanitizePersistedSidebarWidth, hydratedUIPartialMatchesState, - migrateStatusBarItems, clampPetSize } from './ui-slice-hydration-sanitizers' import { hydrateAgentReadState, sanitizeTaskResumeState } from './ui-slice-hydration-values' +import { hydrateStatusBarItems } from './ui-slice-hydration-status-bar-items' const MAX_LEFT_SIDEBAR_WIDTH = 500 const MAX_RIGHT_SIDEBAR_WIDTH = 4000 -const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' -const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' -const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' -const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' -const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' - -function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { - let items = migrateStatusBarItems(ui.statusBarItems) - const defaults = [ - ['_portsStatusBarDefaultAdded', DEFAULT_ON_PORTS_STATUS_BAR_ITEM], - ['_kimiStatusBarDefaultAdded', DEFAULT_ON_KIMI_STATUS_BAR_ITEM], - ['_minimaxStatusBarDefaultAdded', DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM], - ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], - ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM] - ] as const - for (const [flag, item] of defaults) { - if (!ui[flag] && !items.includes(item)) { - items = [...items, item] - } - } - if (typeof window !== 'undefined' && defaults.some(([flag]) => !ui[flag])) { - window.api.ui - .set({ statusBarItems: items, ...Object.fromEntries(defaults.map(([flag]) => [flag, true])) }) - .catch(console.error) - } - return items -} - export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Partial { return { hydratePersistedUI: (ui, source = 'sync') => diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts new file mode 100644 index 00000000000..47c770804c2 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts @@ -0,0 +1,33 @@ +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' +import { migrateStatusBarItems } from './ui-slice-hydration-sanitizers' + +const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' +const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' +const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' +const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' +const DEFAULT_ON_CURSOR_STATUS_BAR_ITEM: StatusBarItem = 'cursor' + +export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { + let items = migrateStatusBarItems(ui.statusBarItems) + const defaults = [ + ['_portsStatusBarDefaultAdded', DEFAULT_ON_PORTS_STATUS_BAR_ITEM], + ['_kimiStatusBarDefaultAdded', DEFAULT_ON_KIMI_STATUS_BAR_ITEM], + ['_minimaxStatusBarDefaultAdded', DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM], + ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], + ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], + ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM] + ] as const + for (const [flag, item] of defaults) { + if (!ui[flag] && !items.includes(item)) { + items = [...items, item] + } + } + if (typeof window !== 'undefined' && defaults.some(([flag]) => !ui[flag])) { + window.api.ui + .set({ statusBarItems: items, ...Object.fromEntries(defaults.map(([flag]) => [flag, true])) }) + .catch(console.error) + } + return items +} diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 8738039de61..900757900a6 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import type { PreloadApi } from '../../../../preload/api-types' export function createMiniMaxCredentialsApi(): NonNullable< @@ -14,6 +15,28 @@ export function createMiniMaxCredentialsApi(): NonNullable< } } +export function createCursorAccountsApi(): NonNullable['cursorAccounts']> { + // Why an explanation and not a bare `signedIn: false`: Cursor's session lives on + // the machine running Orca, and this bridge cannot read it. The host may well be + // signed in — its usage meter still arrives over the rate-limit snapshot — so + // asserting "not signed in" here would contradict the meter beside it. + return { + getStatus: () => + Promise.resolve({ + signedIn: false, + email: null, + displayName: null, + credentialSource: null, + planType: null, + tokenFresh: false, + error: translate( + 'auto.components.web.preloadApi.cursorAccounts.hostOnly', + 'Cursor sign-in details are only readable on the computer running Orca.' + ) + }) + } +} + export function createGrokAccountsApi(): NonNullable['grokAccounts']> { const unsigned = { signedIn: false, diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index 8bac20f5097..fc119f88258 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -55,6 +55,7 @@ describe('web preload API composition', () => { 'rateLimits', 'minimaxCredentials', 'grokAccounts', + 'cursorAccounts', 'codexAccounts', 'claudeAccounts', 'cli', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0f6848a1827..17f77ded803 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -4,6 +4,7 @@ import { createWebE2EApi } from './preload-api/web-e2e-api' import { createClaudeAccountsApi, createCodexAccountsApi, + createCursorAccountsApi, createGrokAccountsApi, createMiniMaxCredentialsApi } from './preload-api/web-agent-accounts-api' @@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial { rateLimits: createRateLimitsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), grokAccounts: createGrokAccountsApi(), + cursorAccounts: createCursorAccountsApi(), codexAccounts: createCodexAccountsApi(), claudeAccounts: createClaudeAccountsApi(), cli: createCliApi(), diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 2dd034cbe5f..b4941cc1ed8 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -52,7 +52,6 @@ src/main/browser/agent-browser-bridge-state.ts src/main/browser/agent-browser-bridge-types.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts -src/main/claude-accounts/keychain.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts @@ -115,6 +114,7 @@ src/main/ipc/repos/repo-clone-lifecycle.ts src/main/ipc/worktree-apfs-clone.ts src/main/ipc/wsl-watcher-process-exit.ts src/main/local-builds/local-build-candidate.ts +src/main/macos-keychain/generic-password.ts src/main/macos-system-sleep-assertion.ts src/main/macos-tcc-prompt-watch.ts src/main/memory/collector.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 828a01350bb..3f303f571d1 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -9,7 +9,7 @@ main/agent-hooks/managed-hook-owner-identity.ts main/agent-hooks/managed-hook-runtime.ts main/app-icon.ts main/automations/precheck-runner.ts -main/claude-accounts/keychain.ts +main/macos-keychain/generic-password.ts main/computer/macos-computer-use-permission-status.ts main/computer/macos-computer-use-permissions.ts main/computer/macos-native-provider-transport.ts diff --git a/src/shared/cursor-usage-buckets.ts b/src/shared/cursor-usage-buckets.ts new file mode 100644 index 00000000000..a7b834f5f53 --- /dev/null +++ b/src/shared/cursor-usage-buckets.ts @@ -0,0 +1,18 @@ +/** + * Cursor bills individual plans from two pools that both reset with the monthly + * billing cycle, plus optional on-demand spend once the pools run out. + * https://cursor.com/docs/account/pricing + */ +export const CURSOR_MODELS_BUCKET_NAME = 'Cursor Models' +export const CURSOR_OTHER_MODELS_BUCKET_NAME = 'Other Models' +export const CURSOR_ON_DEMAND_BUCKET_NAME = 'On-demand' + +export const CURSOR_USAGE_BUCKET_NAMES = [ + CURSOR_MODELS_BUCKET_NAME, + CURSOR_OTHER_MODELS_BUCKET_NAME, + CURSOR_ON_DEMAND_BUCKET_NAME +] as const + +export function isCursorUsageBucket(name: string): boolean { + return CURSOR_USAGE_BUCKET_NAMES.some((bucket) => bucket === name) +} diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index db5bd7ac377..6b1f20b58b2 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -118,6 +118,8 @@ export type PersistedUIState = { _antigravityStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Grok status item. */ _grokStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Cursor status item. */ + _cursorStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 96ae50f9b28..3dade988f0b 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -11,10 +11,12 @@ export function createEmptyRateLimitState(overrides: Partial = { antigravity: null, minimax: null, grok: null, + cursor: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, + cursorAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index d3276b1afe4..b24e4038579 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -16,10 +16,12 @@ describe('RateLimitState', () => { antigravity: null, minimax: null, grok: null, + cursor: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, + cursorAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], @@ -30,5 +32,7 @@ describe('RateLimitState', () => { expect(state.minimax).toBeNull() expect(state.minimaxCookieConfigured).toBe(false) expect(state.minimaxApiKeyConfigured).toBe(false) + expect(state.cursor).toBeNull() + expect(state.cursorAuthConfigured).toBe(false) }) }) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index dd89c9e8ec6..48ff7c7abe3 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -57,15 +57,16 @@ export type ProviderRateLimits = { | 'minimax' | 'grok' | 'antigravity' + | 'cursor' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ weekly: RateLimitWindow | null /** Claude Fable 7-day weekly window, null if not available. */ fableWeekly?: RateLimitWindow | null - /** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */ + /** 30-day monthly window (OpenCode Go, Grok unified billing, Cursor plan pools), null if not available. */ monthly?: RateLimitWindow | null - /** Named per-model buckets (Gemini only). */ + /** Named per-model buckets (Gemini models, Cursor plan pools). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ rateLimitResetCredits?: { @@ -117,6 +118,17 @@ export type GrokAccountStatus = { error: string | null } +export type CursorAccountStatus = { + signedIn: boolean + email: string | null + displayName: string | null + /** Which local store the session came from: the macOS Keychain, the CLI auth file, or Cursor IDE. */ + credentialSource: 'keychain' | 'cli' | 'desktop' | null + planType: string | null + tokenFresh: boolean + error: string | null +} + export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null @@ -126,6 +138,7 @@ export type RateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null + cursor: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the @@ -149,6 +162,12 @@ export type RateLimitState = { opencodeGoApiKeyConfigured: boolean /** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */ grokAuthConfigured: boolean + /** + * True when main finds a Cursor session on this machine: the macOS Keychain + * item cursor-agent writes, its legacy auth.json, or the Cursor IDE's own + * stored login. The token itself never leaves main. + */ + cursorAuthConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index df84a06a256..b78a984889e 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -69,6 +69,7 @@ export const StatusBarItem = z.enum([ 'kimi', 'minimax', 'grok', + 'cursor', 'ssh', 'resource-usage', 'ports' @@ -182,6 +183,7 @@ export const UiUpdateFields = z _minimaxStatusBarDefaultAdded: z.boolean().optional(), _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), + _cursorStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 73d72a70ab2..2eb855d9dd5 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'kimi', 'minimax', 'grok', + 'cursor', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index fe6157d870b..d9aee820949 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -62,6 +62,7 @@ export type StatusBarItem = | 'kimi' | 'minimax' | 'grok' + | 'cursor' | 'ssh' | 'resource-usage' | 'ports' From fa018fe5207f4527d176fb7e38c8e35b0558b340 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:04:12 -0700 Subject: [PATCH 018/387] chore(deps): refresh maintained dependencies (#22964) * chore(deps): refresh maintained dependencies * fix(deps): defer upgrades that violate runtime and test contracts * test: align catalog response assertion and updated formatter --- cloud/package.json | 2 +- cloud/pnpm-lock.yaml | 361 ++++++++- mobile/package.json | 12 +- mobile/pnpm-lock.yaml | 580 +++++++-------- .../terminal-live-text-commit.test.ts | 16 +- package.json | 32 +- pnpm-lock.yaml | 683 +++++++++--------- pnpm-workspace.yaml | 2 +- .../ipc/worktrees-removal-recovery.test.ts | 4 +- 9 files changed, 1005 insertions(+), 687 deletions(-) diff --git a/cloud/package.json b/cloud/package.json index 545f5387bcf..0dd2a546699 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 4849a4ecea6..6042ee85cd4 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^24.10.0 version: 24.13.2 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) apps/push: dependencies: @@ -65,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay: dependencies: @@ -114,7 +114,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-fence-broker: dependencies: @@ -139,7 +139,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-ops: dependencies: @@ -164,7 +164,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) packages/postgres-schema: devDependencies: @@ -176,7 +176,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/push-contract: dependencies: @@ -192,7 +192,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/relay-contract: dependencies: @@ -208,7 +208,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -227,156 +227,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -593,6 +749,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -871,6 +1032,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -1015,81 +1181,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -1195,13 +1439,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -1284,6 +1536,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1542,13 +1823,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@1.0.3: {} typescript@5.9.3: {} undici-types@7.18.2: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1557,14 +1844,27 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.2 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 tsx: 4.22.4 - vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1581,7 +1881,34 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 diff --git a/mobile/package.json b/mobile/package.json index 8ab89918f99..13724e1c57a 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -60,27 +60,27 @@ "react-native-reanimated": "4.3.4", "react-native-safe-area-context": "^5.7.0", "react-native-screens": "^4.24.0", - "react-native-svg": "^15.15.4", + "react-native-svg": "^15.15.5", "react-native-uitextview": "2.2.0", "react-native-web": "^0.21.2", "react-native-webview": "13.16.2", "react-native-worklets": "^0.8.3", "tweetnacl": "^1.0.3", "ws": "^8.21.3", - "zod": "~4.4.3", - "zustand": "^5.0.13" + "zod": "~4.6.5", + "zustand": "^5.0.15" }, "devDependencies": { "@types/react": "^19.2.14", "@types/react-native": "^0.73.0", "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", - "acorn": "8.15.0", + "acorn": "8.18.0", "esbuild": "0.25.4", "expo-module-scripts": "^55.0.2", "happy-dom": "^20.11.8", - "oxfmt": "^0.65.0", - "oxlint": "^1.80.0", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", "react-test-renderer": "19.2.8", "tsx": "^4.22.4", "typescript": "6.0.3", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 66f6e872624..f55a633e3f6 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -100,7 +100,7 @@ importers: version: 55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-router: specifier: ^55.0.18 - version: 55.0.18(a23394d2f99d0f32345ec3f01a246fbf) + version: 55.0.18(05d3bd5bc5f5a85f046d39988db35c3c) expo-secure-store: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -121,7 +121,7 @@ importers: version: 3.3.0 lucide-react-native: specifier: ^1.14.0 - version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 1.14.0(react-native-svg@15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) mermaid: specifier: 11.17.2 version: 11.17.2 @@ -147,8 +147,8 @@ importers: specifier: ^4.24.0 version: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-svg: - specifier: ^15.15.4 - version: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + specifier: ^15.15.5 + version: 15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-uitextview: specifier: 2.2.0 version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -168,11 +168,11 @@ importers: specifier: ^8.21.3 version: 8.21.3 zod: - specifier: ~4.4.3 - version: 4.4.3 + specifier: ~4.6.5 + version: 4.6.5 zustand: - specifier: ^5.0.13 - version: 5.0.13(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + specifier: ^5.0.15 + version: 5.0.15(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: '@types/react': specifier: ^19.2.14 @@ -187,23 +187,23 @@ importers: specifier: ^8.18.1 version: 8.18.1 acorn: - specifier: 8.15.0 - version: 8.15.0 + specifier: 8.18.0 + version: 8.18.0 esbuild: specifier: 0.25.4 version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.5.1))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.6.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) happy-dom: specifier: ^20.11.8 version: 20.11.8 oxfmt: - specifier: ^0.65.0 - version: 0.65.0 + specifier: ^0.70.0 + version: 0.70.0 oxlint: - specifier: ^1.80.0 - version: 1.80.0 + specifier: ^1.85.0 + version: 1.85.0 react-test-renderer: specifier: 19.2.8 version: 19.2.8(react@19.2.8) @@ -215,10 +215,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.16 - version: 8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1) + version: 8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.5.1)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1)) + version: 4.1.11(@types/node@26.6.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1)) packages: @@ -451,6 +451,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} @@ -1129,6 +1134,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.9': + resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-unicode-escapes@7.27.1': resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} engines: {node: '>=6.9.0'} @@ -1968,246 +1979,246 @@ packages: '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} - '@oxfmt/binding-android-arm-eabi@0.65.0': - resolution: {integrity: sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==} + '@oxfmt/binding-android-arm-eabi@0.70.0': + resolution: {integrity: sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.65.0': - resolution: {integrity: sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==} + '@oxfmt/binding-android-arm64@0.70.0': + resolution: {integrity: sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.65.0': - resolution: {integrity: sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==} + '@oxfmt/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.65.0': - resolution: {integrity: sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==} + '@oxfmt/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.65.0': - resolution: {integrity: sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==} + '@oxfmt/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': - resolution: {integrity: sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.65.0': - resolution: {integrity: sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==} + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.65.0': - resolution: {integrity: sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==} + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.65.0': - resolution: {integrity: sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==} + '@oxfmt/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.65.0': - resolution: {integrity: sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==} + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + resolution: {integrity: sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.65.0': - resolution: {integrity: sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==} + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.65.0': - resolution: {integrity: sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==} + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + resolution: {integrity: sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.65.0': - resolution: {integrity: sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==} + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.65.0': - resolution: {integrity: sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==} + '@oxfmt/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.65.0': - resolution: {integrity: sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==} + '@oxfmt/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.65.0': - resolution: {integrity: sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==} + '@oxfmt/binding-openharmony-arm64@0.70.0': + resolution: {integrity: sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.65.0': - resolution: {integrity: sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==} + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.65.0': - resolution: {integrity: sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==} + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + resolution: {integrity: sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.65.0': - resolution: {integrity: sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==} + '@oxfmt/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.80.0': - resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==} + '@oxlint/binding-android-arm-eabi@1.85.0': + resolution: {integrity: sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.80.0': - resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==} + '@oxlint/binding-android-arm64@1.85.0': + resolution: {integrity: sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.80.0': - resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==} + '@oxlint/binding-darwin-arm64@1.85.0': + resolution: {integrity: sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.80.0': - resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==} + '@oxlint/binding-darwin-x64@1.85.0': + resolution: {integrity: sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.80.0': - resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==} + '@oxlint/binding-freebsd-x64@1.85.0': + resolution: {integrity: sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.80.0': - resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==} + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': + resolution: {integrity: sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.80.0': - resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==} + '@oxlint/binding-linux-arm-musleabihf@1.85.0': + resolution: {integrity: sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.80.0': - resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==} + '@oxlint/binding-linux-arm64-gnu@1.85.0': + resolution: {integrity: sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.80.0': - resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==} + '@oxlint/binding-linux-arm64-musl@1.85.0': + resolution: {integrity: sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.80.0': - resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==} + '@oxlint/binding-linux-ppc64-gnu@1.85.0': + resolution: {integrity: sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.80.0': - resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==} + '@oxlint/binding-linux-riscv64-gnu@1.85.0': + resolution: {integrity: sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.80.0': - resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==} + '@oxlint/binding-linux-riscv64-musl@1.85.0': + resolution: {integrity: sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.80.0': - resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==} + '@oxlint/binding-linux-s390x-gnu@1.85.0': + resolution: {integrity: sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.80.0': - resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==} + '@oxlint/binding-linux-x64-gnu@1.85.0': + resolution: {integrity: sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.80.0': - resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==} + '@oxlint/binding-linux-x64-musl@1.85.0': + resolution: {integrity: sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.80.0': - resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==} + '@oxlint/binding-openharmony-arm64@1.85.0': + resolution: {integrity: sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.80.0': - resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==} + '@oxlint/binding-win32-arm64-msvc@1.85.0': + resolution: {integrity: sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.80.0': - resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==} + '@oxlint/binding-win32-ia32-msvc@1.85.0': + resolution: {integrity: sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.80.0': - resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==} + '@oxlint/binding-win32-x64-msvc@1.85.0': + resolution: {integrity: sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2899,14 +2910,8 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - - '@types/node@26.5.1': - resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} '@types/react-native@0.73.0': resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} @@ -3132,8 +3137,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -5831,8 +5836,8 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxfmt@0.65.0: - resolution: {integrity: sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==} + oxfmt@0.70.0: + resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5844,8 +5849,8 @@ packages: vite-plus: optional: true - oxlint@1.80.0: - resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==} + oxlint@1.85.0: + resolution: {integrity: sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6095,8 +6100,8 @@ packages: react: '*' react-native: '*' - react-native-svg@15.15.4: - resolution: {integrity: sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==} + react-native-svg@15.15.5: + resolution: {integrity: sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==} peerDependencies: react: '*' react-native: '*' @@ -6653,8 +6658,8 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} tinyrainbow@3.1.0: @@ -6793,9 +6798,6 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} @@ -7147,11 +7149,11 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} - zustand@5.0.13: - resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -7497,6 +7499,10 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8245,6 +8251,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8742,7 +8759,7 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.18(a23394d2f99d0f32345ec3f01a246fbf) + expo-router: 55.0.18(05d3bd5bc5f5a85f046d39988db35c3c) react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@expo/dom-webview' @@ -9076,7 +9093,7 @@ snapshots: react: 19.2.8 optionalDependencies: '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-router: 55.0.18(a23394d2f99d0f32345ec3f01a246fbf) + expo-router: 55.0.18(05d3bd5bc5f5a85f046d39988db35c3c) react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color @@ -9148,7 +9165,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 26.1.1 + '@types/node': 26.6.2 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -9161,14 +9178,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.5.1 + '@types/node': 26.6.2 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.5.1) + jest-config: 29.7.0(@types/node@26.6.2) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -9199,7 +9216,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.6.2 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -9217,7 +9234,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.6.0 + '@types/node': 26.6.2 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -9241,7 +9258,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 26.5.1 + '@types/node': 26.6.2 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -9315,7 +9332,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 26.1.1 + '@types/node': 26.6.2 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9367,118 +9384,118 @@ snapshots: '@oxc-project/types@0.137.0': {} - '@oxfmt/binding-android-arm-eabi@0.65.0': + '@oxfmt/binding-android-arm-eabi@0.70.0': optional: true - '@oxfmt/binding-android-arm64@0.65.0': + '@oxfmt/binding-android-arm64@0.70.0': optional: true - '@oxfmt/binding-darwin-arm64@0.65.0': + '@oxfmt/binding-darwin-arm64@0.70.0': optional: true - '@oxfmt/binding-darwin-x64@0.65.0': + '@oxfmt/binding-darwin-x64@0.70.0': optional: true - '@oxfmt/binding-freebsd-x64@0.65.0': + '@oxfmt/binding-freebsd-x64@0.70.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.65.0': + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.65.0': + '@oxfmt/binding-linux-arm64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.65.0': + '@oxfmt/binding-linux-arm64-musl@0.70.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.65.0': + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.65.0': + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.65.0': + '@oxfmt/binding-linux-riscv64-musl@0.70.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.65.0': + '@oxfmt/binding-linux-s390x-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.65.0': + '@oxfmt/binding-linux-x64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.65.0': + '@oxfmt/binding-linux-x64-musl@0.70.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.65.0': + '@oxfmt/binding-openharmony-arm64@0.70.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.65.0': + '@oxfmt/binding-win32-arm64-msvc@0.70.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.65.0': + '@oxfmt/binding-win32-ia32-msvc@0.70.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.65.0': + '@oxfmt/binding-win32-x64-msvc@0.70.0': optional: true - '@oxlint/binding-android-arm-eabi@1.80.0': + '@oxlint/binding-android-arm-eabi@1.85.0': optional: true - '@oxlint/binding-android-arm64@1.80.0': + '@oxlint/binding-android-arm64@1.85.0': optional: true - '@oxlint/binding-darwin-arm64@1.80.0': + '@oxlint/binding-darwin-arm64@1.85.0': optional: true - '@oxlint/binding-darwin-x64@1.80.0': + '@oxlint/binding-darwin-x64@1.85.0': optional: true - '@oxlint/binding-freebsd-x64@1.80.0': + '@oxlint/binding-freebsd-x64@1.85.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.80.0': + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.80.0': + '@oxlint/binding-linux-arm-musleabihf@1.85.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.80.0': + '@oxlint/binding-linux-arm64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.80.0': + '@oxlint/binding-linux-arm64-musl@1.85.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.80.0': + '@oxlint/binding-linux-ppc64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.80.0': + '@oxlint/binding-linux-riscv64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.80.0': + '@oxlint/binding-linux-riscv64-musl@1.85.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.80.0': + '@oxlint/binding-linux-s390x-gnu@1.85.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.80.0': + '@oxlint/binding-linux-x64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-x64-musl@1.80.0': + '@oxlint/binding-linux-x64-musl@1.85.0': optional: true - '@oxlint/binding-openharmony-arm64@1.80.0': + '@oxlint/binding-openharmony-arm64@1.85.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.80.0': + '@oxlint/binding-win32-arm64-msvc@1.85.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.80.0': + '@oxlint/binding-win32-ia32-msvc@1.85.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.80.0': + '@oxlint/binding-win32-x64-msvc@1.85.0': optional: true '@pkgr/core@0.2.9': {} @@ -9835,7 +9852,7 @@ snapshots: '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.7) babel-plugin-syntax-hermes-parser: 0.33.3 @@ -9867,7 +9884,7 @@ snapshots: '@react-native/codegen@0.85.2(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 hermes-parser: 0.33.3 invariant: 2.2.4 nullthrows: 1.1.1 @@ -10086,7 +10103,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.5.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.6.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -10096,7 +10113,7 @@ snapshots: react-test-renderer: 19.2.8(react@19.2.8) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@26.5.1) + jest: 29.7.0(@types/node@26.6.2) '@tootallnate/once@2.0.1': {} @@ -10260,7 +10277,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 26.1.1 + '@types/node': 26.6.2 '@types/hammerjs@2.0.46': {} @@ -10285,7 +10302,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.6.2 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -10297,15 +10314,7 @@ snapshots: dependencies: undici-types: 7.19.2 - '@types/node@26.1.1': - dependencies: - undici-types: 8.3.0 - - '@types/node@26.1.2': - dependencies: - undici-types: 8.3.0 - - '@types/node@26.5.1': + '@types/node@26.6.2': dependencies: undici-types: 8.9.0 @@ -10489,13 +10498,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1))': + '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1) + vite: 8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -10553,18 +10562,18 @@ snapshots: acorn-globals@7.0.1: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 acorn-walk: 8.3.5 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.15.0 + acorn: 8.18.0 acorn-walk@8.3.5: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 - acorn@8.15.0: {} + acorn@8.18.0: {} agent-base@6.0.2: dependencies: @@ -10956,7 +10965,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.6.2 buffer@6.0.3: dependencies: @@ -11027,7 +11036,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.6.2 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -11036,7 +11045,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.6.2 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -11148,13 +11157,13 @@ snapshots: dependencies: layout-base: 2.0.1 - create-jest@29.7.0(@types/node@26.5.1): + create-jest@29.7.0(@types/node@26.6.2): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@26.5.1) + jest-config: 29.7.0(@types/node@26.6.2) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11936,8 +11945,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -12133,7 +12142,7 @@ snapshots: expo: 55.0.30(e404b20c1cb2e50a7d80f2cede5c5264) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.5.1))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.6.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -12141,7 +12150,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.5.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.6.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -12149,11 +12158,11 @@ snapshots: commander: 12.1.0 eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.5.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.6.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.5.1)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.6.2)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.5.1))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.6.2))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12216,7 +12225,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.18(a23394d2f99d0f32345ec3f01a246fbf): + expo-router@55.0.18(05d3bd5bc5f5a85f046d39988db35c3c): dependencies: '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12253,7 +12262,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.5.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.6.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12553,7 +12562,7 @@ snapshots: happy-dom@20.11.8: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.6.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -12871,7 +12880,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 semver: 7.8.5 @@ -12918,7 +12927,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.5.1 + '@types/node': 26.6.2 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -12938,16 +12947,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@26.5.1): + jest-cli@29.7.0(@types/node@26.6.2): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@26.5.1) + create-jest: 29.7.0(@types/node@26.6.2) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@26.5.1) + jest-config: 29.7.0(@types/node@26.6.2) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -12957,7 +12966,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@26.5.1): + jest-config@29.7.0(@types/node@26.6.2): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -12982,7 +12991,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.2 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -13019,7 +13028,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 25.6.0 + '@types/node': 26.6.2 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -13033,11 +13042,11 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.6.2 jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.5.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.6.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 @@ -13048,7 +13057,7 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.5.1)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.6.2)) json5: 2.2.3 lodash: 4.18.1 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -13071,7 +13080,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 26.1.1 + '@types/node': 26.6.2 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -13117,7 +13126,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.6.2 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -13152,7 +13161,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.5.1 + '@types/node': 26.6.2 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -13180,7 +13189,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.5.1 + '@types/node': 26.6.2 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -13226,7 +13235,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 26.1.1 + '@types/node': 26.6.2 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -13247,11 +13256,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.5.1)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.6.2)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@26.5.1) + jest: 29.7.0(@types/node@26.6.2) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -13262,7 +13271,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.6.2 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -13271,17 +13280,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 26.1.1 + '@types/node': 26.6.2 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@26.5.1): + jest@29.7.0(@types/node@26.6.2): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@26.5.1) + jest-cli: 29.7.0(@types/node@26.6.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -13306,7 +13315,7 @@ snapshots: jsdom@20.0.3: dependencies: abab: 2.0.6 - acorn: 8.15.0 + acorn: 8.18.0 acorn-globals: 7.0.1 cssom: 0.5.0 cssstyle: 2.3.0 @@ -13484,11 +13493,11 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react-native@1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + lucide-react-native@1.14.0(react-native-svg@15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-svg: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-svg: 15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) magic-string@0.30.21: dependencies: @@ -13792,7 +13801,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 metro: 0.84.6 @@ -13858,7 +13867,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 '@babel/generator': 7.29.8 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 @@ -14100,51 +14109,51 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxfmt@0.65.0: + oxfmt@0.70.0: dependencies: - tinypool: 2.1.0 + tinypool: 2.1.2 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.65.0 - '@oxfmt/binding-android-arm64': 0.65.0 - '@oxfmt/binding-darwin-arm64': 0.65.0 - '@oxfmt/binding-darwin-x64': 0.65.0 - '@oxfmt/binding-freebsd-x64': 0.65.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.65.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.65.0 - '@oxfmt/binding-linux-arm64-gnu': 0.65.0 - '@oxfmt/binding-linux-arm64-musl': 0.65.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.65.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.65.0 - '@oxfmt/binding-linux-riscv64-musl': 0.65.0 - '@oxfmt/binding-linux-s390x-gnu': 0.65.0 - '@oxfmt/binding-linux-x64-gnu': 0.65.0 - '@oxfmt/binding-linux-x64-musl': 0.65.0 - '@oxfmt/binding-openharmony-arm64': 0.65.0 - '@oxfmt/binding-win32-arm64-msvc': 0.65.0 - '@oxfmt/binding-win32-ia32-msvc': 0.65.0 - '@oxfmt/binding-win32-x64-msvc': 0.65.0 + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 - oxlint@1.80.0: + oxlint@1.85.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.80.0 - '@oxlint/binding-android-arm64': 1.80.0 - '@oxlint/binding-darwin-arm64': 1.80.0 - '@oxlint/binding-darwin-x64': 1.80.0 - '@oxlint/binding-freebsd-x64': 1.80.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.80.0 - '@oxlint/binding-linux-arm-musleabihf': 1.80.0 - '@oxlint/binding-linux-arm64-gnu': 1.80.0 - '@oxlint/binding-linux-arm64-musl': 1.80.0 - '@oxlint/binding-linux-ppc64-gnu': 1.80.0 - '@oxlint/binding-linux-riscv64-gnu': 1.80.0 - '@oxlint/binding-linux-riscv64-musl': 1.80.0 - '@oxlint/binding-linux-s390x-gnu': 1.80.0 - '@oxlint/binding-linux-x64-gnu': 1.80.0 - '@oxlint/binding-linux-x64-musl': 1.80.0 - '@oxlint/binding-openharmony-arm64': 1.80.0 - '@oxlint/binding-win32-arm64-msvc': 1.80.0 - '@oxlint/binding-win32-ia32-msvc': 1.80.0 - '@oxlint/binding-win32-x64-msvc': 1.80.0 + '@oxlint/binding-android-arm-eabi': 1.85.0 + '@oxlint/binding-android-arm64': 1.85.0 + '@oxlint/binding-darwin-arm64': 1.85.0 + '@oxlint/binding-darwin-x64': 1.85.0 + '@oxlint/binding-freebsd-x64': 1.85.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.85.0 + '@oxlint/binding-linux-arm-musleabihf': 1.85.0 + '@oxlint/binding-linux-arm64-gnu': 1.85.0 + '@oxlint/binding-linux-arm64-musl': 1.85.0 + '@oxlint/binding-linux-ppc64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-musl': 1.85.0 + '@oxlint/binding-linux-s390x-gnu': 1.85.0 + '@oxlint/binding-linux-x64-gnu': 1.85.0 + '@oxlint/binding-linux-x64-musl': 1.85.0 + '@oxlint/binding-openharmony-arm64': 1.85.0 + '@oxlint/binding-win32-arm64-msvc': 1.85.0 + '@oxlint/binding-win32-ia32-msvc': 1.85.0 + '@oxlint/binding-win32-x64-msvc': 1.85.0 p-limit@2.3.0: dependencies: @@ -14365,13 +14374,12 @@ snapshots: react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) warn-once: 0.1.1 - react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-svg@15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.8 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - warn-once: 0.1.1 react-native-uitextview@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: @@ -14995,14 +15003,14 @@ snapshots: terser@5.49.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -15023,7 +15031,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@2.1.0: {} + tinypool@2.1.2: {} tinyrainbow@3.1.0: {} @@ -15061,11 +15069,11 @@ snapshots: ts-dedent@2.3.0: {} - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.5.1))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.6.2))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@26.5.1) + jest: 29.7.0(@types/node@26.6.2) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -15158,8 +15166,6 @@ snapshots: undici-types@7.19.2: {} - undici-types@8.3.0: {} - undici-types@8.9.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -15240,7 +15246,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1): + vite@8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -15248,17 +15254,17 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.2 esbuild: 0.25.4 fsevents: 2.3.3 terser: 5.51.2 tsx: 4.22.4 yaml: 2.9.1 - vitest@4.1.11(@types/node@26.5.1)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1)): + vitest@4.1.11(@types/node@26.6.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1)) + '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15275,10 +15281,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@26.5.1)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1) + vite: 8.1.0(@types/node@26.6.2)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.5.1 + '@types/node': 26.6.2 happy-dom: 20.11.8 jsdom: 20.0.3 transitivePeerDependencies: @@ -15445,9 +15451,9 @@ snapshots: zod@3.25.76: {} - zod@4.4.3: {} + zod@4.6.5: {} - zustand@5.0.13(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.15(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.14 react: 19.2.8 diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts index 7c41cb1c5b8..ac177542be8 100644 --- a/mobile/src/terminal/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -127,14 +127,14 @@ describe('terminal live accessory local edit text', () => { const fieldText = `${'a'.repeat(100_000)}🙂` const originalIterator = String.prototype[Symbol.iterator] let visits = 0 - const iterator = vi - .spyOn(String.prototype, Symbol.iterator) - .mockImplementation(function* (this: string) { - for (const codePoint of originalIterator.call(this)) { - visits += 1 - yield codePoint - } - }) + const iterator = vi.spyOn(String.prototype, Symbol.iterator).mockImplementation(function* ( + this: string + ) { + for (const codePoint of originalIterator.call(this)) { + visits += 1 + yield codePoint + } + }) let result: string try { result = getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText }) diff --git a/package.json b/package.json index 5e88d8ab994..96a0d2f2ec5 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.251", "@electron-toolkit/utils": "^4.0.0", - "@floating-ui/dom": "1.7.6", + "@floating-ui/dom": "1.8.0", "@linear/sdk": "^82.1.0", "@parcel/watcher": "^2.5.6", "@streamparser/json": "0.0.26", @@ -183,18 +183,18 @@ "i18next": "^26.3.1", "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", - "posthog-node": "^5.33.3", + "posthog-node": "^5.52.5", "proper-lockfile": "4.1.2", "qrcode": "^1.5.4", - "react-i18next": "17.0.13", + "react-i18next": "17.0.15", "serve-sim": "0.1.40", "sherpa-onnx": "1.12.37", "ssh2": "^1.17.0", - "tldts": "7.4.12", + "tldts": "7.4.14", "tweetnacl": "^1.0.3", "ws": "^8.21.3", - "yaml": "^2.8.4", - "zod": "~4.5.4" + "yaml": "^2.9.1", + "zod": "~4.6.5" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", @@ -202,16 +202,16 @@ "@electron-toolkit/tsconfig": "^2.0.0", "@electron/rebuild": "^4.2.0", "@monaco-editor/react": "^4.7.0", - "@oxlint/plugins": "1.80.0", - "@playwright/test": "^1.59.1", - "@sanity/diff-match-patch": "^3.2.0", + "@oxlint/plugins": "1.85.0", + "@playwright/test": "^1.63.0", + "@sanity/diff-match-patch": "^3.2.1", "@shadcn/lint": "^0.1.0", - "@stablyai/playwright-test": "^2.1.14", + "@stablyai/playwright-test": "^2.1.16", "@tailwindcss/vite": "^4.2.4", - "@tanstack/react-virtual": "^3.14.10", + "@tanstack/react-virtual": "^3.14.13", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@tiptap/extension-code-block": "3.31.3", "@tiptap/extension-code-block-lowlight": "3.31.3", "@tiptap/extension-details": "3.31.3", @@ -251,7 +251,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "diff": "8.0.4", - "dompurify": "3.4.14", + "dompurify": "3.4.15", "electron": "43.7.0", "electron-builder": "^26.15.3", "electron-builder-squirrel-windows": "^26.15.3", @@ -270,8 +270,8 @@ "marked": "^17.0.1", "mermaid": "^11.17.2", "monaco-editor": "^0.55.1", - "oxfmt": "^0.65.0", - "oxlint": "^1.80.0", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", "oxlint-plugin-anti-slop": "github:dmmulroy/anti-slop#c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b", "oxlint-plugin-react-doctor": "0.9.1", "oxlint-tsgolint": "7.0.2001", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 513267db1e3..9c3feeb3337 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,7 +106,7 @@ settings: excludeLinksFromLockfile: false overrides: - monaco-editor>dompurify: 3.4.14 + monaco-editor>dompurify: 3.4.15 patchedDependencies: '@vscode/windows-process-tree@0.8.0': e66202cc623996d02040c93449eb9ae353fddadf426cb53202a59ee710ee6fe7 @@ -125,13 +125,13 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: 0.3.251 - version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4) + version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.6.5))(zod@4.6.5) '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@43.7.0(supports-color@7.2.0)) '@floating-ui/dom': - specifier: 1.7.6 - version: 1.7.6 + specifier: 1.8.0 + version: 1.8.0 '@linear/sdk': specifier: ^82.1.0 version: 82.1.0(graphql@16.14.2) @@ -163,8 +163,8 @@ importers: specifier: ^1.1.0 version: 1.1.0(patch_hash=346cb29d33dd6eeb14910ff411c7584b0b2ff9a4b271c4b6d23c3b48e6548f74) posthog-node: - specifier: ^5.33.3 - version: 5.33.3 + specifier: ^5.52.5 + version: 5.52.5 proper-lockfile: specifier: 4.1.2 version: 4.1.2 @@ -172,8 +172,8 @@ importers: specifier: ^1.5.4 version: 1.5.4 react-i18next: - specifier: 17.0.13 - version: 17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + specifier: 17.0.15 + version: 17.0.15(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) serve-sim: specifier: 0.1.40 version: 0.1.40(typescript@7.0.2) @@ -184,8 +184,8 @@ importers: specifier: ^1.17.0 version: 1.17.0 tldts: - specifier: 7.4.12 - version: 7.4.12 + specifier: 7.4.14 + version: 7.4.14 tweetnacl: specifier: ^1.0.3 version: 1.0.3 @@ -193,11 +193,11 @@ importers: specifier: ^8.21.3 version: 8.21.3 yaml: - specifier: ^2.8.4 - version: 2.8.4 + specifier: ^2.9.1 + version: 2.9.1 zod: - specifier: ~4.5.4 - version: 4.5.4 + specifier: ~4.6.5 + version: 4.6.5 devDependencies: '@dnd-kit/core': specifier: ^6.3.1 @@ -215,35 +215,35 @@ importers: specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@oxlint/plugins': - specifier: 1.80.0 - version: 1.80.0 + specifier: 1.85.0 + version: 1.85.0 '@playwright/test': - specifier: ^1.59.1 - version: 1.59.1 + specifier: ^1.63.0 + version: 1.63.0 '@sanity/diff-match-patch': - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^3.2.1 + version: 3.2.1 '@shadcn/lint': specifier: ^0.1.0 version: 0.1.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) '@stablyai/playwright-test': - specifier: ^2.1.14 - version: 2.1.14(@playwright/test@1.59.1)(zod@4.5.4) + specifier: ^2.1.16 + version: 2.1.16(@playwright/test@1.63.0)(zod@4.6.5) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)) '@tanstack/react-virtual': - specifier: ^3.14.10 - version: 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^3.14.13 + version: 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/user-event': - specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.1) '@tiptap/extension-code-block': specifier: 3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) @@ -294,7 +294,7 @@ importers: version: 3.31.3 '@tiptap/react': specifier: 3.31.3 - version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tiptap/starter-kit': specifier: 3.31.3 version: 3.31.3 @@ -321,7 +321,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) + version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)) '@vscode/ripgrep-universal': specifier: 1.18.0 version: 1.18.0 @@ -362,8 +362,8 @@ importers: specifier: 8.0.4 version: 8.0.4 dompurify: - specifier: 3.4.14 - version: 3.4.14 + specifier: 3.4.15 + version: 3.4.15 electron: specifier: 43.7.0 version: 43.7.0(supports-color@7.2.0) @@ -375,7 +375,7 @@ importers: version: 26.15.3(dmg-builder@26.15.3) electron-vite: specifier: ^5.0.0 - version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) + version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.8) @@ -419,11 +419,11 @@ importers: specifier: ^0.55.1 version: 0.55.1 oxfmt: - specifier: ^0.65.0 - version: 0.65.0 + specifier: ^0.70.0 + version: 0.70.0 oxlint: - specifier: ^1.80.0 - version: 1.80.0(oxlint-tsgolint@7.0.2001) + specifier: ^1.85.0 + version: 1.85.0(oxlint-tsgolint@7.0.2001) oxlint-plugin-anti-slop: specifier: github:dmmulroy/anti-slop#c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b version: https://codeload.github.com/dmmulroy/anti-slop/tar.gz/c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b @@ -519,10 +519,10 @@ importers: version: 11.0.5 vite: specifier: npm:rolldown-vite@7.3.1 - version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) + version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -1040,11 +1040,11 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} '@floating-ui/react-dom@2.1.8': resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} @@ -1052,8 +1052,8 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} @@ -1685,124 +1685,124 @@ packages: '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} - '@oxfmt/binding-android-arm-eabi@0.65.0': - resolution: {integrity: sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==} + '@oxfmt/binding-android-arm-eabi@0.70.0': + resolution: {integrity: sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.65.0': - resolution: {integrity: sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==} + '@oxfmt/binding-android-arm64@0.70.0': + resolution: {integrity: sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.65.0': - resolution: {integrity: sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==} + '@oxfmt/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.65.0': - resolution: {integrity: sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==} + '@oxfmt/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.65.0': - resolution: {integrity: sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==} + '@oxfmt/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': - resolution: {integrity: sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.65.0': - resolution: {integrity: sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==} + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.65.0': - resolution: {integrity: sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==} + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.65.0': - resolution: {integrity: sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==} + '@oxfmt/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.65.0': - resolution: {integrity: sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==} + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + resolution: {integrity: sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.65.0': - resolution: {integrity: sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==} + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.65.0': - resolution: {integrity: sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==} + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + resolution: {integrity: sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.65.0': - resolution: {integrity: sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==} + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.65.0': - resolution: {integrity: sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==} + '@oxfmt/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.65.0': - resolution: {integrity: sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==} + '@oxfmt/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.65.0': - resolution: {integrity: sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==} + '@oxfmt/binding-openharmony-arm64@0.70.0': + resolution: {integrity: sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.65.0': - resolution: {integrity: sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==} + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.65.0': - resolution: {integrity: sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==} + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + resolution: {integrity: sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.65.0': - resolution: {integrity: sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==} + '@oxfmt/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1837,124 +1837,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.80.0': - resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==} + '@oxlint/binding-android-arm-eabi@1.85.0': + resolution: {integrity: sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.80.0': - resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==} + '@oxlint/binding-android-arm64@1.85.0': + resolution: {integrity: sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.80.0': - resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==} + '@oxlint/binding-darwin-arm64@1.85.0': + resolution: {integrity: sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.80.0': - resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==} + '@oxlint/binding-darwin-x64@1.85.0': + resolution: {integrity: sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.80.0': - resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==} + '@oxlint/binding-freebsd-x64@1.85.0': + resolution: {integrity: sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.80.0': - resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==} + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': + resolution: {integrity: sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.80.0': - resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==} + '@oxlint/binding-linux-arm-musleabihf@1.85.0': + resolution: {integrity: sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.80.0': - resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==} + '@oxlint/binding-linux-arm64-gnu@1.85.0': + resolution: {integrity: sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.80.0': - resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==} + '@oxlint/binding-linux-arm64-musl@1.85.0': + resolution: {integrity: sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.80.0': - resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==} + '@oxlint/binding-linux-ppc64-gnu@1.85.0': + resolution: {integrity: sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.80.0': - resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==} + '@oxlint/binding-linux-riscv64-gnu@1.85.0': + resolution: {integrity: sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.80.0': - resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==} + '@oxlint/binding-linux-riscv64-musl@1.85.0': + resolution: {integrity: sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.80.0': - resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==} + '@oxlint/binding-linux-s390x-gnu@1.85.0': + resolution: {integrity: sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.80.0': - resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==} + '@oxlint/binding-linux-x64-gnu@1.85.0': + resolution: {integrity: sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.80.0': - resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==} + '@oxlint/binding-linux-x64-musl@1.85.0': + resolution: {integrity: sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.80.0': - resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==} + '@oxlint/binding-openharmony-arm64@1.85.0': + resolution: {integrity: sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.80.0': - resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==} + '@oxlint/binding-win32-arm64-msvc@1.85.0': + resolution: {integrity: sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.80.0': - resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==} + '@oxlint/binding-win32-ia32-msvc@1.85.0': + resolution: {integrity: sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.80.0': - resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==} + '@oxlint/binding-win32-x64-msvc@1.85.0': + resolution: {integrity: sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1963,8 +1963,8 @@ packages: resolution: {integrity: sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@oxlint/plugins@1.80.0': - resolution: {integrity: sha512-QRgH1XqQEYNHa4f1vvPQ5fAdNdncHGIUG1ZWLlGIZHky3qwCEeAKYitZNbZMtaXtAQAAFFTOwqUfzESvimqZNA==} + '@oxlint/plugins@1.85.0': + resolution: {integrity: sha512-qqHqML3ME3cxGgFvWJG8wwMnJc9ol5SMdtqkq0k8r7l0USmC+HRXYjzDacME4Dqkx4yqrUsBhj0JYvji8fHxJQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@parcel/watcher-android-arm64@2.5.6': @@ -2069,16 +2069,16 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@playwright/test@1.59.1': - resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} - engines: {node: '>=18'} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} hasBin: true - '@posthog/core@1.28.3': - resolution: {integrity: sha512-SOy0aphKawZzp8jxfeOpTcXPwi6ii0I2V6tX8YXnM+WbxKKR/R+BXLK0jS6LV8kZtW3H5YxmPAfuIbUP1UnGTw==} + '@posthog/core@1.55.1': + resolution: {integrity: sha512-S76bSbCHGVC8oQa1zyue6A6WzkNf1Ue4oRvfvBNLRnFM3UoWnpWnQ4T/4IzrIEtVCQYv1OI7gk8uL8/U6Tuu7w==} - '@posthog/types@1.372.9': - resolution: {integrity: sha512-B7k9S+H9WUKHXxe1HOkQWbpWtMcrBvsodm5stZaLQ3pYxf9TowtwssdzTtX4hHjzSYqgrS1IpNnJX4vs1KgBzA==} + '@posthog/types@1.412.4': + resolution: {integrity: sha512-Q7lV9O9TbLngjOYw1ucm3bS3tn48xb1B4ZQdDy8gv7yfKe99hCTeWYG4xZ36nJM80VxpTF9TriiJt5hngDOkBg==} '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -2861,8 +2861,8 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@sanity/diff-match-patch@3.2.0': - resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} + '@sanity/diff-match-patch@3.2.1': + resolution: {integrity: sha512-Ll3BPOn9aPGeBUOFDZpKk0IObL+NI4tNq7VyOKj680xd+iCtfjnbcZZU0vpj94XozVGE0Zf+0FoYWXk2xJgZCg==} engines: {node: '>=18.18'} '@sec-ant/readable-stream@0.4.1': @@ -2888,8 +2888,8 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} - '@stablyai/playwright-base@2.1.14': - resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==} + '@stablyai/playwright-base@2.1.16': + resolution: {integrity: sha512-mdZ+WO5VA6hhHcWLQFcDwOolosvrA60dY0Uua8L45iWsqa4RdzEpmoDmdf7m3nGdlHDk9uY8XSJFSXK9qr64Ig==} engines: {node: '>=18'} peerDependencies: '@playwright/test': ^1.52.0 @@ -2898,13 +2898,13 @@ packages: zod: optional: true - '@stablyai/playwright-test@2.1.14': - resolution: {integrity: sha512-CAyVVnRdsyJg9pbK3Yq5L9lcvEabilFLb2RWeTQybKv7sDkEEqE2t1boXqBt3X6wQO6lsyhUHB9pc10wSwuc4Q==} + '@stablyai/playwright-test@2.1.16': + resolution: {integrity: sha512-NLbywKt/Y+AFra2puI2Ja3KADBPOQksPSpl6Zk7IlgZvawbz0CNom90aiAQX/jZ0g2KkF94cAQ08UZeVVwGzyA==} peerDependencies: '@playwright/test': ^1.52.0 - '@stablyai/playwright@2.1.14': - resolution: {integrity: sha512-+SkphioOf+o2VWiM3KPm/fFTTjwNHUV5b2ZRPrLMTsW6bwmEvjo2FbVOUobNBqbopQBnntNLd8ZCG2gvw7rwtg==} + '@stablyai/playwright@2.1.16': + resolution: {integrity: sha512-gG+TIce/Y6Z8l0hJVuJUmD1c0UF+dPrCfqGrebR5TFgO5Vxag5UzgSyoHXEozn2oAfqrnxPE2l9QmkrclIr61w==} peerDependencies: '@playwright/test': ^1.52.0 @@ -3105,14 +3105,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/react-virtual@3.14.10': - resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==} + '@tanstack/react-virtual@3.14.13': + resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.17.8': - resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==} + '@tanstack/virtual-core@3.17.11': + resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -3122,8 +3122,8 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} engines: {node: '>=18'} peerDependencies: '@testing-library/dom': ^10.0.0 @@ -3137,8 +3137,8 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -4569,8 +4569,8 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dompurify@3.4.14: - resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} + dompurify@3.4.15: + resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} @@ -4967,11 +4967,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6109,8 +6104,8 @@ packages: resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==} engines: {node: ^20.19.0 || >=22.12.0} - oxfmt@0.65.0: - resolution: {integrity: sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==} + oxfmt@0.70.0: + resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6134,8 +6129,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.80.0: - resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==} + oxlint@1.85.0: + resolution: {integrity: sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6265,14 +6260,14 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} - playwright-core@1.59.1: - resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} - engines: {node: '>=18'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} hasBin: true - playwright@1.59.1: - resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} - engines: {node: '>=18'} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} hasBin: true plist@3.1.0: @@ -6301,8 +6296,8 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} - posthog-node@5.33.3: - resolution: {integrity: sha512-6BkdRtRKf/y/j6JGXLUDWpruL9Rebpe2EtbSU3EB+yaI9ukTwEGT8XJQDyM8wcsxu1HdOnvAwN7gnOOu5OgY2A==} + posthog-node@5.52.5: + resolution: {integrity: sha512-r4KRXh2MvcHYh1s3NUca022GqujkJjpmD4qbRzWOLVTu8BfN3fqZj+MyKIxvHPXsS8sKsNVX+MlKk2qzW0Y4GQ==} engines: {node: ^20.20.0 || >=22.22.0} peerDependencies: rxjs: ^7.0.0 @@ -6474,8 +6469,8 @@ packages: react: optional: true - react-i18next@17.0.13: - resolution: {integrity: sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==} + react-i18next@17.0.15: + resolution: {integrity: sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==} peerDependencies: i18next: '>= 26.2.0' react: '>= 16.8.0' @@ -7053,19 +7048,19 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.4.12: - resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + tldts-core@7.4.14: + resolution: {integrity: sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==} - tldts@7.4.12: - resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + tldts@7.4.14: + resolution: {integrity: sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==} hasBin: true tmp-promise@3.0.3: @@ -7437,13 +7432,8 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.8.4: - resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} - engines: {node: '>= 14.6'} - hasBin: true - - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true @@ -7483,8 +7473,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} @@ -7526,18 +7516,18 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)': + '@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.6.5))(zod@4.6.5)': dependencies: - '@anthropic-ai/sdk': 0.122.0(zod@4.5.4) - '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.5.4) - zod: 4.5.4 + '@anthropic-ai/sdk': 0.122.0(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.6.5) + zod: 4.6.5 - '@anthropic-ai/sdk@0.122.0(zod@4.5.4)': + '@anthropic-ai/sdk@0.122.0(zod@4.6.5)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.1.1 optionalDependencies: - zod: 4.5.4 + zod: 4.6.5 '@babel/code-frame@7.29.7': dependencies: @@ -8036,22 +8026,22 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 '@floating-ui/react-dom@2.1.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: @@ -8258,7 +8248,7 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4)': + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.6.5)': dependencies: '@hono/node-server': 2.1.0(hono@4.13.7) ajv: 8.20.0 @@ -8269,14 +8259,14 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.8 express: 5.2.1(supports-color@7.2.0) - express-rate-limit: 8.5.2(express@5.2.1) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.13.7 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.5.4 - zod-to-json-schema: 3.25.2(zod@4.5.4) + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -8291,7 +8281,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.8 express: 5.2.1(supports-color@7.2.0) - express-rate-limit: 8.5.2(express@5.2.1) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.13.7 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -8550,61 +8540,61 @@ snapshots: '@oxc-project/types@0.148.0': optional: true - '@oxfmt/binding-android-arm-eabi@0.65.0': + '@oxfmt/binding-android-arm-eabi@0.70.0': optional: true - '@oxfmt/binding-android-arm64@0.65.0': + '@oxfmt/binding-android-arm64@0.70.0': optional: true - '@oxfmt/binding-darwin-arm64@0.65.0': + '@oxfmt/binding-darwin-arm64@0.70.0': optional: true - '@oxfmt/binding-darwin-x64@0.65.0': + '@oxfmt/binding-darwin-x64@0.70.0': optional: true - '@oxfmt/binding-freebsd-x64@0.65.0': + '@oxfmt/binding-freebsd-x64@0.70.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.65.0': + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.65.0': + '@oxfmt/binding-linux-arm64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.65.0': + '@oxfmt/binding-linux-arm64-musl@0.70.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.65.0': + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.65.0': + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.65.0': + '@oxfmt/binding-linux-riscv64-musl@0.70.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.65.0': + '@oxfmt/binding-linux-s390x-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.65.0': + '@oxfmt/binding-linux-x64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.65.0': + '@oxfmt/binding-linux-x64-musl@0.70.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.65.0': + '@oxfmt/binding-openharmony-arm64@0.70.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.65.0': + '@oxfmt/binding-win32-arm64-msvc@0.70.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.65.0': + '@oxfmt/binding-win32-ia32-msvc@0.70.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.65.0': + '@oxfmt/binding-win32-x64-msvc@0.70.0': optional: true '@oxlint-tsgolint/darwin-arm64@7.0.2001': @@ -8625,66 +8615,66 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.80.0': + '@oxlint/binding-android-arm-eabi@1.85.0': optional: true - '@oxlint/binding-android-arm64@1.80.0': + '@oxlint/binding-android-arm64@1.85.0': optional: true - '@oxlint/binding-darwin-arm64@1.80.0': + '@oxlint/binding-darwin-arm64@1.85.0': optional: true - '@oxlint/binding-darwin-x64@1.80.0': + '@oxlint/binding-darwin-x64@1.85.0': optional: true - '@oxlint/binding-freebsd-x64@1.80.0': + '@oxlint/binding-freebsd-x64@1.85.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.80.0': + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.80.0': + '@oxlint/binding-linux-arm-musleabihf@1.85.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.80.0': + '@oxlint/binding-linux-arm64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.80.0': + '@oxlint/binding-linux-arm64-musl@1.85.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.80.0': + '@oxlint/binding-linux-ppc64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.80.0': + '@oxlint/binding-linux-riscv64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.80.0': + '@oxlint/binding-linux-riscv64-musl@1.85.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.80.0': + '@oxlint/binding-linux-s390x-gnu@1.85.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.80.0': + '@oxlint/binding-linux-x64-gnu@1.85.0': optional: true - '@oxlint/binding-linux-x64-musl@1.80.0': + '@oxlint/binding-linux-x64-musl@1.85.0': optional: true - '@oxlint/binding-openharmony-arm64@1.80.0': + '@oxlint/binding-openharmony-arm64@1.85.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.80.0': + '@oxlint/binding-win32-arm64-msvc@1.85.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.80.0': + '@oxlint/binding-win32-ia32-msvc@1.85.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.80.0': + '@oxlint/binding-win32-x64-msvc@1.85.0': optional: true '@oxlint/plugins@1.78.0': {} - '@oxlint/plugins@1.80.0': {} + '@oxlint/plugins@1.85.0': {} '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -8768,15 +8758,15 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@playwright/test@1.59.1': + '@playwright/test@1.63.0': dependencies: - playwright: 1.59.1 + playwright: 1.63.0 - '@posthog/core@1.28.3': + '@posthog/core@1.55.1': dependencies: - '@posthog/types': 1.372.9 + '@posthog/types': 1.412.4 - '@posthog/types@1.372.9': {} + '@posthog/types@1.412.4': {} '@radix-ui/number@1.1.2': {} @@ -9584,7 +9574,7 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@sanity/diff-match-patch@3.2.0': {} + '@sanity/diff-match-patch@3.2.1': {} '@sec-ant/readable-stream@0.4.1': {} @@ -9606,27 +9596,27 @@ snapshots: '@stablelib/base64@1.0.1': {} - '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)': + '@stablyai/playwright-base@2.1.16(@playwright/test@1.63.0)(zod@4.6.5)': dependencies: - '@playwright/test': 1.59.1 + '@playwright/test': 1.63.0 jpeg-js: 0.4.4 p-retry: 4.6.2 pngjs: 7.0.0 optionalDependencies: - zod: 4.5.4 + zod: 4.6.5 - '@stablyai/playwright-test@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)': + '@stablyai/playwright-test@2.1.16(@playwright/test@1.63.0)(zod@4.6.5)': dependencies: - '@playwright/test': 1.59.1 - '@stablyai/playwright': 2.1.14(@playwright/test@1.59.1)(zod@4.5.4) - '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.5.4) + '@playwright/test': 1.63.0 + '@stablyai/playwright': 2.1.16(@playwright/test@1.63.0)(zod@4.6.5) + '@stablyai/playwright-base': 2.1.16(@playwright/test@1.63.0)(zod@4.6.5) transitivePeerDependencies: - zod - '@stablyai/playwright@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)': + '@stablyai/playwright@2.1.16(@playwright/test@1.63.0)(zod@4.6.5)': dependencies: - '@playwright/test': 1.59.1 - '@stablyai/playwright-base': 2.1.14(@playwright/test@1.59.1)(zod@4.5.4) + '@playwright/test': 1.63.0 + '@stablyai/playwright-base': 2.1.16(@playwright/test@1.63.0)(zod@4.6.5) transitivePeerDependencies: - zod @@ -9759,20 +9749,20 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1) - '@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tanstack/react-virtual@3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tanstack/virtual-core': 3.17.8 + '@tanstack/virtual-core': 3.17.11 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@tanstack/virtual-core@3.17.8': {} + '@tanstack/virtual-core@3.17.11': {} '@testing-library/dom@10.4.1': dependencies: @@ -9794,7 +9784,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 @@ -9804,7 +9794,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -9823,7 +9813,7 @@ snapshots: '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 optional: true @@ -9863,9 +9853,9 @@ snapshots: dependencies: '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 optional: true @@ -10000,7 +9990,7 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.42.3 - '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 @@ -10013,7 +10003,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' @@ -10431,7 +10421,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -10439,7 +10429,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1) transitivePeerDependencies: - supports-color @@ -10452,14 +10442,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -11266,7 +11256,7 @@ snapshots: dom-accessibility-api@0.6.3: {} - dompurify@3.4.14: + dompurify@3.4.15: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -11355,7 +11345,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)): + electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) @@ -11363,7 +11353,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1) optionalDependencies: '@swc/core': 1.15.46 transitivePeerDependencies: @@ -11605,7 +11595,7 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)): dependencies: express: 5.2.1(supports-color@7.2.0) ip-address: 10.4.0 @@ -11791,9 +11781,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -12135,8 +12122,8 @@ snapshots: minimatch: 10.2.5 ora: 9.4.0 react: 19.2.8 - react-i18next: 17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) - yaml: 2.9.0 + react-i18next: 17.0.15(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + yaml: 2.9.1 transitivePeerDependencies: - '@swc/helpers' - '@types/node' @@ -12149,7 +12136,7 @@ snapshots: '@babel/runtime': 7.29.7 '@swc/core': 1.15.46 chokidar: 5.0.0 - yaml: 2.9.0 + yaml: 2.9.1 transitivePeerDependencies: - '@swc/helpers' @@ -12429,7 +12416,7 @@ snapshots: picomatch: 4.0.4 string-argv: 0.3.2 tinyexec: 1.1.2 - yaml: 2.8.4 + yaml: 2.9.1 listr2@9.0.5: dependencies: @@ -12732,7 +12719,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.23 - dompurify: 3.4.14 + dompurify: 3.4.15 es-toolkit: 1.46.1 fastdom: 1.0.12 katex: 0.16.47 @@ -13036,7 +13023,7 @@ snapshots: monaco-editor@0.55.1: dependencies: - dompurify: 3.4.14 + dompurify: 3.4.15 marked: 14.0.0 ms@2.1.3: {} @@ -13254,29 +13241,29 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc': 0.148.0 optional: true - oxfmt@0.65.0: + oxfmt@0.70.0: dependencies: - tinypool: 2.1.0 + tinypool: 2.1.2 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.65.0 - '@oxfmt/binding-android-arm64': 0.65.0 - '@oxfmt/binding-darwin-arm64': 0.65.0 - '@oxfmt/binding-darwin-x64': 0.65.0 - '@oxfmt/binding-freebsd-x64': 0.65.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.65.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.65.0 - '@oxfmt/binding-linux-arm64-gnu': 0.65.0 - '@oxfmt/binding-linux-arm64-musl': 0.65.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.65.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.65.0 - '@oxfmt/binding-linux-riscv64-musl': 0.65.0 - '@oxfmt/binding-linux-s390x-gnu': 0.65.0 - '@oxfmt/binding-linux-x64-gnu': 0.65.0 - '@oxfmt/binding-linux-x64-musl': 0.65.0 - '@oxfmt/binding-openharmony-arm64': 0.65.0 - '@oxfmt/binding-win32-arm64-msvc': 0.65.0 - '@oxfmt/binding-win32-ia32-msvc': 0.65.0 - '@oxfmt/binding-win32-x64-msvc': 0.65.0 + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 oxlint-plugin-anti-slop@https://codeload.github.com/dmmulroy/anti-slop/tar.gz/c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b: dependencies: @@ -13298,27 +13285,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.80.0(oxlint-tsgolint@7.0.2001): + oxlint@1.85.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.80.0 - '@oxlint/binding-android-arm64': 1.80.0 - '@oxlint/binding-darwin-arm64': 1.80.0 - '@oxlint/binding-darwin-x64': 1.80.0 - '@oxlint/binding-freebsd-x64': 1.80.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.80.0 - '@oxlint/binding-linux-arm-musleabihf': 1.80.0 - '@oxlint/binding-linux-arm64-gnu': 1.80.0 - '@oxlint/binding-linux-arm64-musl': 1.80.0 - '@oxlint/binding-linux-ppc64-gnu': 1.80.0 - '@oxlint/binding-linux-riscv64-gnu': 1.80.0 - '@oxlint/binding-linux-riscv64-musl': 1.80.0 - '@oxlint/binding-linux-s390x-gnu': 1.80.0 - '@oxlint/binding-linux-x64-gnu': 1.80.0 - '@oxlint/binding-linux-x64-musl': 1.80.0 - '@oxlint/binding-openharmony-arm64': 1.80.0 - '@oxlint/binding-win32-arm64-msvc': 1.80.0 - '@oxlint/binding-win32-ia32-msvc': 1.80.0 - '@oxlint/binding-win32-x64-msvc': 1.80.0 + '@oxlint/binding-android-arm-eabi': 1.85.0 + '@oxlint/binding-android-arm64': 1.85.0 + '@oxlint/binding-darwin-arm64': 1.85.0 + '@oxlint/binding-darwin-x64': 1.85.0 + '@oxlint/binding-freebsd-x64': 1.85.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.85.0 + '@oxlint/binding-linux-arm-musleabihf': 1.85.0 + '@oxlint/binding-linux-arm64-gnu': 1.85.0 + '@oxlint/binding-linux-arm64-musl': 1.85.0 + '@oxlint/binding-linux-ppc64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-musl': 1.85.0 + '@oxlint/binding-linux-s390x-gnu': 1.85.0 + '@oxlint/binding-linux-x64-gnu': 1.85.0 + '@oxlint/binding-linux-x64-musl': 1.85.0 + '@oxlint/binding-openharmony-arm64': 1.85.0 + '@oxlint/binding-win32-arm64-msvc': 1.85.0 + '@oxlint/binding-win32-ia32-msvc': 1.85.0 + '@oxlint/binding-win32-x64-msvc': 1.85.0 oxlint-tsgolint: 7.0.2001 p-cancelable@2.1.1: {} @@ -13430,13 +13417,11 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 - playwright-core@1.59.1: {} + playwright-core@1.63.0: {} - playwright@1.59.1: + playwright@1.63.0: dependencies: - playwright-core: 1.59.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 plist@3.1.0: dependencies: @@ -13466,9 +13451,9 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-node@5.33.3: + posthog-node@5.52.5: dependencies: - '@posthog/core': 1.28.3 + '@posthog/core': 1.55.1 postject@1.0.0-alpha.6: dependencies: @@ -13713,7 +13698,7 @@ snapshots: optionalDependencies: react: 19.2.8 - react-i18next@17.0.13(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): + react-i18next@17.0.15(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 4.0.1 @@ -13962,7 +13947,7 @@ snapshots: robust-predicates@3.0.3: {} - rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4): + rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1): dependencies: '@oxc-project/runtime': 0.101.0 fdir: 6.5.0(picomatch@4.0.4) @@ -13976,7 +13961,7 @@ snapshots: esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 - yaml: 2.8.4 + yaml: 2.9.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -14380,15 +14365,15 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@2.1.0: {} + tinypool@2.1.2: {} tinyrainbow@3.1.0: {} - tldts-core@7.4.12: {} + tldts-core@7.4.14: {} - tldts@7.4.12: + tldts@7.4.14: dependencies: - tldts-core: 7.4.12 + tldts-core: 7.4.14 tmp-promise@3.0.3: dependencies: @@ -14404,7 +14389,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.12 + tldts: 7.4.14 optional: true trim-lines@3.0.1: {} @@ -14613,10 +14598,10 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -14633,7 +14618,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -14726,9 +14711,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.8.4: {} - - yaml@2.9.0: {} + yaml@2.9.1: {} yargs-parser@18.1.3: dependencies: @@ -14773,13 +14756,13 @@ snapshots: dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.2(zod@4.5.4): + zod-to-json-schema@3.25.2(zod@4.6.5): dependencies: - zod: 4.5.4 + zod: 4.6.5 zod@3.25.76: {} - zod@4.5.4: {} + zod@4.6.5: {} zustand@5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 90020c50741..1cf67829478 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,7 +57,7 @@ allowBuilds: '@orca/windows-registry': false overrides: - monaco-editor>dompurify: 3.4.14 + monaco-editor>dompurify: 3.4.15 patchedDependencies: '@xterm/addon-image@0.10.0-beta.300': config/patches/@xterm__addon-image@0.10.0-beta.300.patch diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index fc372ddc81f..d3231d50bd8 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -321,7 +321,9 @@ describe('registerWorktreeHandlers', () => { expect(finish).toHaveBeenCalledWith(false) expect(removePath).toHaveBeenCalledTimes(mode === 'removal-fails' ? 1 : 0) await rm(orphanPath, { recursive: true, force: true }) - await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({}) + await expect(handlers['worktrees:remove'](null, { worktreeId })).resolves.toEqual({ + catalogVersion: anyCatalogVersion + }) expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(finish).toHaveBeenLastCalledWith(true) } finally { From ff74506c0bde1530fc079a6cf5b592a7a3127860 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 19:40:32 -0700 Subject: [PATCH 019/387] fix(native-chat): keep terminal pane chat ownership stable (#22984) Co-authored-by: m4air --- .../terminal-layout-normalization.ts | 10 ++++++++- .../mobile-session-layout-projection.ts | 3 ++- .../native-chat-leaf-routing.test.ts | 4 ++-- .../native-chat/native-chat-leaf-routing.ts | 5 +++++ .../terminal-layout-leaf-detach.ts | 2 ++ .../terminal-pane/terminal-layout-leaf-ids.ts | 3 +++ .../terminal-layout-pty-ownership.ts | 7 ++++++ .../terminal-shutdown-layout-capture.test.ts | 6 +++-- .../terminal-shutdown-layout-capture.ts | 5 +++++ .../use-terminal-pane-chat-state.ts | 17 +++++++++++++- .../use-terminal-pane-foundation.ts | 4 +++- .../use-terminal-pane-layout-persistence.ts | 11 +++++++++- .../use-terminal-pane-title-effects.ts | 12 +++++++++- .../src/lib/terminal-layout-equality.ts | 1 + .../mobile-session-terminal-tabs.ts | 1 + .../terminal-surfaces.ts | 7 ++++++ .../terminal-write-identity-bailouts.test.ts | 1 + src/shared/terminal-tab-types.ts | 2 ++ src/shared/workspace-session-schema.ts | 1 + .../workspace-session-terminal-schema.test.ts | 22 +++++++++++++++++++ 20 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts index c2cd72247f6..551d57e6e35 100644 --- a/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts +++ b/src/main/persistence/restoring-sessions/terminal-layout-normalization.ts @@ -218,6 +218,10 @@ export function normalizeTerminalLayoutSnapshotForPersistence( inputSnapshot.expandedLeafId && !duplicatedInputLeafIds.has(inputSnapshot.expandedLeafId) ? (leafIdByInputLeafId.get(inputSnapshot.expandedLeafId) ?? null) : null + const chatLeafId = + inputSnapshot.chatLeafId && !duplicatedInputLeafIds.has(inputSnapshot.chatLeafId) + ? (leafIdByInputLeafId.get(inputSnapshot.chatLeafId) ?? null) + : null const ptyIdsByLeafId = remapLeafRecordForPersistence( inputSnapshot.ptyIdsByLeafId, leafIdByInputLeafId, @@ -244,7 +248,9 @@ export function normalizeTerminalLayoutSnapshotForPersistence( !leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) || !leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId) const metadataChanged = - activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId + activeLeafId !== inputSnapshot.activeLeafId || + expandedLeafId !== inputSnapshot.expandedLeafId || + chatLeafId !== (inputSnapshot.chatLeafId ?? null) if (!changed && !recordsChanged && !metadataChanged) { return { snapshot, changed: false, leafIdByInputLeafId } } @@ -253,6 +259,7 @@ export function normalizeTerminalLayoutSnapshotForPersistence( buffersByLeafId: _oldBuffersByLeafId, scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, + chatLeafId: _oldChatLeafId, ...snapshotWithoutLeafRecords } = inputSnapshot return { @@ -261,6 +268,7 @@ export function normalizeTerminalLayoutSnapshotForPersistence( root, activeLeafId, expandedLeafId, + ...(chatLeafId ? { chatLeafId } : {}), ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), diff --git a/src/main/runtime/mobile-session-layout-projection.ts b/src/main/runtime/mobile-session-layout-projection.ts index e495116e81e..3dd92091c77 100644 --- a/src/main/runtime/mobile-session-layout-projection.ts +++ b/src/main/runtime/mobile-session-layout-projection.ts @@ -63,7 +63,8 @@ export function cloneTerminalLayoutSnapshot( const cloned: TerminalLayoutSnapshot = { root: layout.root, activeLeafId: layout.activeLeafId, - expandedLeafId: layout.expandedLeafId + expandedLeafId: layout.expandedLeafId, + ...(layout.chatLeafId ? { chatLeafId: layout.chatLeafId } : {}) } if (layout.ptyIdsByLeafId) { cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index b435d40f797..47a4d020a25 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -135,7 +135,7 @@ describe('resolveNativeChatLeafRoute', () => { ).toEqual({ chatLeafId: 'agent-leaf', exitChat: false }) }) - it('moves chat to an eligible active sibling after its leaf closes', () => { + it('exits chat when the user closes its pane', () => { expect( resolveNativeChatLeafRoute({ isChatViewMode: true, @@ -144,7 +144,7 @@ describe('resolveNativeChatLeafRoute', () => { chatLeafStillMounted: false, activeLeafIsEligible: true }) - ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + ).toEqual({ chatLeafId: null, exitChat: true }) }) it('does not move chat when its mounted leaf temporarily becomes ineligible', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index 45eaa451b95..176194952dc 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -107,6 +107,11 @@ export function resolveNativeChatLeafRoute(args: { if (!args.activeLeafId && !confirmedAgentExit) { return { chatLeafId: args.chatLeafId, exitChat: false } } + if (args.chatLeafId && !args.chatLeafStillMounted && !confirmedAgentExit) { + // A user-closed chat pane is an explicit close, not an agent handoff. Do not + // retarget the chat surface to whichever sibling became active. + return { chatLeafId: null, exitChat: true } + } if (args.activeLeafIsEligible && (!confirmedAgentExit || args.activeLeafId !== args.chatLeafId)) { return { chatLeafId: args.activeLeafId, exitChat: false } } diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts index 4287e4eeee2..ffa88d60783 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -94,6 +94,7 @@ export function detachTerminalLayoutLeaf( ptyIdsByLeafId }), expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId, + ...(layout.chatLeafId && layout.chatLeafId !== leafId ? { chatLeafId: layout.chatLeafId } : {}), ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), @@ -110,6 +111,7 @@ export function detachTerminalLayoutLeaf( root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null, + ...(layout.chatLeafId === leafId ? { chatLeafId: leafId } : {}), ...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}), ...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}), ...(detachedScrollbackRefsByLeafId diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index 37870c03d70..7ea60830f9c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -182,6 +182,7 @@ function normalizeTerminalLayoutLeafIds(snapshot: TerminalLayoutSnapshot | null // selection ids must not strand focus on a missing pane. const remappedActiveLeafId = getRemappedLeafId(activeLeafId, rewrite) ?? firstLeafId(root) const remappedExpandedLeafId = getRemappedLeafId(expandedLeafId, rewrite) + const remappedChatLeafId = getRemappedLeafId(snapshot.chatLeafId, rewrite) const ptyIdsByLeafId = remapLeafRecord(snapshot.ptyIdsByLeafId, rewrite) const buffersByLeafId = remapLeafRecord(snapshot.buffersByLeafId, rewrite) const scrollbackRefsByLeafId = remapLeafRecord(snapshot.scrollbackRefsByLeafId, rewrite) @@ -191,6 +192,7 @@ function normalizeTerminalLayoutLeafIds(snapshot: TerminalLayoutSnapshot | null buffersByLeafId: _oldBuffersByLeafId, scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, + chatLeafId: _oldChatLeafId, ...snapshotWithoutLeafRecords } = snapshot return { @@ -203,6 +205,7 @@ function normalizeTerminalLayoutLeafIds(snapshot: TerminalLayoutSnapshot | null ptyIdsByLeafId }), expandedLeafId: remappedExpandedLeafId, + ...(remappedChatLeafId ? { chatLeafId: remappedChatLeafId } : {}), ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts index 3687621f151..001be213fab 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.ts @@ -205,6 +205,9 @@ export function normalizeTerminalLayoutPtyOwnership( const ptyIdsByLeafId = coalesceLeafRecord(snapshot.ptyIdsByLeafId, retainedLeafIdByRemovedLeafId) const rootLeafIds = collectLeafIds(root) const activeLeafId = resolveOwnedActiveLeafId(rootLeafIds, mappedActiveLeafId, ptyIdsByLeafId) + const remappedChatLeafId = snapshot.chatLeafId + ? resolveRetainedLeafId(snapshot.chatLeafId, retainedLeafIdByRemovedLeafId) + : null const { buffersByLeafId, scrollbackRefsByLeafId } = coalesceScrollbackRecords( snapshot.buffersByLeafId, snapshot.scrollbackRefsByLeafId, @@ -217,6 +220,7 @@ export function normalizeTerminalLayoutPtyOwnership( buffersByLeafId: _oldBuffersByLeafId, scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, + chatLeafId: _oldChatLeafId, ...snapshotWithoutLeafRecords } = snapshot @@ -231,6 +235,9 @@ export function normalizeTerminalLayoutPtyOwnership( rootLeafIds.includes(snapshot.expandedLeafId) ? snapshot.expandedLeafId : null, + ...(remappedChatLeafId && rootLeafIds.includes(remappedChatLeafId) + ? { chatLeafId: remappedChatLeafId } + : {}), ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 066aadfdb46..7e75da603ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -190,7 +190,8 @@ describe('captureTerminalShutdownLayout', () => { expandedPaneId: null, paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]), paneTitlesByPaneId: { 1: 'build logs' }, - existingLayout: undefined + existingLayout: undefined, + chatLeafId: LEAF_ID }) expect(order).toEqual(['flush', 'serialize']) @@ -200,7 +201,8 @@ describe('captureTerminalShutdownLayout', () => { expandedLeafId: null, buffersByLeafId: { [LEAF_ID]: `snapshot:queued-before-quit${CURSOR_HOME}` }, ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }, - titlesByLeafId: { [LEAF_ID]: 'build logs' } + titlesByLeafId: { [LEAF_ID]: 'build logs' }, + chatLeafId: LEAF_ID }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 523db618d33..f53c96347d8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -25,6 +25,7 @@ type CaptureTerminalShutdownLayoutArgs = { paneTransports: ReadonlyMap> paneTitlesByPaneId: Record existingLayout: TerminalLayoutSnapshot | undefined + chatLeafId?: string | null /** Merge prior for buffers, resolved across both scrollback homes; defaults to the shared layout's. */ priorBuffersByLeafId?: Record captureBuffers?: boolean @@ -98,6 +99,7 @@ export function captureTerminalShutdownLayout({ paneTransports, paneTitlesByPaneId, existingLayout, + chatLeafId, priorBuffersByLeafId = existingLayout?.buffersByLeafId, captureBuffers = true, clearedScrollbackLeafIds @@ -141,6 +143,9 @@ export function captureTerminalShutdownLayout({ new Map(panes.map((pane) => [pane.id, pane.leafId])) ) const currentLeafIds = new Set(panes.map((p) => p.leafId)) + if (chatLeafId && panes.some((pane) => pane.leafId === chatLeafId)) { + layout.chatLeafId = chatLeafId + } const livePtyIdsByLeafId: Record = {} const preservedPtyIdsByLeafId: Record = {} for (const pane of panes) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 5f6985936b3..8874150df89 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -33,6 +33,7 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController clearCodexRestartNotice, consumePendingCodexPaneRestart, setTabCanExpandPane, + setTabLayout, setTabPaneExpanded, setTabViewMode, suppressPtyExit, @@ -148,15 +149,29 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController ) const applyNativeChatLeafRoute = useCallback( (route: NativeChatLeafRoute): void => { + if (!isChatViewMode && chatLeafId && route.chatLeafId === null) { + // Keep the owner through the batched toggle that turns chat mode on. + return + } if (route.chatLeafId !== chatLeafId) { setChatLeafId(route.chatLeafId) } + const existingLayout = useAppStore.getState().terminalLayoutsByTabId[tabId] + if (existingLayout && existingLayout.chatLeafId !== (route.chatLeafId ?? undefined)) { + if (route.chatLeafId) { + setTabLayout(tabId, { ...existingLayout, chatLeafId: route.chatLeafId }) + } else if (existingLayout?.chatLeafId) { + const nextLayout = { ...existingLayout } + delete nextLayout.chatLeafId + setTabLayout(tabId, nextLayout) + } + } if (route.exitChat && unifiedTabId) { setTabViewMode(unifiedTabId, 'terminal') } }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - [chatLeafId, setTabViewMode, unifiedTabId] + [chatLeafId, isChatViewMode, setTabLayout, setTabViewMode, tabId, unifiedTabId] ) const handleConfirmedAgentExit = useCallback( (leafId: string): void => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts index 7114b07e1bb..7a0da0ec79e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts @@ -123,7 +123,9 @@ export function useTerminalPaneFoundation( const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false) const [quickCommandEditorHostId, setQuickCommandEditorHostId] = useState(LOCAL_EXECUTION_HOST_ID) - const [chatLeafId, setChatLeafId] = useState(null) + const [chatLeafId, setChatLeafId] = useState( + () => useAppStore.getState().terminalLayoutsByTabId[tabId]?.chatLeafId ?? null + ) const onAgentExitedRef = useRef<(leafId: string) => void>(() => {}) const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( undefined diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index b211a4ae4ba..014d7b34e28 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -16,7 +16,9 @@ import type { TerminalPaneStartupController } from './use-terminal-pane-startup- export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartupController) { const { clearedScrollbackLeafIdsRef, + chatLeafId, containerRef, + effectiveChatViewMode, expandedPaneIdRef, managerRef, paneCount, @@ -49,6 +51,13 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup const existing = useAppStore.getState().terminalLayoutsByTabId[tabId] const currentPanes = manager.getPanes() const currentLeafIds = new Set(currentPanes.map((pane) => pane.leafId)) + if ( + effectiveChatViewMode && + chatLeafId && + currentPanes.some((pane) => pane.leafId === chatLeafId) + ) { + layout.chatLeafId = chatLeafId + } const clearedScrollbackLeafIds = clearedScrollbackLeafIdsRef.current const scrollbackPreserveLeafIds = new Set( [...currentLeafIds].filter((leafId) => !clearedScrollbackLeafIds.has(leafId)) @@ -120,7 +129,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup clearedScrollbackLeafIds.delete(leafId) } // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [tabId, setTabLayout, worktreeId]) + }, [chatLeafId, effectiveChatViewMode, tabId, setTabLayout, worktreeId]) const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts index 6ca710a55d5..dccbb079f30 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts @@ -29,10 +29,12 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl clearTerminalPaneUnread, clearTerminalTabUnread, clearWorktreeUnread, + chatLeafId, clearedScrollbackLeafIdsRef, containerRef, expandedPaneId, expandedPaneIdRef, + effectiveChatViewMode, isolatedPaneKey, isVisible, managerRef, @@ -215,6 +217,7 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl paneTransports: paneTransportsRef.current, paneTitlesByPaneId: paneTitlesRef.current, existingLayout: existing, + chatLeafId: effectiveChatViewMode ? chatLeafId : null, // Why resolved: a pane that serializes empty mid-replay must fall back to whichever home // holds its last good copy, not only the shared one. priorBuffersByLeafId: resolveLeafScrollbackBuffers({ @@ -247,7 +250,14 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl } } // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [tabId, worktreeId, setTabLayout, setTabLocalOnlyScrollback]) + }, [ + chatLeafId, + effectiveChatViewMode, + tabId, + worktreeId, + setTabLayout, + setTabLocalOnlyScrollback + ]) useEffect(() => { if (renamingPaneId === null) { diff --git a/src/renderer/src/lib/terminal-layout-equality.ts b/src/renderer/src/lib/terminal-layout-equality.ts index 88eca78e0d3..b13dcc37bb9 100644 --- a/src/renderer/src/lib/terminal-layout-equality.ts +++ b/src/renderer/src/lib/terminal-layout-equality.ts @@ -49,6 +49,7 @@ export function terminalLayoutEqual( terminalLayoutNodeEqual(a?.root, b.root) && (a?.activeLeafId ?? null) === b.activeLeafId && (a?.expandedLeafId ?? null) === b.expandedLeafId && + (a?.chatLeafId ?? null) === (b.chatLeafId ?? null) && sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) && sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) && sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) && diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts index ac88ad17eb6..ce3df5a71f0 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts @@ -54,6 +54,7 @@ export function buildMobileTerminalSurfaceTabs( }), activeLeafId, expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null, + ...(sanitizedSavedLayout?.chatLeafId ? { chatLeafId: sanitizedSavedLayout.chatLeafId } : {}), ...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}), ...(sanitizedSavedLayout?.titlesByLeafId ? { titlesByLeafId: sanitizedSavedLayout.titlesByLeafId } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts index 7c76129ae9e..8810af4b933 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -200,6 +200,12 @@ export function chooseRemoteTerminalLayout( : parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId) ? parentLayout.expandedLeafId : null + const chatLeafId = + parentLayout?.chatLeafId && knownLeafIds.has(parentLayout.chatLeafId) + ? parentLayout.chatLeafId + : existingLayout?.chatLeafId && knownLeafIds.has(existingLayout.chatLeafId) + ? existingLayout.chatLeafId + : undefined // Why retained: this rebuilds the layout from the host's picture, and the host publishes no // scrollback of its own — a parked remote pane's bytes live only in the client's copy. Without // this, ANY inventory frame landing between park and reveal drops the only copy: the rebuild is @@ -219,6 +225,7 @@ export function chooseRemoteTerminalLayout( }), activeLeafId, expandedLeafId, + ...(chatLeafId ? { chatLeafId } : {}), ptyIdsByLeafId, // Why: surface.title is the tab/PTY label, not a pane title; restoring it as one renders a fake title bar. Only host layout titles are real pane titles. ...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {}) diff --git a/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts b/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts index 14aecc47129..0bb7ae090de 100644 --- a/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts +++ b/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts @@ -151,6 +151,7 @@ describe('setTabLayout identity bailout', () => { const mutations: Partial[] = [ { activeLeafId: 'leaf-b' }, { expandedLeafId: 'leaf-a' }, + { chatLeafId: 'leaf-b' }, { titlesByLeafId: { 'leaf-a': 'test' } }, { ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-b': 'pty-c' } }, { buffersByLeafId: { 'leaf-a': 'scrollback' } }, diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index d99472e2fde..9f7d9428b0a 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -132,6 +132,8 @@ export type TerminalLayoutSnapshot = { root: TerminalPaneLayoutNode | null activeLeafId: string | null expandedLeafId: string | null + /** Stable leaf that owns the terminal tab's native chat surface. */ + chatLeafId?: string /** Live PTY IDs per leaf for in-session remounts such as tab-group moves. * Not used for app restart because PTYs are transient processes. */ ptyIdsByLeafId?: Record diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index f2138905960..54448e1e547 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -71,6 +71,7 @@ const terminalLayoutSnapshotSchema = z.object({ root: terminalPaneLayoutNodeSchema.nullable(), activeLeafId: z.string().nullable(), expandedLeafId: z.string().nullable(), + chatLeafId: z.string().optional(), ptyIdsByLeafId: salvagedOptional('ptyIdsByLeafId', leafStringsSchema), buffersByLeafId: salvagedOptional('buffersByLeafId', leafStringsSchema), scrollbackRefsByLeafId: salvagedOptional('scrollbackRefsByLeafId', leafStringsSchema), diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 18a930fa492..a01998a9512 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from 'vitest' import { parseWorkspaceSession } from './workspace-session-schema' describe('parseWorkspaceSession terminal fields', () => { + it('preserves the native chat owner leaf in a terminal layout', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: {}, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'leaf-chat' }, + activeLeafId: 'leaf-chat', + expandedLeafId: null, + chatLeafId: 'leaf-chat' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.terminalLayoutsByTabId.tab1?.chatLeafId).toBe('leaf-chat') + } + }) + it('preserves terminal startup cwd while accepting older omitted fields', () => { const result = parseWorkspaceSession({ activeRepoId: null, From a00c424a1159468f8843d1d47fbb38302a467dde Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 20:00:04 -0700 Subject: [PATCH 020/387] Preserve pending SSH terminal layout edits (#22991) Co-authored-by: m4air --- .../app-shell/use-app-session-persistence.ts | 29 +++++++ ...mote-workspace-target-sync-test-harness.ts | 3 + .../hooks/remote-workspace-session-merge.ts | 8 +- ...pshot-apply-deferred-session-write.test.ts | 87 ++++++++++++++++++- .../hooks/remote-workspace-snapshot-apply.ts | 12 ++- .../remote-workspace-target-sync.test.ts | 28 ++++++ .../src/hooks/remote-workspace-target-sync.ts | 17 ++++ .../store/slices/ssh-target-cleanup.test.ts | 13 +++ .../src/store/slices/ssh-target-cleanup.ts | 16 +++- .../terminal-write-identity-bailouts.test.ts | 71 ++++++++++++++- src/renderer/src/store/slices/terminals.ts | 1 + .../teardown/remove-worktree-store-cleanup.ts | 1 + .../teardown/worktree-purge-state.ts | 1 + .../src/store/terminals/terminal-actions.ts | 3 +- .../store/terminals/terminal-layout-state.ts | 43 ++++++++- .../src/store/terminals/terminal-state.ts | 7 ++ .../src/store/terminals/terminal-tab-close.ts | 1 + .../workspace-terminal-hydration-patch.ts | 5 ++ 18 files changed, 331 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index e187ecb19d5..4d1e9987a09 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -4,6 +4,7 @@ import { isDirectSshRemoteWorkspaceApplyInProgress, onDirectSshRemoteWorkspaceApplyWindowClosed } from '../hooks/remote-workspace-snapshot-apply' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -37,6 +38,8 @@ import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' import type { AppState } from '../store/types' +import type { DirectSshLayoutEdit } from '../store/terminals/terminal-state' +import type { RemoteWorkspaceObservedPatchResult } from '../../../shared/remote-workspace-types' import { applyRemoteWorkspacePushStatus } from '../hooks/remote-workspace-push-status' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. @@ -89,6 +92,26 @@ function remoteWorkspaceUploadAuthorityIsCurrent( ) } +function captureUploadedDirectSshLayoutEdits( + pendingLayoutEdits: AppState['pendingDirectSshLayoutEditsByTabId'], + targetId: string, + result: RemoteWorkspaceObservedPatchResult | undefined +): Record { + if (!result?.ok) { + return {} + } + return Object.fromEntries( + Object.entries(pendingLayoutEdits).flatMap(([tabId, entry]) => { + const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] + return entry.targetId === targetId && + uploaded && + terminalLayoutNodeEqual(entry.root, uploaded.root) + ? [[tabId, entry]] + : [] + }) + ) +} + /** * Writes durable renderer session state to disk: the debounced per-host writer, the remote * workspace upload chain, and the synchronous shutdown checkpoint. @@ -108,6 +131,7 @@ export function useAppSessionPersistence(): void { const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) void localWrite const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) + const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId if (uploadAuthorities.length > 0) { void (async () => { try { @@ -141,6 +165,11 @@ export function useAppSessionPersistence(): void { for (const { targetId, result } of results ?? []) { const authority = currentAuthorityByTargetId.get(targetId) if (authority && remoteWorkspaceUploadAuthorityIsCurrent(resultState, authority)) { + if (result?.ok) { + resultState.acknowledgeDirectSshLayoutEdits( + captureUploadedDirectSshLayoutEdits(pendingLayoutEdits, targetId, result) + ) + } applyRemoteWorkspacePushStatus(resultState, targetId, result, authority) } } diff --git a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts index fcc8909af3c..3884704d2a1 100644 --- a/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts +++ b/src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts @@ -82,6 +82,7 @@ export function worktree(id = 'repo-a::/remote/work') { } export function appState(overrides: Record = {}): AppState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture intentionally supplies only the slices used by target-sync tests. return { workspaceSessionReady: true, repos: [repo()], @@ -100,6 +101,7 @@ export function appState(overrides: Record = {}): AppState { directSshPaneRetryByTabId: {}, directSshLivePtyBindingByTabId: {}, terminalLayoutsByTabId: {}, + pendingDirectSshLayoutEditsByTabId: {}, activeTabIdByWorktree: {}, openFiles: [], editorDrafts: {}, @@ -125,6 +127,7 @@ export function appState(overrides: Record = {}): AppState { markRemoteWorkspaceHydrated: vi.fn(), clearRemoteWorkspaceHydrated: vi.fn(), setRemoteWorkspaceSyncStatus: vi.fn(), + acknowledgeDirectSshLayoutEdits: vi.fn(), reconnectPersistedTerminals: vi.fn(async () => {}), ...overrides } as unknown as AppState diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 1932ad30eca..964c942e0ad 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -33,7 +33,8 @@ export function mergeDirectSshRemoteWorkspaceSession( liveTabsByWorktree: AppState['tabsByWorktree'], preserveLocalTerminalTabIds: ReadonlySet, replaceExecutionHostId?: ExecutionHostId, - remoteRevision?: number + remoteRevision?: number, + preserveLocalLayoutTabIds: ReadonlySet = new Set() ): WorkspaceSessionState { // Live tabs across the worktrees this snapshot replaces. Close-suppression consults it so a tab // that is still live locally always beats its own tombstone. @@ -243,15 +244,16 @@ export function mergeDirectSshRemoteWorkspaceSession( ) }) ) + const preservedLayoutTabIds = new Set([...locallyPreservedTabIds, ...preserveLocalLayoutTabIds]) const terminalLayoutsByTabId = { ...Object.fromEntries( Object.entries(current.terminalLayoutsByTabId).filter( - ([tabId]) => !replacedTabIds.has(tabId) || locallyPreservedTabIds.has(tabId) + ([tabId]) => !replacedTabIds.has(tabId) || preservedLayoutTabIds.has(tabId) ) ), ...Object.fromEntries( Object.entries(remote.terminalLayoutsByTabId) - .filter(([tabId]) => !locallyPreservedTabIds.has(tabId) && !suppressedTabIds.has(tabId)) + .filter(([tabId]) => !preservedLayoutTabIds.has(tabId) && !suppressedTabIds.has(tabId)) // Why: this replace is wholesale, and a park capture does not bump tab.generation, so a // just-parked tab is not locally preserved and the only client-side copy of its remote // scrollback would go with its layout. Structure stays the host's. diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts index 9b78320a9be..00b2e8b0c26 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts @@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { RemoteWorkspaceObservedSnapshot } from '../../../shared/remote-workspace-types' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' import { @@ -139,11 +141,15 @@ describe('session writes deferred by a direct-SSH apply', () => { vi.useRealTimers() }) - async function applyTargetA(store: TestStore, onApplied: () => void): Promise { - await applyDirectSshRemoteWorkspaceSnapshot({ + async function applyTargetA( + store: TestStore, + onApplied: () => void, + remote = snapshot(1) + ): Promise { + const result = await applyDirectSshRemoteWorkspaceSnapshot({ store, - snapshot: snapshot(1), - token: token(1), + snapshot: remote, + token: token(remote.revision), arrival: 1, isArrivalCurrent: () => true, isPreparationTokenCurrent: () => true, @@ -154,6 +160,7 @@ describe('session writes deferred by a direct-SSH apply', () => { return 0 } }) + expect(result).toBe('applied') } function seedBothTargets(store: TestStore): void { @@ -214,6 +221,78 @@ describe('session writes deferred by a direct-SSH apply', () => { } }) + it.each(['missing', 'outdated'] as const)( + 'preserves a local split awaiting upload when reconnect has a %s layout', + async (remoteLayout) => { + const store = createTestStore() + seedCatalog(store) + const first = '11111111-1111-4111-8111-111111111111' + const second = '22222222-2222-4222-8222-222222222222' + const original: TerminalLayoutSnapshot = { + root: { type: 'leaf', leafId: first }, + activeLeafId: first, + expandedLeafId: null, + ptyIdsByLeafId: { [first]: 'pty-tab-a' } + } + const initial = snapshot(1) + initial.session.terminalLayoutsByTabId = { 'tab-a': original } + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ + store, + persist, + shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), + subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed + }) + try { + store.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + await applyTargetA(store, () => {}, initial) + store.getState().updateTabPtyId('tab-a', toAppSshPtyId(TARGET_A, 'pty-tab-a')) + vi.advanceTimersByTime(1_200) + persist.mockClear() + const generation = store.getState().tabsByWorktree[WORKTREE_A][0].generation + const split: TerminalLayoutSnapshot = { + ...original, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: first }, + second: { type: 'leaf', leafId: second } + }, + activeLeafId: second, + ptyIdsByLeafId: { + [first]: toAppSshPtyId(TARGET_A, 'pty-tab-a'), + [second]: toAppSshPtyId(TARGET_A, 'pty-split') + } + } + store.getState().setTabLayout('tab-a', split) + expect(store.getState().tabsByWorktree[WORKTREE_A][0].generation).toBe(generation) + expect(persist).not.toHaveBeenCalled() + + store.getState().clearDirectSshTargetPtyBindings(TARGET_A) + expect(store.getState().directSshPaneRetryByTabId).toEqual({}) + expect(store.getState().directSshLivePtyBindingByTabId).toEqual({}) + expect(store.getState().tabsByWorktree[WORKTREE_A][0].pendingActivationSpawn).toBeFalsy() + const stale = snapshot(1) + stale.session.tabsByWorktreePath[PATH_A][0].generation = generation + if (remoteLayout === 'outdated') { + stale.session.terminalLayoutsByTabId = { 'tab-a': original } + } + await applyTargetA(store, () => {}, stale) + vi.advanceTimersByTime(1_200) + + const restored = store.getState().terminalLayoutsByTabId['tab-a'] + expect(restored?.root).toEqual(split.root) + expect(restored?.ptyIdsByLeafId).toEqual(split.ptyIdsByLeafId) + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.terminalLayoutsByTabId?.['tab-a']?.root).toEqual( + split.root + ) + } finally { + cleanup() + } + } + ) + it('still wakes the deferred write when the wall clock steps back during the tail', async () => { const store = createTestStore() seedBothTargets(store) diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index af2e845ab51..e304ff17597 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -182,7 +182,17 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ state.tabsByWorktree, currentRecoveryTabIds(state, authority, worktreeIds), toSshExecutionHostId(authority.targetId), - snapshot.revision + snapshot.revision, + new Set( + [...worktreeIds].flatMap((id) => + (state.tabsByWorktree[id] ?? []) + .filter( + (tab) => + state.pendingDirectSshLayoutEditsByTabId[tab.id]?.targetId === authority.targetId + ) + .map((tab) => tab.id) + ) + ) ) if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) { return 'stale' diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts index 7d9f662ffe0..65f7d2c0204 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.test.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.test.ts @@ -45,6 +45,34 @@ describe('createRemoteWorkspaceTargetSync', () => { ) }) + it('acknowledges the captured blank-host edit when a newer null-root edit arrives', async () => { + const firstEdit = { targetId: 'target-a', root: null } + const newerEdit = { targetId: 'target-a', root: null } + const acknowledgeDirectSshLayoutEdits = vi.fn() + const state = appState({ + tabsByWorktree: { + 'repo-a::/remote/work': [{ id: 'tab-a', worktreeId: 'repo-a::/remote/work', ptyId: null }] + }, + pendingDirectSshLayoutEditsByTabId: { 'tab-a': firstEdit }, + acknowledgeDirectSshLayoutEdits + }) + const harness = createHarness(state, async () => snapshot(0)) + const uploaded = snapshot(1) + uploaded.session.terminalLayoutsByTabId = { + 'tab-a': { root: null, activeLeafId: null, expandedLeafId: null } + } + harness.setForConnectedTargets.mockImplementationOnce(async () => { + state.pendingDirectSshLayoutEditsByTabId = { 'tab-a': newerEdit } + return [{ targetId: owner.targetId, result: { ok: true, snapshot: uploaded } }] + }) + + await harness.sync.syncAfterConnect(token()) + + const acknowledged = acknowledgeDirectSshLayoutEdits.mock.calls[0]?.[0]?.['tab-a'] + expect(acknowledged).toBe(firstEdit) + expect(state.pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(newerEdit) + }) + it.each([ ['stale-revision', 'Workspace changed on another device'], ['unavailable', 'Remote workspace sync unavailable'] diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index c3d99224dbc..05d027ece2e 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -12,6 +12,7 @@ import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapsh import { createRemoteWorkspaceSnapshotArrivalCoordinator } from './remote-workspace-snapshot-arrival-coordinator' import { createDeferredSnapshotPlacementRetries } from './remote-workspace-deferred-placement-retry' import { applyRemoteWorkspacePushStatus } from './remote-workspace-push-status' +import { terminalLayoutNodeEqual } from '../lib/terminal-layout-equality' import { waitForRemoteWorkspaceSessionReady } from './remote-workspace-session-readiness' import type { RemoteWorkspaceTargetSync, @@ -191,6 +192,7 @@ export function createRemoteWorkspaceTargetSync( if (!isArrivalCurrent(authority.targetId, arrival) || !deps.isPreparationTokenCurrent(token)) { return } + const pendingLayoutEdits = deps.store.getState().pendingDirectSshLayoutEditsByTabId const results = await deps.remoteWorkspace.setForConnectedTargets({ session: buildWorkspaceSessionPayload(deps.store.getState()), hydratedTargetIds: [authority.targetId], @@ -203,6 +205,21 @@ export function createRemoteWorkspaceTargetSync( return } const result = results.find((entry) => entry.targetId === authority.targetId)?.result + if (result?.ok) { + const currentState = deps.store.getState() + currentState.acknowledgeDirectSshLayoutEdits( + Object.fromEntries( + Object.entries(pendingLayoutEdits ?? {}).flatMap(([tabId, entry]) => { + const uploaded = result.snapshot.session.terminalLayoutsByTabId[tabId] + return entry.targetId === authority.targetId && + uploaded && + terminalLayoutNodeEqual(entry.root, uploaded.root) + ? [[tabId, entry]] + : [] + }) + ) + ) + } applyRemoteWorkspacePushStatus(deps.store.getState(), authority.targetId, result, snapshot) } diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts index 91872ff245e..b3b7323ce20 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts @@ -127,4 +127,17 @@ describe('SSH target cleanup tab map', () => { store.getState().clearRemovedSshTargetState('removed') expect(store.getState().tabsByWorktree).toBe(tabsByWorktree) }) + + it('clears pending split edits owned by the removed target', () => { + const store = createTestStore() + const removed = { targetId: 'removed', root: null } + const retained = { targetId: 'other', root: null } + store.setState({ + pendingDirectSshLayoutEditsByTabId: { removedTab: removed, otherTab: retained } + }) + + const patch = buildRemovedSshTargetCleanupPatch(store.getState(), 'removed') + + expect(patch?.pendingDirectSshLayoutEditsByTabId).toEqual({ otherTab: retained }) + }) }) diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 9cac633bedd..859fde228f6 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -227,6 +227,14 @@ export function buildRemovedSshTargetCleanupPatch( targetId, targetTabIds ) + const nextPendingLayoutEdits = Object.fromEntries( + Object.entries(state.pendingDirectSshLayoutEditsByTabId ?? {}).filter( + ([, entry]) => entry.targetId !== targetId + ) + ) + const removedPendingLayoutEdits = + Object.keys(nextPendingLayoutEdits).length !== + Object.keys(state.pendingDirectSshLayoutEditsByTabId ?? {}).length const nextDeferredTargets = state.deferredSshReconnectTargets.filter((id) => id !== targetId) const nextTransientClearedConnections = { @@ -273,7 +281,8 @@ export function buildRemovedSshTargetCleanupPatch( removedPendingReconnect || removedPaneRetries || removedLiveBindings || - removedRetryHistory + removedRetryHistory || + removedPendingLayoutEdits if (!changed) { return null } @@ -304,6 +313,9 @@ export function buildRemovedSshTargetCleanupPatch( ...(removedPendingReconnect ? { pendingReconnectPtyIdByTabId: nextPendingReconnect } : {}), ...(removedPaneRetries ? { directSshPaneRetryByTabId: nextPaneRetries } : {}), ...(removedLiveBindings ? { directSshLivePtyBindingByTabId: nextLiveBindings } : {}), - ...(removedRetryHistory ? { directSshPaneRetryHistoryByTabId: nextRetryHistory } : {}) + ...(removedRetryHistory ? { directSshPaneRetryHistoryByTabId: nextRetryHistory } : {}), + ...(removedPendingLayoutEdits + ? { pendingDirectSshLayoutEditsByTabId: nextPendingLayoutEdits } + : {}) } } diff --git a/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts b/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts index 0bb7ae090de..e559829a29f 100644 --- a/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts +++ b/src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts @@ -14,7 +14,7 @@ */ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import { createTestStore } from './store-test-helpers' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' const REPEATS = 1_000 @@ -225,4 +225,73 @@ describe('setTabLayout identity bailout', () => { unsubscribe() expect(wakeups).toBe(0) }) + + it('guards SSH acknowledgements by captured edit identity and target', () => { + const store = createTestStore() + const worktreeA = 'repo-a::/tmp/a' + const worktreeB = 'repo-b::/tmp/b' + store.setState({ + repos: [ + { + id: 'repo-a', + path: '/tmp', + displayName: 'Repo A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + }, + { + id: 'repo-b', + path: '/tmp', + displayName: 'Repo B', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-b' + } + ], + worktreesByRepo: { + 'repo-a': [makeWorktree({ id: worktreeA, repoId: 'repo-a', hostId: 'ssh:target-a' })], + 'repo-b': [makeWorktree({ id: worktreeB, repoId: 'repo-b', hostId: 'ssh:target-b' })] + }, + tabsByWorktree: { + [worktreeA]: [makeTab({ id: 'tab-a', worktreeId: worktreeA })], + [worktreeB]: [makeTab({ id: 'tab-b', worktreeId: worktreeB })] + } + }) + const empty = { root: null, activeLeafId: null, expandedLeafId: null } + + store.getState().setTabLayout('tab-a', { + ...empty, + root: { type: 'leaf', leafId: 'leaf-initial' }, + activeLeafId: 'leaf-initial' + }) + store.getState().setTabLayout('tab-a', empty) + const firstEdit = store.getState().pendingDirectSshLayoutEditsByTabId['tab-a'] + store.getState().setTabLayout('tab-a', { + ...empty, + root: { type: 'leaf', leafId: 'leaf-a' }, + activeLeafId: 'leaf-a' + }) + const currentEdit = store.getState().pendingDirectSshLayoutEditsByTabId['tab-a'] + expect(currentEdit).not.toBe(firstEdit) + + store.getState().acknowledgeDirectSshLayoutEdits({ 'tab-a': firstEdit }) + expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-a']).toBe(currentEdit) + + store.getState().setTabLayout('tab-b', { + ...empty, + root: { type: 'leaf', leafId: 'leaf-initial-b' }, + activeLeafId: 'leaf-initial-b' + }) + store.getState().setTabLayout('tab-b', empty) + const targetBEdit = store.getState().pendingDirectSshLayoutEditsByTabId['tab-b'] + store.getState().acknowledgeDirectSshLayoutEdits({ 'tab-a': currentEdit }) + expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-a']).toBeUndefined() + expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-b']).toBe(targetBEdit) + + store.getState().acknowledgeDirectSshLayoutEdits({ + 'tab-b': { targetId: 'target-a', root: targetBEdit.root } + }) + expect(store.getState().pendingDirectSshLayoutEditsByTabId['tab-b']).toBe(targetBEdit) + }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 36f08c497ca..1da1c484509 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -42,6 +42,7 @@ export const createTerminalSlice: StateCreator expandedPaneByTabId: {}, canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, + pendingDirectSshLayoutEditsByTabId: {}, localOnlyScrollbackByTabId: {}, pendingStartupByTabId: {}, pendingInitialCwdByTabId: {}, diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 2a0068e3f4b..d19ab200ccf 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -60,6 +60,7 @@ export function applyRemoveWorktreeSuccessState( nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId), unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds), terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), + pendingDirectSshLayoutEditsByTabId: omitByTabId(s.pendingDirectSshLayoutEditsByTabId), localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId), diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index db501c284f4..de3598c9cb2 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -117,6 +117,7 @@ export function buildWorktreePurgeState( workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey), tabsByWorktree: omitByWorktree(s.tabsByWorktree), terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), + pendingDirectSshLayoutEditsByTabId: omitByTabId(s.pendingDirectSshLayoutEditsByTabId), localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId), runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 9720e08cacf..d1bc838346d 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,5 +1,5 @@ import type { AgentAttentionUnreadReason } from '@/attention/agent-attention-contract' -import type { TerminalState } from './terminal-state' +import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -189,6 +189,7 @@ export type TerminalActions = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + acknowledgeDirectSshLayoutEdits: (uploaded: Readonly>) => void /** Client-local park scrollback for a tab. Never uploaded; read via `resolveLeafScrollback`. */ setTabLocalOnlyScrollback: (tabId: string, buffersByLeafId: Record | null) => void syncPaneDetachPtyOwnership: (args: { diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 4e33ce87fc6..455e73eb0b5 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -1,5 +1,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' -import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' +import { terminalLayoutEqual, terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' +import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' +import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' import { normalizeTerminalLayoutPtyOwnership, resolveTerminalLayoutPtyOwnershipTransfers @@ -23,6 +25,7 @@ export function createTerminalLayoutActions( | 'setTabPaneExpanded' | 'setTabCanExpandPane' | 'setTabLayout' + | 'acknowledgeDirectSshLayoutEdits' | 'setTabLocalOnlyScrollback' | 'syncPaneDetachPtyOwnership' > { @@ -75,6 +78,19 @@ export function createTerminalLayoutActions( : { canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } } ) }, + acknowledgeDirectSshLayoutEdits: (uploaded) => { + set((s) => { + const acknowledged = Object.keys(uploaded).filter((tabId) => { + const pending = s.pendingDirectSshLayoutEditsByTabId[tabId] + const candidate = uploaded[tabId] + return pending === candidate + }) + const pending = omitRecordKeys(s.pendingDirectSshLayoutEditsByTabId, acknowledged) + return pending === s.pendingDirectSshLayoutEditsByTabId + ? s + : { pendingDirectSshLayoutEditsByTabId: pending } + }) + }, setTabLayout: (tabId, layout) => { let ownershipTransfers: ReturnType = [] set((s) => { @@ -84,7 +100,13 @@ export function createTerminalLayoutActions( } const next = { ...s.terminalLayoutsByTabId } delete next[tabId] - return { terminalLayoutsByTabId: next } + return { + terminalLayoutsByTabId: next, + pendingDirectSshLayoutEditsByTabId: omitRecordKeys( + s.pendingDirectSshLayoutEditsByTabId, + [tabId] + ) + } } const normalized = normalizeTerminalLayoutPtyOwnership(layout) // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched. @@ -99,8 +121,23 @@ export function createTerminalLayoutActions( if (existing && terminalLayoutEqual(existing, normalized.snapshot)) { return s } + const structuralEdit = !terminalLayoutNodeEqual(existing?.root, normalized.snapshot.root) + const workspaceId = structuralEdit + ? Object.keys(s.tabsByWorktree).find((id) => + s.tabsByWorktree[id].some((tab) => tab.id === tabId) + ) + : undefined + const tracksRemoteEdit = workspaceId && getConnectionIdFromState(s, workspaceId) return { - terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot } + terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot }, + ...(tracksRemoteEdit + ? { + pendingDirectSshLayoutEditsByTabId: { + ...s.pendingDirectSshLayoutEditsByTabId, + [tabId]: { targetId: tracksRemoteEdit, root: normalized.snapshot.root } + } + } + : {}) } }) transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers) diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 5a4b097f057..d0595b8721f 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -23,6 +23,11 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' +export type DirectSshLayoutEdit = { + targetId: string + root: TerminalLayoutSnapshot['root'] +} + export type TerminalState = { tabsByWorktree: Record activeTabId: string | null @@ -44,6 +49,8 @@ export type TerminalState = { expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record + /** Local structural edits remain protected until the owning SSH host acknowledges them. */ + pendingDirectSshLayoutEditsByTabId: Record /** Ordinary-park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see * WorkspaceSessionState.localOnlyScrollbackByTabId. Read via resolveLeafScrollbackBuffers only. */ localOnlyScrollbackByTabId: Record> diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index b4ff87f4f10..c84263e7b7f 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -220,6 +220,7 @@ export function createTerminalTabCloseActions( expandedPaneByTabId: nextExpanded, canExpandPaneByTabId: nextCanExpand, terminalLayoutsByTabId: nextLayouts, + pendingDirectSshLayoutEditsByTabId: omitByTabId(s.pendingDirectSshLayoutEditsByTabId), localOnlyScrollbackByTabId: nextLocalOnlyScrollback, pendingStartupByTabId: nextPendingStartupByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index e2713203a41..6f2e3d47e75 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -174,6 +174,11 @@ export function targetScopedWorkspaceHydrationPatch( hydrated.activeTabIdByWorktree, workspaceKeys ), + pendingDirectSshLayoutEditsByTabId: Object.fromEntries( + Object.entries(state.pendingDirectSshLayoutEditsByTabId ?? {}).filter( + ([tabId]) => !deletedTargetTabIds.has(tabId) + ) + ), tabsByWorktree: replaceHydratedRecordKeys( state.tabsByWorktree, hydrated.tabsByWorktree, From c5f33bd13906135911a6f574984479a503b462af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:08:24 -0700 Subject: [PATCH 021/387] fix(ipynb): run no workspace interpreter until the notebook is trusted (#22962) --- src/main/ipc/notebook.ts | 6 +- src/main/notebook/python-environments.test.ts | 93 ++++++++++++++++++- src/main/notebook/python-environments.ts | 74 +++++++++++++-- src/preload/api/filesystem-api.ts | 2 + .../components/editor/IpynbCellToolbar.tsx | 36 ++++--- .../editor/IpynbKernelToolbar.test.tsx | 81 +++++++++++++++- .../components/editor/IpynbKernelToolbar.tsx | 42 ++++++--- .../editor/ipynb-kernel-session.test.ts | 41 ++++++++ .../components/editor/ipynb-kernel-session.ts | 12 ++- .../components/editor/ipynb-kernel-store.ts | 14 ++- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/notebook-kernel-types.ts | 3 +- 17 files changed, 365 insertions(+), 45 deletions(-) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index cb7f4e564b5..70f8224011b 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -49,11 +49,13 @@ export function registerNotebookHandlers(store: Store): void { 'notebook:listPythonEnvironments', async ( _event, - args: { filePath: string; rootPath: string | null } + args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean } ): Promise => { await resolveAuthorizedPath(args.filePath, store) // Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp. - return listPythonEnvironments(args.filePath, args.rootPath) + return listPythonEnvironments(args.filePath, args.rootPath, { + runWorkspaceInterpreters: args.runWorkspaceInterpreters === true + }) } ) diff --git a/src/main/notebook/python-environments.test.ts b/src/main/notebook/python-environments.test.ts index 05088d8236b..eb69d981493 100644 --- a/src/main/notebook/python-environments.test.ts +++ b/src/main/notebook/python-environments.test.ts @@ -9,7 +9,8 @@ vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProces import { createNotebookVenv, findWorkspaceInterpreters, - installIpykernel + installIpykernel, + listPythonEnvironments } from './python-environments' function existing(...paths: string[]): (path: string) => boolean { @@ -47,6 +48,96 @@ describe('findWorkspaceInterpreters', () => { }) }) +describe('listPythonEnvironments', () => { + let root = '' + const windows = process.platform === 'win32' + const venvPython = (): string => + join(root, '.venv', ...(windows ? ['Scripts', 'python.exe'] : ['bin', 'python'])) + const condaPython = (): string => + join(root, 'nb', '.conda', ...(windows ? ['python.exe'] : ['bin', 'python'])) + const programs = (): string[] => runProcessMock.mock.calls.map(([spec]) => spec.program) + + beforeEach(() => { + runProcessMock.mockReset() + runProcessMock.mockImplementation(async ({ program }: { program: string }) => ({ + code: 0, + stdout: `${program}\n3.13.0\n`, + stderr: '' + })) + root = mkdtempSync(join(tmpdir(), 'orca-pyenvs-')) + for (const interpreter of [venvPython(), condaPython()]) { + mkdirSync(dirname(interpreter), { recursive: true }) + writeFileSync(interpreter, '') + } + writeFileSync( + join(root, '.venv', 'pyvenv.cfg'), + 'home = /usr/bin\nversion_info = 3.12.1.final.0\n' + ) + const condaMeta = join(root, 'nb', '.conda', 'conda-meta') + mkdirSync(condaMeta) + writeFileSync(join(condaMeta, 'python-dateutil-2.9.0-pyhd8ed1ab_0.json'), '{}') + writeFileSync(join(condaMeta, 'python-3.11.9-h8d4a6d2_0.json'), '{}') + return () => rmSync(root, { recursive: true, force: true }) + }) + + it('lists workspace envs from disk without running them before trust', async () => { + const found = await listPythonEnvironments(join(root, 'nb', 'a.ipynb'), root, { + runWorkspaceInterpreters: false + }) + expect(programs()).not.toContain(venvPython()) + expect(programs()).not.toContain(condaPython()) + expect(programs().length).toBeGreaterThan(0) + expect(found.workspace).toEqual([ + { path: condaPython(), name: '.conda', version: '3.11.9' }, + { path: venvPython(), name: '.venv', version: '3.12.1' } + ]) + }) + + it('reads the stdlib venv version key and omits a version it cannot find', async () => { + writeFileSync(join(root, '.venv', 'pyvenv.cfg'), 'home = /usr/bin\nversion = 3.10.4\n') + const found = await listPythonEnvironments(join(root, 'a.ipynb'), root, { + runWorkspaceInterpreters: false + }) + expect(found.workspace).toEqual([{ path: venvPython(), name: '.venv', version: '3.10.4' }]) + + writeFileSync(join(root, '.venv', 'pyvenv.cfg'), 'home = /usr/bin\n') + const unversioned = await listPythonEnvironments(join(root, 'a.ipynb'), root, { + runWorkspaceInterpreters: false + }) + expect(unversioned.workspace).toEqual([{ path: venvPython(), name: '.venv' }]) + }) + + it('reads only the head of pyvenv.cfg, whatever size it reports', async () => { + // procfs files report size 0 yet never end, so the read must be bounded, not size-gated. + const padding = `# ${'x'.repeat(1022)}\n`.repeat(128) + const cfg = join(root, '.venv', 'pyvenv.cfg') + const list = () => + listPythonEnvironments(join(root, 'a.ipynb'), root, { runWorkspaceInterpreters: false }) + + writeFileSync(cfg, `version_info = 3.12.1.final.0\n${padding}`) + expect((await list()).workspace).toEqual([ + { path: venvPython(), name: '.venv', version: '3.12.1' } + ]) + + writeFileSync(cfg, `${padding}version_info = 3.12.1.final.0\n`) + expect((await list()).workspace).toEqual([{ path: venvPython(), name: '.venv' }]) + }) + + it('probes workspace envs once the notebook is trusted, dropping ones that fail', async () => { + runProcessMock.mockImplementation(async ({ program }: { program: string }) => + program === condaPython() + ? { code: 1, stdout: '', stderr: 'broken' } + : { code: 0, stdout: `${program}\n3.13.0\n`, stderr: '' } + ) + const found = await listPythonEnvironments(join(root, 'nb', 'a.ipynb'), root, { + runWorkspaceInterpreters: true + }) + expect(programs()).toContain(venvPython()) + expect(programs()).toContain(condaPython()) + expect(found.workspace).toEqual([{ path: venvPython(), name: '.venv', version: '3.13.0' }]) + }) +}) + describe('installIpykernel', () => { beforeEach(() => runProcessMock.mockReset()) const result = (code: number | null, stderr = '', extra = {}) => ({ diff --git a/src/main/notebook/python-environments.ts b/src/main/notebook/python-environments.ts index 4270f9ca62d..116e20e9e1b 100644 --- a/src/main/notebook/python-environments.ts +++ b/src/main/notebook/python-environments.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import type { ProcessResult } from '../../shared/child-process/process-spec' @@ -15,6 +15,7 @@ const WORKSPACE_ENV_DIRS = ['.venv', '.conda'] const INSTALL_TIMEOUT_MS = 10 * 60_000 const INSTALL_DETAIL_CHARS = 4000 const VENV_TIMEOUT_MS = 2 * 60_000 +const PYVENV_CFG_MAX_BYTES = 64 * 1024 /** `.venv`/`.conda` interpreters from the notebook's folder up to the workspace root, nearest first. */ export function findWorkspaceInterpreters( @@ -62,27 +63,86 @@ async function probe( } } -/** Names an interpreter after its environment folder when it lives in one. */ -function environmentName(executable: string): string { +/** The environment folder an interpreter lives in, when it lives in one. */ +function environmentDir(executable: string): string | undefined { // venvs keep python in bin/ or Scripts\; Windows conda envs keep it at the env root. - const envDir = [dirname(dirname(executable)), dirname(executable)].find( + return [dirname(dirname(executable)), dirname(executable)].find( (dir) => existsSync(join(dir, 'pyvenv.cfg')) || existsSync(join(dir, 'conda-meta')) ) - return basename(envDir ?? executable) +} + +/** Names an interpreter after its environment folder when it lives in one. */ +function environmentName(executable: string): string { + return basename(environmentDir(executable) ?? executable) +} + +/** The head of a regular file, read once into a fixed buffer. */ +function readFileHead(path: string, maxBytes: number): string | undefined { + // Why not a FIFO or device: opening one can block; a symlinked procfs file passes and reports size 0. + if (!existsSync(path) || !statSync(path).isFile()) { + return undefined + } + const fd = openSync(path, 'r') + try { + const buffer = Buffer.alloc(maxBytes) + return buffer.toString('utf8', 0, readSync(fd, buffer, 0, maxBytes, 0)) + } finally { + closeSync(fd) + } +} + +/** The Python version an environment records on disk: venv `pyvenv.cfg`, else conda's `conda-meta`. */ +function recordedVersion(envDir: string): string | undefined { + try { + // Why bounded, not size-checked: a hostile repo can point pyvenv.cfg at an endless file. + const cfg = readFileHead(join(envDir, 'pyvenv.cfg'), PYVENV_CFG_MAX_BYTES) + const match = cfg && /^\s*version(?:_info)?\s*=\s*(\d+\.\d+(?:\.\d+)?)/m.exec(cfg) + if (match) { + return match[1] + } + const condaMeta = join(envDir, 'conda-meta') + if (existsSync(condaMeta)) { + for (const entry of readdirSync(condaMeta)) { + const match = /^python-(\d+\.\d+(?:\.\d+)?)-.*\.json$/.exec(entry) + if (match) { + return match[1] + } + } + } + } catch { + // Unreadable metadata just leaves the version unknown. + } + return undefined +} + +/** Describes a workspace interpreter from its files alone, without running it. */ +function describeWithoutRunning(interpreter: string): PythonEnvironment { + const envDir = environmentDir(interpreter) + const version = envDir === undefined ? undefined : recordedVersion(envDir) + const name = basename(envDir ?? interpreter) + return version ? { path: interpreter, name, version } : { path: interpreter, name } } export function describePython(path: string): Promise { return probe(path, [], environmentName) } +/** + * Pythons a notebook can use. `runWorkspaceInterpreters` is false until the notebook is trusted: + * a repo can ship its own `.venv/bin/python`, so those are then read from disk, never run. + */ export async function listPythonEnvironments( notebookPath: string, - rootPath: string | null + rootPath: string | null, + { runWorkspaceInterpreters }: { runWorkspaceInterpreters: boolean } ): Promise { const pathCommands = process.platform === 'win32' ? [['py', '-3'], ['python']] : [['python3'], ['python']] + const workspaceInterpreters = findWorkspaceInterpreters(notebookPath, rootPath) const [workspace, onPath] = await Promise.all([ - Promise.all(findWorkspaceInterpreters(notebookPath, rootPath).map(describePython)), + runWorkspaceInterpreters + ? Promise.all(workspaceInterpreters.map(describePython)) + : workspaceInterpreters.map(describeWithoutRunning), Promise.all( pathCommands.map(([program, ...args]) => probe(program, args, () => [program, ...args].join(' ')) diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 2a8f0612905..d8b74cd9304 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -170,6 +170,8 @@ export type FilesystemApi = { listPythonEnvironments: (args: { filePath: string rootPath: string | null + /** False until the notebook is trusted: workspace envs are then listed without running them. */ + runWorkspaceInterpreters: boolean }) => Promise describePython: (args: { path: string }) => Promise startKernel: (args: { filePath: string; python: string }) => Promise diff --git a/src/renderer/src/components/editor/IpynbCellToolbar.tsx b/src/renderer/src/components/editor/IpynbCellToolbar.tsx index 04adc084904..2ca3f42b0b6 100644 --- a/src/renderer/src/components/editor/IpynbCellToolbar.tsx +++ b/src/renderer/src/components/editor/IpynbCellToolbar.tsx @@ -41,33 +41,47 @@ function cellKindLabel(kind: IpynbCellKind): string { export function IpynbToolbarButton({ label, disabled = false, + disabledReason, shortcut, onClick, children }: { label: string disabled?: boolean + /** Shown instead of the label while disabled, so the user learns why. */ + disabledReason?: string shortcut?: ShortcutKeyComboDetails onClick: () => void children: ReactNode }): React.JSX.Element { + const reason = disabled ? disabledReason : undefined + const button = ( + + ) return ( - + {/* Why the span: a disabled button gets no pointer or focus events, so its tooltip never opens. */} + {reason ? ( + + {button} + + ) : ( + button + )} - {label} + {reason ?? label} {shortcut && shortcut.keys.length > 0 ? ( ) : null} diff --git a/src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx b/src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx index 71348f4839a..cd68eec18bc 100644 --- a/src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx +++ b/src/renderer/src/components/editor/IpynbKernelToolbar.test.tsx @@ -1,25 +1,100 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -vi.hoisted(() => { +const { listPythonEnvironments } = vi.hoisted(() => { + const listPythonEnvironments = vi.fn() // The kernel session subscribes to kernel frames when it loads. Object.defineProperty(window, 'api', { configurable: true, - value: { notebook: { onKernelFrame: () => () => {} } } + value: { notebook: { onKernelFrame: () => () => {}, listPythonEnvironments } } }) + return { listPythonEnvironments } }) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('@/store', () => ({ useAppStore: { subscribe: () => () => {} } })) import { IpynbKernelToolbar } from './IpynbKernelToolbar' +import { trustNotebook } from './ipynb-kernel-session' import { getSession, setEnvironment, updateSession } from './ipynb-kernel-store' const FILE = '/repo/nb.ipynb' +const CREATE_VENV = /^Create virtual environment…/ +const TRUST_FIRST = 'Run a cell to trust this notebook first' afterEach(cleanup) +function renderToolbar(filePath: string) { + return render( + + + + ) +} + +function togglePicker(label: string): void { + fireEvent.pointerDown(screen.getByRole('button', { name: label }), { + button: 0, + ctrlKey: false, + pointerType: 'mouse' + }) +} + +describe('kernel picker trust', () => { + it('lists workspace envs without running them until the notebook is trusted', async () => { + const file = '/repo/untrusted.ipynb' + const venv = { path: '/repo/.venv/bin/python', name: '.venv' } + listPythonEnvironments.mockResolvedValue({ + workspace: [venv], + path: [{ path: '/usr/bin/python3', name: 'python3', version: '3.12.1' }] + }) + renderToolbar(file) + + togglePicker('Select Kernel') + await screen.findByText('/repo/.venv/bin/python') + expect(listPythonEnvironments).toHaveBeenLastCalledWith({ + filePath: file, + rootPath: '/repo', + runWorkspaceInterpreters: false + }) + // A version read from nowhere shows the bare env name. + expect(screen.getByText('.venv')).toBeTruthy() + // Creating a .venv would reuse, and so run, one the repo shipped; the item says why it is off. + expect(screen.getByRole('menuitem', { name: CREATE_VENV }).dataset.disabled).toBe('') + expect(screen.getByText(TRUST_FIRST)).toBeTruthy() + + act(() => trustNotebook(file)) + await waitFor(() => + expect(listPythonEnvironments).toHaveBeenLastCalledWith({ + filePath: file, + rootPath: '/repo', + runWorkspaceInterpreters: true + }) + ) + expect(screen.getByRole('menuitem', { name: CREATE_VENV }).dataset.disabled).toBeUndefined() + expect(screen.queryByText(TRUST_FIRST)).toBeNull() + }) + + it('keeps Restart kernel disabled, and says why, until the notebook is trusted', () => { + const file = '/repo/saved-env.ipynb' + setEnvironment(file, { path: '/repo/.venv/bin/python', name: '.venv', version: '3.12.1' }) + renderToolbar(file) + expect(screen.getByRole('button', { name: 'Restart kernel' })).toHaveProperty('disabled', true) + // The focusable wrapper is what lets the disabled button's tooltip open. + expect(screen.getByLabelText(TRUST_FIRST).getAttribute('tabindex')).toBe('0') + + act(() => trustNotebook(file)) + expect(screen.getByRole('button', { name: 'Restart kernel' })).toHaveProperty('disabled', false) + expect(screen.queryByLabelText(TRUST_FIRST)).toBeNull() + }) +}) + describe('ipykernel setup dialog', () => { it('Cancel drops the waiting cells, as Esc does', () => { const venv = { path: '/repo/.venv/bin/python', name: '.venv', version: '3.12.1' } diff --git a/src/renderer/src/components/editor/IpynbKernelToolbar.tsx b/src/renderer/src/components/editor/IpynbKernelToolbar.tsx index c15b4f69c13..00092c3719c 100644 --- a/src/renderer/src/components/editor/IpynbKernelToolbar.tsx +++ b/src/renderer/src/components/editor/IpynbKernelToolbar.tsx @@ -40,7 +40,7 @@ import { useNotebookKernelState } from './ipynb-kernel-store' type KernelState = ReturnType function environmentLabel({ name, version }: PythonEnvironment): string { - return `${name} (Python ${version})` + return version ? `${name} (Python ${version})` : name } function kernelLabel({ environment, status, setup }: KernelState): string { @@ -109,21 +109,27 @@ export function IpynbKernelToolbar({ kernel.status === 'starting' || (kernel.setup !== null && kernel.setup.phase !== 'idle') // A PATH interpreter, not the selected one: that may be the very .venv being (re)created. const venvBase = environments?.path[0] + const untrustedHint = translate( + 'auto.components.editor.IpynbViewer.trustFirst', + 'Run a cell to trust this notebook first' + ) useEffect(() => { if (!pickerOpen) { return } let current = true - void window.api.notebook.listPythonEnvironments({ filePath, rootPath }).then((found) => { - if (current) { - setEnvironments(found) - } - }) + void window.api.notebook + .listPythonEnvironments({ filePath, rootPath, runWorkspaceInterpreters: kernel.trusted }) + .then((found) => { + if (current) { + setEnvironments(found) + } + }) return () => { current = false } - }, [filePath, pickerOpen, rootPath]) + }, [filePath, kernel.trusted, pickerOpen, rootPath]) const choose = (path: string): void => { const environment = [...(environments?.workspace ?? []), ...(environments?.path ?? [])].find( @@ -146,7 +152,9 @@ export function IpynbKernelToolbar({ ) : null} restartKernel(filePath)} > @@ -220,8 +228,9 @@ export function IpynbKernelToolbar({ {translate('auto.components.editor.IpynbViewer.browsePython', 'Browse for Python…')} + {/* Why trusted: it reuses an existing .venv, running a Python the repo may have shipped. */} { if (venvBase) { offerVirtualEnvironment(filePath, venvBase) @@ -229,10 +238,17 @@ export function IpynbKernelToolbar({ }} > - {translate( - 'auto.components.editor.IpynbViewer.createVenvItem', - 'Create virtual environment…' - )} + + + {translate( + 'auto.components.editor.IpynbViewer.createVenvItem', + 'Create virtual environment…' + )} + + {venvBase && !kernel.trusted ? ( + {untrustedHint} + ) : null} + diff --git a/src/renderer/src/components/editor/ipynb-kernel-session.test.ts b/src/renderer/src/components/editor/ipynb-kernel-session.test.ts index 1dffc0ab6a7..e609bae9e2c 100644 --- a/src/renderer/src/components/editor/ipynb-kernel-session.test.ts +++ b/src/renderer/src/components/editor/ipynb-kernel-session.test.ts @@ -55,9 +55,50 @@ beforeEach(() => { listener({ openFiles: openFiles.current }, { openFiles: [] }) } openFiles.current = [{ filePath: FILE }] + // Cells only reach runCells after the user trusts the notebook. + for (const filePath of [ + FILE, + '/repo/nb.ipynb', + '/other.ipynb', + '/third.ipynb', + '/fourth.ipynb' + ]) { + session.trustNotebook(filePath) + } }) describe('notebook kernel session', () => { + it('runs no interpreter before trust: a pick or restart only records the choice', async () => { + const untrusted = '/untrusted/nb.ipynb' + const shipped = { path: '/untrusted/.venv/bin/python', name: '.venv' } + openFiles.current = [{ filePath: untrusted }] + session.selectEnvironment(untrusted, shipped) + session.restartKernel(untrusted) + await Promise.resolve() + expect(notebookApi.startKernel).not.toHaveBeenCalled() + expect(notebookApi.listPythonEnvironments).not.toHaveBeenCalled() + expect(getSession(untrusted)).toMatchObject({ status: 'off', trusted: false, queue: [] }) + + session.trustNotebook(untrusted) + await session.runCells(untrusted, [{ key: 'a', code: 'x' }], '/untrusted') + expect(notebookApi.startKernel).toHaveBeenCalledWith({ + filePath: untrusted, + python: shipped.path + }) + expect(notebookApi.execute).toHaveBeenCalledWith({ filePath: untrusted, code: 'x' }) + }) + + it('discovers with workspace interpreters once trusted', async () => { + openFiles.current = [{ filePath: '/fifth.ipynb' }] + session.trustNotebook('/fifth.ipynb') + await session.runCells('/fifth.ipynb', [{ key: 'a', code: 'x' }], '/proj') + expect(notebookApi.listPythonEnvironments).toHaveBeenCalledWith({ + filePath: '/fifth.ipynb', + rootPath: '/proj', + runWorkspaceInterpreters: true + }) + }) + it('starts the recommended env, runs queued cells in order, and stops the queue on an error', async () => { await session.runCells( FILE, diff --git a/src/renderer/src/components/editor/ipynb-kernel-session.ts b/src/renderer/src/components/editor/ipynb-kernel-session.ts index de0c15bfe77..a75f406138a 100644 --- a/src/renderer/src/components/editor/ipynb-kernel-session.ts +++ b/src/renderer/src/components/editor/ipynb-kernel-session.ts @@ -64,7 +64,11 @@ async function discoverEnvironment( filePath: string, rootPath: string | null ): Promise { - const found = await window.api.notebook.listPythonEnvironments({ filePath, rootPath }) + const found = await window.api.notebook.listPythonEnvironments({ + filePath, + rootPath, + runWorkspaceInterpreters: true + }) const recommended = found.workspace[0] ?? found.path[0] if (recommended && isOpen(filePath)) { setEnvironment(filePath, recommended) @@ -72,7 +76,11 @@ async function discoverEnvironment( return recommended } +/** Starts the notebook's kernel; the one place a notebook's Python runs, so it refuses until trusted. */ async function start(filePath: string, rootPath: string | null = null): Promise { + if (!getSession(filePath).trusted) { + return + } // Why 'starting' before discovery: a second run meanwhile must queue, not start another kernel. updateSession(filePath, () => ({ status: 'starting' })) let result: KernelStartResult | null @@ -156,7 +164,7 @@ export function restartKernel(filePath: string): void { void start(filePath) } -/** Switching interpreters restarts a running kernel; otherwise queued cells wait for the new one. */ +/** Switching interpreters restarts a running kernel; before trust it only records the choice. */ export function selectEnvironment(filePath: string, environment: PythonEnvironment): void { setEnvironment(filePath, environment) // A pick replaces an open setup prompt; the new env reopens it if it lacks ipykernel too. diff --git a/src/renderer/src/components/editor/ipynb-kernel-store.ts b/src/renderer/src/components/editor/ipynb-kernel-store.ts index abdf8cab9b3..057e8f552f5 100644 --- a/src/renderer/src/components/editor/ipynb-kernel-store.ts +++ b/src/renderer/src/components/editor/ipynb-kernel-store.ts @@ -54,11 +54,15 @@ function loadEnvironments(): Record { return isRecord(stored) ? Object.fromEntries( Object.entries(stored).flatMap(([filePath, env]) => - isRecord(env) && - typeof env.path === 'string' && - typeof env.name === 'string' && - typeof env.version === 'string' - ? [[filePath, { path: env.path, name: env.name, version: env.version }]] + isRecord(env) && typeof env.path === 'string' && typeof env.name === 'string' + ? [ + [ + filePath, + typeof env.version === 'string' + ? { path: env.path, name: env.name, version: env.version } + : { path: env.path, name: env.name } + ] + ] : [] ) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91a5bdbb9e5..5c4817bab0a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15155,6 +15155,7 @@ "tryAgain": "Try again", "kernelCreatingVenv": "Creating .venv…", "createVenvItem": "Create virtual environment…", + "trustFirst": "Run a cell to trust this notebook first", "kernelStartFailed": "The kernel failed to start.", "noPython": "Python was not found on this computer. Install it from [python.org](https://www.python.org/downloads/), then run the cell again.", "kernelDied": "The kernel died.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 722d704acee..56c99187d65 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13089,6 +13089,7 @@ "notPython": "Ese archivo no es un intérprete de Python.", "interrupt": "Interrumpir", "restart": "Reiniciar kernel", + "trustFirst": "Ejecuta una celda para confiar primero en este notebook", "runAll": "Ejecutar todo", "clearAll": "Borrar todas las salidas", "notResponding": "Sin respuesta. Reinicia el kernel", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index add1dd437d3..de7876b99e0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -15020,6 +15020,7 @@ "notPython": "Ce fichier n'est pas un interpréteur Python.", "interrupt": "Interrompre", "restart": "Redémarrer le kernel", + "trustFirst": "Exécutez une cellule pour d'abord faire confiance à ce notebook", "runAll": "Tout exécuter", "clearAll": "Effacer toutes les sorties", "notResponding": "Ne répond pas. Redémarrez le kernel", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7ffa3e0e2eb..732aa65d48d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14917,6 +14917,7 @@ "notPython": "そのファイルは Python インタープリターではありません。", "interrupt": "中断", "restart": "カーネルを再起動", + "trustFirst": "先にセルを実行して、このノートブックを信頼してください", "runAll": "すべて実行", "clearAll": "すべての出力をクリア", "notResponding": "応答がありません。カーネルを再起動してください", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b44e62130a5..cb7c6881577 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14917,6 +14917,7 @@ "notPython": "해당 파일은 Python 인터프리터가 아닙니다.", "interrupt": "중단", "restart": "커널 다시 시작", + "trustFirst": "먼저 셀을 실행하여 이 노트북을 신뢰하세요", "runAll": "모두 실행", "clearAll": "모든 출력 지우기", "notResponding": "응답 없음. 커널을 다시 시작하세요", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5e7a0c9ec0f..b9dc67facaa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14917,6 +14917,7 @@ "notPython": "该文件不是 Python 解释器。", "interrupt": "中断", "restart": "重启内核", + "trustFirst": "请先运行一个单元格以信任此笔记本", "runAll": "全部运行", "clearAll": "清除所有输出", "notResponding": "无响应。请重启内核", diff --git a/src/shared/notebook-kernel-types.ts b/src/shared/notebook-kernel-types.ts index 02947ead5e7..022a07eaafd 100644 --- a/src/shared/notebook-kernel-types.ts +++ b/src/shared/notebook-kernel-types.ts @@ -4,7 +4,8 @@ export type PythonEnvironment = { path: string /** `.venv`, `python3`, … — what the kernel picker shows first. */ name: string - version: string + /** Absent for a workspace env listed before trust, which is read from disk rather than run. */ + version?: string } export type PythonEnvironments = { From 02a55944347346065453e179e071e64d9ae861dc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:15:12 -0400 Subject: [PATCH 022/387] fix(mobile): give each host field one writer so relay routing can't revert edits (#22956) * fix(mobile): give each host field one writer so relay routing can't revert edits Relay learners (director re-resolution, credential rotation, direct-to-relay upgrade) saved the whole HostProfile snapshot their connection opened with, so a late relay write reverted an Edit Host endpoint and rewrote the device token. The relay overlay also stored a copy of the paired address, which the direct probe kept dialling after an edit. Pairing is now the only full-profile writer (savePairedHost, fenced by a census). Learners call setRelayRouting(hostId, relay), which never touches the row or keychain, refuses a removed host, and skips unchanged routing. The overlay is relay-only in memory; one serializer writes the v2 shape older builds parse, without the direct entry. Saving a new endpoint rebuilds even a Relay-active client from the saved row. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil * test(mobile): re-record RPC goldens for relay-only host routing Baseline moves to the product commit. adapterSha256 moves for the pairing and relay-credential adapters, which now read relay.relayHostId and inject saveRelayRouting. Only the four direct-upgrade bodies change: the settled host no longer carries the overlay's endpoints copy (direct-primary, relay-primary) or the duplicate relayHostId; relay and every effect are unchanged. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil * refactor(mobile): relay learners own only relay routing Follow-up to the field-ownership split. The supervisor keeps its host read-only and holds the relay in a small owner that persists moves through setRelayRouting; the direct upgrade returns { relay, bundle } and the lifecycle composes the profile once. With one direct endpoint left, the probe takes a bound openDirect and the lifecycle computes the direct path once. One name per writer: setRelayRouting and savePairedHost are also the dependency keys, and the removed-host error is RelayRoutingHostRemovedError. The census now counts real value imports of savePairedHost, not mentions. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil * test(mobile): re-record RPC goldens for the { relay, bundle } upgrade result Baseline moves to the refactor commit, and adapterSha256 moves for the pairing and relay-credential adapters (dependency keys renamed to savePairedHost and setRelayRouting). Only the four direct-upgrade bodies change: the settled upgrade result is now { relay, bundle } instead of { host, bundle }, with identical relay, bundle, state and effects. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil * refactor(mobile): refresh edited hosts and let the establisher own relay Edit Host now reuses refreshHostClient, which already rebuilds an owned client (relay-active included) from the saved row after re-pairing, instead of a savedAddressChanged flag on forceReconnect. The session establisher, the only relay dialer, holds the relay and adopts learned routing through setRelayRouting; the supervisor takes a host id and a required relay, so the no-relay guards and the synthetic upgraded profile go. enqueueHostListMutation returns its operation's value, and the overlay store names its API after routing. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil --------- Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil --- mobile/app/h/[hostId]/edit.tsx | 10 +- .../aivault-history-scan-fulfilled.json | 2 +- .../aivault-history-scan-unsupported.json | 2 +- .../aivault-history-scan-worktrees-late.json | 2 +- .../aivault-history-screen-listed.json | 2 +- .../aivault-history-screen-worktrees.json | 2 +- .../aivault-resume-launch-create-refused.json | 2 +- .../aivault-resume-launch-invalid-tab.json | 2 +- .../goldens/aivault-resume-launch-locked.json | 2 +- .../goldens/aivault-resume-launch-sent.json | 2 +- .../aivault-resume-prepare-refused.json | 2 +- .../goldens/aivault-resume-prepare-repin.json | 2 +- .../aivault-resume-prepare-skipped.json | 2 +- .../aivault-resume-prepare-unavailable.json | 2 +- mobile/rpc-foundation/goldens/b1.json | 2 +- mobile/rpc-foundation/goldens/b2.json | 2 +- mobile/rpc-foundation/goldens/b3.json | 2 +- .../goldens/browser-dialog-accepted.json | 2 +- .../goldens/browser-dialog-dismissed.json | 2 +- .../goldens/browser-keyboard-input.json | 2 +- .../browser-pointer-click-accepted.json | 2 +- .../browser-pointer-click-fallback.json | 2 +- .../goldens/browser-wheel-scrolled.json | 2 +- .../clipboard-image-attachment-anonymous.json | 2 +- ...-image-attachment-blocked-before-send.json | 2 +- .../clipboard-image-attachment-cancelled.json | 2 +- .../clipboard-image-attachment-pasted.json | 2 +- ...board-image-attachment-upload-refused.json | 2 +- ...-image-upload-aborts-on-chunk-failure.json | 2 +- .../clipboard-image-upload-chunked.json | 2 +- ...rd-image-upload-single-frame-fallback.json | 2 +- .../clipboard-image-upload-start-refused.json | 2 +- .../goldens/codex-reset-credit-consumed.json | 2 +- .../goldens/codex-reset-credit-resumed.json | 2 +- .../goldens/components-codex-capability.json | 2 +- .../goldens/components-setup-ask.json | 2 +- .../goldens/components-target-local.json | 2 +- .../goldens/components-target-ssh.json | 2 +- .../goldens/diff-review-branch-compare.json | 2 +- .../goldens/diff-review-branch-file-diff.json | 2 +- ...f-review-notes-refused-before-compare.json | 2 +- .../diff-review-refused-file-diff.json | 2 +- .../goldens/diff-review-snapshot.json | 2 +- .../diff-review-status-unavailable.json | 2 +- .../diff-review-worktree-file-diff.json | 2 +- .../goldens/file-tap-open-refused.json | 2 +- .../goldens/file-tap-opens-worktree-file.json | 2 +- .../file-tap-previews-absolute-artifact.json | 2 +- .../goldens/file-tap-resolve-miss.json | 2 +- .../goldens/file-tap-resolve-refused.json | 2 +- .../files-explorer-legacy-fallback.json | 2 +- .../goldens/files-explorer-readdir.json | 2 +- .../goldens/files-ownership-local.json | 2 +- .../goldens/files-ownership-ssh.json | 2 +- .../files-preview-artifact-direct.json | 2 +- .../files-preview-artifact-image-read.json | 2 +- .../goldens/files-preview-artifact-image.json | 2 +- .../goldens/files-preview-grant-refresh.json | 2 +- .../files-preview-worktree-image-read.json | 2 +- .../goldens/files-preview-worktree-image.json | 2 +- .../files-preview-worktree-text-read.json | 2 +- .../goldens/files-preview-worktree.json | 2 +- .../goldens/files-save-blind.json | 2 +- .../goldens/files-save-verified.json | 2 +- .../goldens/files-tab-doc-shapes.json | 2 +- .../goldens/home-host-accounts.json | 2 +- .../goldens/home-host-stats.json | 2 +- .../goldens/host-view-settings-sync.json | 2 +- ...host-worktree-actions-pin-open-delete.json | 2 +- .../goldens/host-worktree-delete-refused.json | 2 +- .../goldens/host-worktree-refresh-stream.json | 2 +- .../interruptions-inventory-lifecycle.json | 2 +- ...ions-settings-bot-overrides-fulfilled.json | 2 +- .../goldens/inventory-lifecycle.json | 2 +- .../goldens/inventory-repeat-query.json | 2 +- .../rpc-foundation/goldens/lifecycle-b3.json | 2 +- .../lifecycle-inventory-lifecycle.json | 2 +- ...ycle-settings-bot-overrides-fulfilled.json | 2 +- ...cle-settings-task-hydration-fulfilled.json | 2 +- ...-settings-workspace-context-fulfilled.json | 2 +- .../goldens/linear-select-workspace.json | 2 +- .../goldens/live-worktree-name-stream.json | 2 +- ...ructured-create-agentsession.create-1.json | 2 +- ...d-create-agentsession.createsupport-1.json | 2 +- ...d-launch-agentsession.createsupport-1.json | 2 +- ...ivault.history-aivault.listsessions-1.json | 2 +- ...ivault.history-screen-platform-status.json | 2 +- ...x-aivault.history-screen-status.get-2.json | 2 +- ...-aivault.history-screen-worktree.ps-1.json | 2 +- .../matrix-aivault.history-status.get-1.json | 2 +- ...-launch-session.tabs.createterminal-1.json | 2 +- ...aivault.resume-launch-terminal.send-1.json | 2 +- ...ration-aivault.preparesessionresume-1.json | 2 +- ...browser.dialog-browser.dialogaccept-1.json | 2 +- ...keyboard-browser.keyboardinserttext-1.json | 2 +- ...x-browser.keyboard-browser.keypress-1.json | 2 +- ...er.pointer-click-browser.mouseclick-1.json | 2 +- ...ser.pointer-click-browser.mousedown-1.json | 2 +- ...ser.pointer-click-browser.mousemove-1.json | 2 +- ...owser.pointer-click-browser.mouseup-1.json | 2 +- ...rix-browser.wheel-browser.mousemove-1.json | 2 +- ...ix-browser.wheel-browser.mousewheel-1.json | 2 +- ...tachment-clipboard.startimageupload-1.json | 2 +- ...pload-clipboard.saveimageastempfile-1.json | 2 +- ...e-upload-clipboard.startimageupload-1.json | 2 +- ...s.codex-reset-capability-status.get-1.json | 2 +- ...it-accounts.consumecodexresetcredit-1.json | 2 +- ...target-local-preflight.detectagents-1.json | 2 +- ...target-preflight.detectremoteagents-1.json | 2 +- ...onents.execution-target-ssh.connect-1.json | 2 +- ...nents.execution-target-ssh.getstate-1.json | 2 +- ...ew-workspace-repositories-repo.list-1.json | 2 +- ...-components.setup-script-repo.hooks-1.json | 2 +- ...ix-files.explorer-screen-files.list-1.json | 2 +- ...files.explorer-screen-files.readdir-1.json | 2 +- ...les.mutation-ownership-ssh.getstate-1.json | 2 +- ...files.mutation-ownership-status.get-1.json | 2 +- ...es.mutation-ownership-worktree.show-1.json | 2 +- ...e-files.readterminalartifactpreview-1.json | 2 +- ...iew-load-files.readterminalartifact-1.json | 2 +- ...iew-load-files.readterminalartifact-2.json | 2 +- ...view-load-files.resolveterminalpath-1.json | 2 +- ...iew-save-files.readterminalartifact-1.json | 2 +- ...ew-save-files.writeterminalartifact-1.json | 2 +- ...ew-worktree-image-files.readpreview-1.json | 2 +- ...es.preview-worktree-text-files.read-1.json | 2 +- .../matrix-files.tab-doc-files.read-1.json | 2 +- ...rix-files.tab-doc-files.readpreview-1.json | 2 +- .../matrix-files.tab-doc-git.diff-1.json | 2 +- ...-files.terminal-path-tap-files.open-1.json | 2 +- ...-path-tap-files.resolveterminalpath-1.json | 2 +- ....base-ref-chain-repo.baserefdefault-1.json | 2 +- ...matrix-git.base-ref-chain-repo.list-1.json | 2 +- ...ix-git.base-ref-chain-worktree.show-1.json | 2 +- ....branch-diff-preview-git.branchdiff-1.json | 2 +- ...-git.changes-load-git.branchcompare-1.json | 2 +- .../matrix-git.changes-load-git.status-1.json | 2 +- .../matrix-git.changes-load-repo.list-1.json | 2 +- ...trix-git.changes-load-worktree.show-1.json | 2 +- ...essage-ai-git.generatecommitmessage-1.json | 2 +- ...tory-commit-files-git.commitcompare-1.json | 2 +- ...it.history-commit-files-git.history-1.json | 2 +- ...matrix-git.history-read-git.history-1.json | 2 +- ...ix-git.remote-prerequisite-git.push-1.json | 2 +- ...x-git.review-preparation-git.status-1.json | 2 +- ...ent-mutation-github.addissuecomment-1.json | 2 +- ...tion-github.addprreviewcommentreply-1.json | 2 +- ...ub.project.deleteissuecommentbyslug-1.json | 2 +- ...ub.project.updateissuecommentbyslug-1.json | 2 +- ...mutation-github.resolvereviewthread-1.json | 2 +- ...x-github.pr-mutation-github.mergepr-1.json | 2 +- ...r-mutation-github.removeprreviewers-1.json | 2 +- ...-mutation-github.requestprreviewers-1.json | 2 +- ...ub.pr-mutation-github.rerunprchecks-1.json | 2 +- ...b.pr-mutation-github.setprautomerge-1.json | 2 +- ...ub.pr-mutation-github.updateprstate-1.json | 2 +- ....pr-read-github.listassignableusers-1.json | 2 +- ...ithub.pr-read-github.prcheckdetails-1.json | 2 +- ...trix-github.pr-read-github.prchecks-1.json | 2 +- ...x-github.pr-read-github.prforbranch-1.json | 2 +- ...trix-github.pr-read-github.reposlug-1.json | 2 +- ...thub.pr-read-github.workitemdetails-1.json | 2 +- ...thub.pr-read-hostedreview.forbranch-1.json | 2 +- ...title-mutation-github.updateprtitle-1.json | 2 +- ...ix-home.host-accounts-accounts.list-1.json | 2 +- ...atrix-home.host-stats-stats.summary-1.json | 2 +- ...sh-runtime.clientevents.subscribe-1-1.json | 2 +- ...sh-runtime.clientevents.subscribe-1-2.json | 2 +- ...sh-runtime.clientevents.subscribe-1-3.json | 2 +- ...sh-runtime.clientevents.subscribe-2-1.json | 2 +- .../matrix-host.view-settings-ui.get-1.json | 2 +- .../matrix-host.view-settings-ui.set-1.json | 2 +- ....worktree-actions-worktree.activate-1.json | 2 +- ...x-host.worktree-actions-worktree.rm-1.json | 2 +- ...-host.worktree-actions-worktree.set-1.json | 2 +- ...-hostedreview.create-chain-git.push-1.json | 2 +- ...ew.create-chain-hostedreview.create-1.json | 2 +- ...tedreview.create-chain-worktree.set-1.json | 2 +- ...dreview.create-intent-git.bulkstage-1.json | 2 +- ...stedreview.create-intent-git.commit-1.json | 2 +- ...te-intent-git.generatecommitmessage-1.json | 2 +- ...hostedreview.create-intent-git.push-1.json | 2 +- ...stedreview.create-intent-git.status-1.json | 2 +- ...stedreview.create-intent-git.status-2.json | 2 +- ...stedreview.create-intent-git.status-3.json | 2 +- ...stedreview.create-intent-git.status-4.json | 2 +- ...w.create-intent-hostedreview.create-1.json | 2 +- ...hostedreview.getcreationeligibility-1.json | 2 +- ...hostedreview.getcreationeligibility-2.json | 2 +- ...edreview.create-intent-worktree.set-1.json | 2 +- ...hostedreview.getcreationeligibility-1.json | 2 +- ...-legacy-inventory-files.searchpaths-1.json | 2 +- ...-legacy-inventory-files.searchpaths-2.json | 2 +- ...trix-legacy-inventory-fresh-inventory.json | 2 +- ...matrix-legacy-inventory-old-inventory.json | 2 +- ...near-detail-barrier-linear.getissue-1.json | 2 +- ...detail-barrier-linear.issuecomments-1.json | 2 +- ...space-picker-linear.selectworkspace-1.json | 2 +- ...me-runtime.clientevents.subscribe-1-1.json | 2 +- ...me-runtime.clientevents.subscribe-1-2.json | 2 +- ...me-runtime.clientevents.subscribe-2-1.json | 2 +- ...ix-live-worktree-name-worktree.show-1.json | 2 +- ...ix-live-worktree-name-worktree.show-2.json | 2 +- ...ix-live-worktree-name-worktree.show-3.json | 2 +- .../matrix-mobileweb.bundle-fetch-app-js.json | 2 +- ...rix-mobileweb.bundle-fetch-index-head.json | 2 +- ...rix-mobileweb.bundle-fetch-index-tail.json | 2 +- ...dle-fetch-mobileweb.bundle.manifest-1.json | 2 +- ...-manifest-mobileweb.bundle.manifest-1.json | 2 +- ...ativechat.image-paste-terminal.send-1.json | 2 +- ...ativechat.image-paste-terminal.send-2.json | 2 +- ...e-upload-clipboard.startimageupload-1.json | 2 +- ...ings.mutatenativechatsessionoptions-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...vechat.terminal-write-terminal.send-1.json | 2 +- ...stream-notifications.getmissedsince-1.json | 2 +- ...op-stream-notifications.subscribe-1-1.json | 2 +- ...op-stream-notifications.subscribe-1-2.json | 2 +- ...op-stream-notifications.unsubscribe-1.json | 2 +- ...-test-screen-notifications.testpush-1.json | 2 +- ...missal-notifications.getmissedsince-1.json | 2 +- ...stration-notifications.registerpush-1.json | 2 +- ...ration-notifications.unregisterpush-1.json | 2 +- ...rix-pairing.pre-profile-direct-status.json | 4 +- ...ng.pre-profile-pairing.getendpoints-1.json | 4 +- ....pre-profile-pairing.provisionrelay-1.json | 4 +- ...trix-pairing.pre-profile-relay-status.json | 4 +- ...se-github.project.updateissuebyslug-1.json | 2 +- ...ntial-rotation-pairing.getendpoints-1.json | 4 +- ...ntial-rotation-pairing.getendpoints-2.json | 4 +- ...ial-rotation-pairing.provisionrelay-1.json | 4 +- ...direct-upgrade-pairing.getendpoints-1.json | 79 +++--- ...direct-upgrade-pairing.getendpoints-2.json | 79 +++--- ...rect-upgrade-pairing.provisionrelay-1.json | 79 +++--- ...iring-recovery-pairing.getendpoints-1.json | 4 +- ...rowser-tab-create-browser.tabcreate-1.json | 2 +- ...ion.content-create-files.createfile-1.json | 2 +- ...x-session.content-create-files.open-1.json | 2 +- ...x-session.content-create-status.get-1.json | 2 +- ...ession.content-create-worktree.show-1.json | 2 +- ...erminal-session.tabs.createterminal-1.json | 2 +- ...ssion.create-terminal-terminal.send-1.json | 2 +- ...ix-session.diff-notes-worktree.show-1.json | 2 +- ...on.diff-review-actions-worktree.set-1.json | 2 +- ...rix-session.diff-review-base-ref-show.json | 2 +- ...ssion.diff-review-git.branchcompare-1.json | 2 +- ...trix-session.diff-review-git.status-1.json | 2 +- ...atrix-session.diff-review-repo.list-1.json | 2 +- ...atrix-session.diff-review-review-show.json | 2 +- ...n.markdown-disk-fallback-files.read-1.json | 2 +- ...down-disk-fallback-markdown.readtab-1.json | 2 +- ...sion.markdown-save-markdown.savetab-1.json | 2 +- ...ve-chat-page-nativechat.readsession-1.json | 2 +- ...ve-chat-page-nativechat.subscribe-1-1.json | 2 +- ...ve-chat-page-nativechat.subscribe-2-1.json | 2 +- ...n.native-chat-readability-repo.list-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...sion.native-chat-stop-terminal.send-1.json | 2 +- ...sion.native-chat-stop-terminal.send-2.json | 2 +- ...pr-branch-context-git.branchcompare-1.json | 2 +- ...ession.pr-branch-context-git.status-1.json | 2 +- ...session.pr-branch-context-repo.list-1.json | 2 +- ...ion.pr-branch-context-worktree.show-1.json | 2 +- ...-session.pr-sidebar-github.prchecks-1.json | 2 +- ...ssion.pr-sidebar-github.prforbranch-1.json | 2 +- ...n.pr-sidebar-hostedreview.forbranch-1.json | 2 +- ...ix-session.pr-sidebar-worktree.show-1.json | 2 +- ...-triage-session.tabs.createterminal-1.json | 2 +- ...rix-session.pr-triage-terminal.send-1.json | 2 +- ...n.review-branch-diff-git.branchdiff-1.json | 2 +- ...x-session.review-file-diff-git.diff-1.json | 2 +- ...x-session.review-file-diff-git.diff-2.json | 2 +- ...x-session.review-file-diff-git.diff-3.json | 2 +- ...on.review-git-mutations-git.discard-1.json | 2 +- ...sion.review-git-mutations-git.stage-1.json | 2 +- ...sion.review-git-mutations-git.stage-2.json | 2 +- ...review-send-sheet-session.tabs.list-1.json | 2 +- ...x-session.startup-worktree.activate-1.json | 2 +- ...x-session.startup-worktree.activate-2.json | 2 +- ...ab-activation-session.tabs.activate-1.json | 2 +- ...ssion.tab-activation-terminal.focus-1.json | 2 +- ...ab-close-session-session.tabs.close-1.json | 2 +- ...ix-session.tab-close-terminal.close-1.json | 2 +- ...sion.tab-documents-markdown.readtab-1.json | 2 +- ...-session.tab-rename-terminal.rename-1.json | 2 +- ...on.tab-reveal-session.tabs.activate-1.json | 2 +- ...ession.tab-reveal-session.tabs.list-1.json | 2 +- ...abs-stream-health-session.tabs.list-1.json | 2 +- ...isplay-mode-terminal.setdisplaymode-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...-gesture-input-terminal.clearbuffer-1.json | 2 +- ...erminal-gesture-input-terminal.send-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...n.terminal-input-send-terminal.send-1.json | 2 +- ...on.terminal-inventory-terminal.list-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...session.terminal-paste-settings.get-1.json | 2 +- ...ession.terminal-paste-terminal.send-1.json | 2 +- ...ssion.worktree-connection-repo.list-1.json | 2 +- ...on.worktree-connection-settings.get-1.json | 2 +- ...t-read-preflight.detectremoteagents-1.json | 2 +- ...atrix-settings-agent-read-repo.list-1.json | 2 +- ...ix-settings-agent-read-settings.get-1.json | 2 +- ...ettings-best-effort-settings.update-1.json | 2 +- ...settings.bot-overrides-settings.get-1.json | 2 +- ...ttings.home-providers-linear.status-1.json | 2 +- ...ings.home-providers-preflight.check-1.json | 2 +- ...ettings.home-providers-settings.get-1.json | 2 +- ...local-agents-preflight.detectagents-1.json | 2 +- ...ings.new-tab-local-agents-repo.list-1.json | 2 +- ...s.new-tab-local-agents-settings.get-1.json | 2 +- ...s-settings.getterminalquickcommands-1.json | 2 +- ...ettings.updateterminalquickcommands-1.json | 2 +- ...ettings.repo-metadata-host.platform-1.json | 2 +- ...ix-settings.repo-metadata-repo.list-1.json | 2 +- ...settings.repo-metadata-settings.get-1.json | 2 +- ...po-metadata-ssh.listtargetsummaries-1.json | 2 +- ...esume-metadata-folderworkspace.list-1.json | 2 +- ...s.resume-metadata-projectgroup.list-1.json | 2 +- ...-settings.resume-metadata-repo.list-1.json | 2 +- ...ttings.resume-metadata-settings.get-1.json | 2 +- ...ettings.resume-metadata-worktree.ps-1.json | 2 +- ...ttings.task-hydration-linear.status-1.json | 2 +- ...ings.task-hydration-preflight.check-1.json | 2 +- ...ettings.task-hydration-settings.get-1.json | 2 +- ...-settings.task-hydration-status.get-1.json | 2 +- ...trix-settings.task-hydration-ui.get-1.json | 2 +- ....task-workspace-create-settings.get-1.json | 2 +- ...sk-workspace-create-worktree.create-1.json | 2 +- ...ettings.task-workspace-settings.get-1.json | 2 +- ...ngs.workspace-context-linear.status-1.json | 2 +- ...s.workspace-context-preflight.check-1.json | 2 +- ...ings.workspace-context-settings.get-1.json | 2 +- ...x-settings.workspace-context-ui.get-1.json | 2 +- ...tings.workspace-submit-settings.get-1.json | 2 +- ...tation-chunk-speech.dictation.chunk-1.json | 2 +- ...ion-session-speech.dictation.finish-1.json | 2 +- ...tion-session-speech.dictation.start-1.json | 2 +- ...ation-start-speech.dictation.cancel-1.json | 2 +- ...tation-start-speech.dictation.start-1.json | 2 +- ....setup-sheet-speech.dictation.setup-1.json | 2 +- ...ch.setup-sheet-speech.models.delete-1.json | 2 +- ....setup-sheet-speech.models.download-1.json | 2 +- ...eech.setup-sheet-speech.models.list-1.json | 2 +- ...cks-files-github.addprreviewcomment-1.json | 2 +- ...-checks-files-github.prfilecontents-1.json | 2 +- ...m-checks-files-github.rerunprchecks-1.json | 2 +- ...ks-files-github.resolvereviewthread-1.json | 2 +- ...checks-files-github.setprfileviewed-1.json | 2 +- ...mment-github-github.addissuecomment-1.json | 2 +- ...mment-gitlab-gitlab.addissuecomment-1.json | 2 +- ...mment-gitlab-mr-gitlab.addmrcomment-1.json | 2 +- ...etail-github-github.workitemdetails-1.json | 2 +- ...etail-gitlab-gitlab.workitemdetails-1.json | 2 +- ....item-detail-linear-linear.getissue-1.json | 2 +- ...-detail-linear-linear.issuecomments-1.json | 2 +- ...metadata-github.listassignableusers-1.json | 2 +- ...m-detail-metadata-github.listlabels-1.json | 2 +- ...ks.item-merge-gitlab-gitlab.mergemr-1.json | 2 +- ...tem-metadata-github-github.updatepr-1.json | 2 +- ...-metadata-gitlab-gitlab.updateissue-1.json | 2 +- ...-metadata-gitlab-mr-gitlab.updatemr-1.json | 2 +- ...-reply-merge-github.addissuecomment-1.json | 2 +- ...erge-github.addprreviewcommentreply-1.json | 2 +- ...sks.item-reply-merge-github.mergepr-1.json | 2 +- ...item-reply-merge-linear.updateissue-1.json | 2 +- ....item-review-github-github.prchecks-1.json | 2 +- ...ew-github-github.requestprreviewers-1.json | 2 +- ...em-status-gitlab-github.updateissue-1.json | 2 +- ...em-status-gitlab-gitlab.updateissue-1.json | 2 +- ...atus-gitlab-mr-gitlab.updatemrstate-1.json | 2 +- ...tasks.linear-connect-linear.connect-1.json | 2 +- ....linear-item-linear.addissuecomment-1.json | 2 +- ...asks.linear-item-linear.createissue-1.json | 2 +- ...x-tasks.linear-item-linear.getissue-1.json | 2 +- ...inear-team-context-linear.listteams-1.json | 2 +- ...near-team-context-linear.teamstates-1.json | 2 +- ...-tasks.paste-lookup-github.reposlug-1.json | 2 +- ...-tasks.paste-lookup-github.workitem-1.json | 2 +- ...e-lookup-github.workitembyownerrepo-1.json | 2 +- ....paste-lookup-gitlab.workitembypath-1.json | 2 +- ...-load-github.project.listaccessible-1.json | 2 +- ...board-load-github.project.listviews-1.json | 2 +- ...board-load-github.project.listviews-2.json | 2 +- ...oard-load-github.project.resolveref-1.json | 2 +- ...board-load-github.project.viewtable-1.json | 2 +- ....project-repo-slugs-github.reposlug-1.json | 2 +- ...ithub.project.addissuecommentbyslug-1.json | 2 +- ...ue-github.project.updateissuebyslug-1.json | 2 +- ...ub.project.updateissuecommentbyslug-1.json | 2 +- ...hub.project.updatepullrequestbyslug-1.json | 2 +- ...ithub.project.workitemdetailsbyslug-1.json | 2 +- ...ields-github.project.clearitemfield-1.json | 2 +- ...ithub.project.updateissuetypebyslug-1.json | 2 +- ...elds-github.project.updateitemfield-1.json | 2 +- ...les-merge-github.addprreviewcomment-1.json | 2 +- ...ject-row-files-merge-github.mergepr-1.json | 2 +- ...w-files-merge-github.prfilecontents-1.json | 2 +- ...-row-files-merge-github.updateissue-1.json | 2 +- ...ow-files-merge-github.updateprstate-1.json | 2 +- ...b.project.listassignableusersbyslug-1.json | 2 +- ...github.project.listissuetypesbyslug-1.json | 2 +- ...oad-github.project.listlabelsbyslug-1.json | 2 +- ...t-row-review-checks-github.prchecks-1.json | 2 +- ...ew-checks-github.requestprreviewers-1.json | 2 +- ...-review-checks-github.rerunprchecks-1.json | 2 +- ...eview-checks-github.setprfileviewed-1.json | 2 +- ...-row-threads-github.addissuecomment-1.json | 2 +- ...eads-github.addprreviewcommentreply-1.json | 2 +- ...ub.project.deleteissuecommentbyslug-1.json | 2 +- ...-threads-github.resolvereviewthread-1.json | 2 +- ...provider-load-github.countworkitems-1.json | 2 +- ....provider-load-github.listworkitems-1.json | 2 +- ...asks.provider-load-linear.listteams-1.json | 2 +- ...x-tasks.provider-load-linear.status-1.json | 2 +- ...tasks.provider-load-settings.update-1.json | 2 +- ...rix-tasks.route-repo-list-repo.list-1.json | 2 +- ...-source-search-github.listworkitems-1.json | 2 +- ...-source-search-gitlab.listworkitems-1.json | 2 +- ...art-source-search-linear.listissues-1.json | 2 +- ...t-source-search-linear.searchissues-1.json | 2 +- ...smart-source-search-repo.searchrefs-1.json | 2 +- ...sk-create-github-github.createissue-1.json | 2 +- ...asks.task-create-github-repo.update-1.json | 2 +- ...sk-create-gitlab-gitlab.createissue-1.json | 2 +- ...sk-create-linear-linear.createissue-1.json | 2 +- ...t-gitlab-items-gitlab.listworkitems-1.json | 2 +- ...task-list-gitlab-todos-gitlab.todos-1.json | 2 +- ....task-list-linear-linear.listissues-1.json | 2 +- ...ask-list-linear-linear.searchissues-1.json | 2 +- ...ks.workspace-source-repo.searchrefs-1.json | 2 +- ...workspace-source-repo.sparsepresets-1.json | 2 +- ...kspace-sparse-repo.savesparsepreset-1.json | 2 +- ...tasks.workspace-sparse-ssh.getstate-1.json | 2 +- ...ce-ssh-local-preflight.detectagents-1.json | 2 +- ...ce-ssh-preflight.detectremoteagents-1.json | 2 +- ...trix-tasks.workspace-ssh-repo.hooks-1.json | 2 +- ...rix-tasks.workspace-ssh-ssh.connect-1.json | 2 +- ...-terminal.query-reply-terminal.send-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...ix-terminal.raw-input-terminal.send-1.json | 2 +- ...chestration.workerterminaluserinput-1.json | 2 +- ...chestration.workerterminaluserinput-2.json | 2 +- ...wport-refit-terminal.updateviewport-1.json | 2 +- ...ansport.capability-probe-status.get-1.json | 2 +- ...nsport.host-status-gates-status.get-1.json | 2 +- ...-transport.pairing-race-direct-status.json | 2 +- ...x-transport.pairing-race-relay-status.json | 2 +- ...ee.agent-launch-create-agent.launch-1.json | 2 +- ...rktree.catalog-snapshot-worktree.ps-1.json | 2 +- ...rktree.create-retry-worktree.create-1.json | 2 +- ...x-worktree.home-catalog-worktree.ps-1.json | 2 +- ....hosted-base-worktree.resolvemrbase-1.json | 2 +- ....hosted-base-worktree.resolveprbase-1.json | 2 +- ...red-names-worktree.listretirednames-1.json | 2 +- ...x-worktree.review-link-worktree.set-1.json | 2 +- ...ree.runtime-capabilities-status.get-1.json | 2 +- ...ix-worktree.setup-hook-trust-ui.set-1.json | 2 +- .../mobile-web-bundle-build-changed.json | 2 +- .../mobile-web-bundle-fetch-paged.json | 2 +- .../mobile-web-bundle-manifest-read.json | 2 +- .../mobile-web-bundle-unavailable.json | 2 +- .../native-chat-image-paste-single.json | 2 +- ...e-chat-image-paste-stops-on-rejection.json | 2 +- ...ative-chat-image-paste-trailing-image.json | 2 +- .../native-chat-image-paste-two-images.json | 2 +- .../native-chat-image-upload-cancelled.json | 2 +- ...native-chat-image-upload-second-fails.json | 2 +- .../native-chat-image-upload-single.json | 2 +- ...ative-chat-image-upload-start-refused.json | 2 +- .../goldens/native-chat-image-upload-two.json | 2 +- .../goldens/native-chat-page-earlier.json | 2 +- .../native-chat-readability-local-repo.json | 2 +- .../native-chat-readability-refused.json | 2 +- .../native-chat-readability-remote-repo.json | 2 +- ...native-chat-session-option-pick-empty.json | 2 +- ...tive-chat-session-option-pick-refused.json | 2 +- ...tive-chat-session-option-pick-written.json | 2 +- .../goldens/native-chat-stop-accepted.json | 2 +- .../native-chat-stop-both-rejected.json | 2 +- .../native-chat-stop-delivery-unknown.json | 2 +- .../goldens/native-chat-write-accepted.json | 2 +- .../goldens/native-chat-write-clear-line.json | 2 +- .../native-chat-write-delivery-unknown.json | 2 +- .../goldens/native-chat-write-rejected.json | 2 +- .../native-chat-write-typed-command.json | 2 +- .../goldens/new-tab-local-agents.json | 2 +- .../new-workspace-repositories-fulfilled.json | 2 +- .../notifications-desktop-stream-closed.json | 2 +- ...notifications-desktop-stream-replayed.json | 2 +- .../goldens/notifications-desktop-stream.json | 2 +- .../notifications-display-test-accepted.json | 2 +- ...fications-display-test-not-registered.json | 2 +- ...tifications-display-test-rate-limited.json | 2 +- ...fications-display-test-unknown-reason.json | 2 +- .../notifications-push-gateway-rejected.json | 2 +- .../notifications-push-registered.json | 2 +- ...re-profile-direct-wins-and-provisions.json | 4 +- ...ovision-unsupported-saves-direct-host.json | 4 +- .../pairing-pre-profile-times-out.json | 4 +- .../goldens/pr-branch-identity.json | 2 +- .../goldens/pr-branch-repo-context.json | 2 +- .../goldens/pr-comment-mutation.json | 2 +- .../pr-comment-resolve-unconfirmed.json | 2 +- .../goldens/pr-mutation-in-band-failure.json | 2 +- .../goldens/pr-mutation-status.json | 2 +- .../goldens/pr-read-fork-routing.json | 2 +- .../goldens/pr-read-surface.json | 2 +- .../goldens/pr-read-upstream-error.json | 2 +- .../goldens/pr-sidebar-checks-refused.json | 2 +- .../goldens/pr-sidebar-load.json | 2 +- .../goldens/pr-title-mutation.json | 2 +- .../goldens/pr-title-unconfirmed.json | 2 +- .../goldens/pr-triage-invalid-terminal.json | 2 +- .../goldens/pr-triage-launch.json | 2 +- .../goldens/pr-triage-send-locked.json | 2 +- .../goldens/probe-new-tab-both-refused.json | 2 +- .../probe-new-tab-null-sibling-refused.json | 2 +- ...probe-new-tab-refused-sibling-rejects.json | 2 +- ...probe-new-tab-rejects-sibling-refused.json | 2 +- .../push-dismissal-tray-reconciled.json | 2 +- .../goldens/quick-commands-load-refused.json | 2 +- .../quick-commands-loaded-and-saved.json | 2 +- ...uick-commands-save-refused-rolls-back.json | 2 +- .../goldens/relay-direct-upgrade-commits.json | 79 +++--- ...ect-upgrade-unsupported-host-declines.json | 4 +- ...ay-pairing-recovery-invite-authorizes.json | 4 +- ...lay-pairing-recovery-resume-committed.json | 4 +- .../relay-rotation-installs-and-commits.json | 4 +- ...ay-rotation-resumes-committed-pending.json | 4 +- .../goldens/review-branch-diff-shapes.json | 2 +- .../review-create-terminal-refused.json | 2 +- .../goldens/review-file-diff-shapes.json | 2 +- .../goldens/review-git-mutations-run.json | 2 +- .../review-mark-reviewed-persists.json | 2 +- .../review-mark-reviewed-rolls-back.json | 2 +- .../goldens/review-open-in-session.json | 2 +- .../review-send-notes-heals-stale-input.json | 2 +- .../review-send-sheet-lists-terminals.json | 2 +- .../goldens/review-stage-file.json | 2 +- .../goldens/review-stage-refused.json | 2 +- .../goldens/sc-base-ref-default.json | 2 +- .../goldens/sc-base-ref-repo-fallback.json | 2 +- .../goldens/sc-base-ref-unavailable.json | 2 +- .../goldens/sc-base-ref-worktree-hit.json | 2 +- .../goldens/sc-branch-diff-previewed.json | 2 +- .../goldens/sc-changes-loaded.json | 2 +- .../sc-commit-message-cancel-rejected.json | 2 +- .../goldens/sc-commit-message-canceled.json | 2 +- .../goldens/sc-commit-message-generated.json | 2 +- .../goldens/sc-create-existing-review.json | 2 +- ...reate-intent-stage-commit-push-create.json | 2 +- .../sc-create-intent-unlisted-provider.json | 2 +- .../sc-create-link-failure-is-non-fatal.json | 2 +- .../sc-create-pushes-then-creates.json | 2 +- .../sc-create-refused-empty-message.json | 2 +- .../sc-create-rejected-empty-message.json | 2 +- .../goldens/sc-eligibility-fetched.json | 2 +- .../goldens/sc-history-commit-files.json | 2 +- .../goldens/sc-history-loaded.json | 2 +- .../goldens/sc-pr-link-hosted-review.json | 2 +- .../goldens/sc-pr-link-read.json | 2 +- .../goldens/sc-pr-link-set.json | 2 +- .../sc-prefill-unavailable-on-refusal.json | 2 +- .../sc-prefill-unavailable-on-rejection.json | 2 +- .../sc-prerequisite-force-with-lease.json | 2 +- .../goldens/sc-prerequisite-publish.json | 2 +- .../goldens/sc-prerequisite-push.json | 2 +- .../goldens/sc-prerequisite-skipped.json | 2 +- .../goldens/sc-reveal-first-poll.json | 2 +- .../goldens/sc-reveal-timeout.json | 2 +- .../sc-review-commit-inner-failure.json | 2 +- ...c-review-commit-refused-empty-message.json | 2 +- .../goldens/sc-review-commit-rejected.json | 2 +- .../goldens/sc-review-commit.json | 2 +- .../sc-review-status-entries-not-array.json | 2 +- .../goldens/sc-review-status-normalized.json | 2 +- .../rpc-foundation/goldens/schedules-b3.json | 2 +- ...les-settings-home-providers-fulfilled.json | 2 +- .../schedules-settings-new-tab-ssh.json | 2 +- ...ules-settings-repo-metadata-fulfilled.json | 2 +- ...es-settings-resume-metadata-fulfilled.json | 2 +- ...les-settings-task-hydration-fulfilled.json | 2 +- ...-settings-workspace-context-fulfilled.json | 2 +- .../goldens/session-browser-tab-created.json | 2 +- .../session-create-browser-refused.json | 2 +- .../goldens/session-create-browser-tab.json | 2 +- ...ession-create-markdown-name-collision.json | 2 +- .../goldens/session-create-markdown-note.json | 2 +- ...nal-ignores-a-second-create-in-flight.json | 2 +- ...minal-launches-an-agent-quick-command.json | 2 +- .../session-create-terminal-refused.json | 2 +- ...ssion-create-terminal-replaces-active.json | 2 +- ...-create-terminal-runs-a-quick-command.json | 2 +- .../session-create-terminal-with-prompt.json | 2 +- ...on-create-terminal-without-active-tab.json | 2 +- ...ession-create-terminal-without-handle.json | 2 +- .../session-diff-notes-load-refused.json | 2 +- .../goldens/session-diff-notes-loaded.json | 2 +- .../goldens/session-file-tab-read.json | 2 +- .../goldens/session-markdown-disk-read.json | 2 +- .../goldens/session-markdown-disk-served.json | 2 +- .../session-markdown-save-conflict.json | 2 +- .../goldens/session-markdown-saved.json | 2 +- .../session-markdown-tab-disk-fallback.json | 2 +- .../goldens/session-markdown-tab-read.json | 2 +- .../goldens/session-markdown-tab-refused.json | 2 +- ...session-startup-both-activation-sites.json | 2 +- ...artup-floating-route-skips-activation.json | 2 +- ...-keeps-terminals-visible-on-reconnect.json | 2 +- ...efused-tab-load-still-loads-terminals.json | 2 +- ...ion-tab-activation-focus-and-activate.json | 2 +- .../session-tab-activation-refused.json | 2 +- ...ession-tab-activation-transport-error.json | 2 +- .../session-tab-close-refused-keeps-tab.json | 2 +- .../session-tab-close-session-tab.json | 2 +- .../goldens/session-tab-close-terminal.json | 2 +- .../goldens/session-tab-closed.json | 2 +- .../goldens/session-tab-rename.json | 2 +- .../goldens/session-tab-renamed.json | 2 +- .../goldens/session-tabs-health-errored.json | 2 +- .../session-tabs-health-reconciled.json | 2 +- .../goldens/session-tabs-health-refused.json | 2 +- ...abs-health-stale-application-revision.json | 2 +- ...terminal-display-mode-auto-take-floor.json | 2 +- ...isplay-mode-auto-without-device-token.json | 2 +- ...al-display-mode-auto-without-viewport.json | 2 +- ...inal-display-mode-drops-second-toggle.json | 2 +- ...sion-terminal-display-mode-to-desktop.json | 2 +- ...session-terminal-list-dedupes-handles.json | 2 +- .../session-terminal-list-empty-guarded.json | 2 +- .../goldens/session-terminal-list-merged.json | 2 +- .../session-terminal-list-refused.json | 2 +- .../settings-bot-overrides-fulfilled.json | 2 +- ...ettings-bot-overrides-refresh-refused.json | 2 +- .../settings-bot-overrides-refused.json | 2 +- ...ettings-bot-overrides-transport-error.json | 2 +- .../goldens/settings-home-coalesced.json | 2 +- .../settings-home-providers-fulfilled.json | 2 +- ...ings-home-providers-refuse-after-data.json | 2 +- .../settings-home-providers-refused.json | 2 +- ...ttings-home-providers-transport-error.json | 2 +- .../goldens/settings-new-tab-refused.json | 2 +- .../goldens/settings-new-tab-ssh.json | 2 +- .../settings-new-tab-transport-error.json | 2 +- .../goldens/settings-repo-cache-expiry.json | 2 +- .../settings-repo-metadata-fulfilled.json | 2 +- .../goldens/settings-repo-metadata-icons.json | 2 +- ...tings-repo-metadata-refuse-after-data.json | 2 +- .../settings-repo-metadata-refused.json | 2 +- .../settings-repo-metadata-single-host.json | 2 +- ...ettings-repo-metadata-transport-error.json | 2 +- .../settings-resume-metadata-fulfilled.json | 2 +- ...ngs-resume-metadata-refuse-after-data.json | 2 +- .../settings-resume-metadata-refused.json | 2 +- ...tings-resume-metadata-transport-error.json | 2 +- .../settings-task-hydration-fulfilled.json | 2 +- ...ings-task-hydration-refuse-after-data.json | 2 +- .../settings-task-hydration-refused.json | 2 +- ...ttings-task-hydration-transport-error.json | 2 +- ...settings-task-workspace-create-linear.json | 2 +- ...-task-workspace-create-pr-start-point.json | 2 +- .../settings-task-workspace-fulfilled.json | 2 +- .../settings-task-workspace-refused.json | 2 +- ...ttings-task-workspace-transport-error.json | 2 +- .../goldens/settings-task-write.json | 2 +- .../settings-workspace-context-fulfilled.json | 2 +- ...s-workspace-context-refuse-after-data.json | 2 +- .../settings-workspace-context-refused.json | 2 +- ...ngs-workspace-context-transport-error.json | 2 +- .../settings-workspace-submit-fulfilled.json | 2 +- .../settings-workspace-submit-refused.json | 2 +- ...ings-workspace-submit-transport-error.json | 2 +- .../speech-audio-chunk-acknowledged.json | 2 +- .../speech-desktop-start-fulfilled.json | 2 +- ...speech-desktop-start-recording-failed.json | 2 +- .../speech-desktop-start-superseded.json | 2 +- .../speech-dictation-session-cancelled.json | 2 +- .../speech-dictation-session-transcript.json | 2 +- .../speech-setup-sheet-denied-to-mobile.json | 2 +- .../goldens/speech-setup-sheet-fulfilled.json | 2 +- .../speech-setup-sheet-legacy-desktop.json | 2 +- .../speech-setup-sheet-model-vocabulary.json | 2 +- .../structured-agent-session-created.json | 2 +- .../goldens/structured-launch-created.json | 2 +- .../structured-launch-definitive-refusal.json | 2 +- ...uctured-launch-replays-dropped-create.json | 2 +- .../structured-launch-support-refused.json | 2 +- .../structured-launch-unsupported.json | 2 +- .../goldens/tasks-route-repo-list.json | 2 +- .../terminal-gesture-flush-and-clear.json | 2 +- .../goldens/terminal-input-send-accepted.json | 2 +- .../goldens/terminal-input-send-refused.json | 2 +- .../goldens/terminal-live-input-accepted.json | 2 +- .../goldens/terminal-paste-accepted.json | 2 +- .../goldens/terminal-paste-refused.json | 2 +- .../terminal-query-reply-accepted.json | 2 +- .../terminal-query-reply-unsubscribed.json | 2 +- .../goldens/terminal-raw-input-refused.json | 2 +- .../goldens/terminal-raw-input-reported.json | 2 +- .../terminal-takeover-report-accepted.json | 2 +- .../terminal-takeover-report-retried.json | 2 +- .../terminal-viewport-refit-applied.json | 2 +- ...erminal-viewport-refit-legacy-desktop.json | 2 +- ...terminal-worktree-connection-resolved.json | 2 +- .../goldens/tk-create-github.json | 2 +- .../goldens/tk-create-gitlab.json | 2 +- .../goldens/tk-create-linear.json | 2 +- .../goldens/tk-item-checks-files.json | 2 +- .../goldens/tk-item-comment-github.json | 2 +- .../goldens/tk-item-comment-gitlab-mr.json | 2 +- .../goldens/tk-item-comment-gitlab.json | 2 +- .../tk-item-detail-github-reactions.json | 2 +- .../goldens/tk-item-detail-github.json | 2 +- .../tk-item-detail-gitlab-reactions.json | 2 +- .../goldens/tk-item-detail-gitlab.json | 2 +- .../goldens/tk-item-detail-linear.json | 2 +- .../goldens/tk-item-detail-metadata.json | 2 +- .../goldens/tk-item-merge-gitlab.json | 2 +- .../goldens/tk-item-metadata-github.json | 2 +- .../goldens/tk-item-metadata-gitlab-mr.json | 2 +- .../goldens/tk-item-metadata-gitlab.json | 2 +- .../goldens/tk-item-reply-merge.json | 2 +- .../goldens/tk-item-review-github.json | 2 +- .../goldens/tk-item-status-gitlab-mr.json | 2 +- .../goldens/tk-item-status-gitlab.json | 2 +- .../goldens/tk-linear-connect.json | 2 +- .../goldens/tk-linear-item.json | 2 +- .../goldens/tk-linear-team-context.json | 2 +- .../goldens/tk-list-gitlab-items.json | 2 +- .../goldens/tk-list-gitlab-todos.json | 2 +- .../goldens/tk-list-linear.json | 2 +- .../goldens/tk-project-board-load.json | 2 +- .../goldens/tk-project-repo-slugs.json | 2 +- .../tk-project-row-comments-issue.json | 2 +- .../goldens/tk-project-row-comments-pr.json | 2 +- .../goldens/tk-project-row-detail.json | 2 +- .../goldens/tk-project-row-fields.json | 2 +- .../goldens/tk-project-row-files-merge.json | 2 +- .../goldens/tk-project-row-metadata-load.json | 2 +- .../goldens/tk-project-row-review-checks.json | 2 +- .../goldens/tk-project-row-threads.json | 2 +- .../goldens/tk-provider-load.json | 2 +- ...-capability-probe-cutover-reasks-fast.json | 2 +- ...ty-probe-non-string-capabilities-drop.json | 2 +- .../transport-capability-probe-publishes.json | 2 +- ...rt-capability-probe-refused-backs-off.json | 2 +- ...-status-gates-drop-keeps-capabilities.json | 2 +- .../transport-host-status-gates-ready.json | 2 +- ...rt-host-status-gates-refused-degrades.json | 2 +- .../transport-pairing-race-both-refused.json | 2 +- ...t-pairing-race-direct-completes-first.json | 2 +- ...rt-pairing-race-relay-completes-first.json | 2 +- ...g-race-relay-wins-when-direct-refused.json | 2 +- .../goldens/tw-capabilities-advertised.json | 2 +- .../tw-capabilities-cutover-retried.json | 2 +- .../tw-capabilities-legacy-idempotency.json | 2 +- .../tw-create-retry-agent-launched.json | 2 +- .../tw-create-retry-ambiguous-after-drop.json | 2 +- ...reate-retry-ambiguous-while-connected.json | 2 +- ...e-retry-ambiguous-without-idempotency.json | 2 +- .../goldens/tw-create-retry-created.json | 2 +- .../tw-create-retry-name-collision.json | 2 +- .../tw-create-retry-unretryable-refusal.json | 2 +- .../goldens/tw-create-retry-warning-kept.json | 2 +- .../goldens/tw-hosted-base-resolved.json | 2 +- .../goldens/tw-hosted-base-soft-error.json | 2 +- .../goldens/tw-paste-lookup-resolved.json | 2 +- .../goldens/tw-paste-lookup-slug-refused.json | 2 +- .../tw-paste-lookup-slug-unsupported.json | 2 +- .../goldens/tw-setup-hook-trust-always.json | 2 +- .../goldens/tw-setup-hook-trust-approved.json | 2 +- .../tw-smart-search-all-providers.json | 2 +- ...tw-smart-search-gitlab-provider-error.json | 2 +- .../tw-smart-search-linear-listed.json | 2 +- .../tw-task-preferences-resume-write.json | 2 +- .../tw-workspace-source-presets-refused.json | 2 +- .../goldens/tw-workspace-source-presets.json | 2 +- .../tw-workspace-sparse-missing-preset.json | 2 +- .../goldens/tw-workspace-sparse-saved.json | 2 +- .../tw-workspace-ssh-connect-refused.json | 2 +- .../goldens/tw-workspace-ssh-connected.json | 2 +- .../tw-workspace-ssh-local-agents.json | 2 +- .../goldens/tw-workspace-ssh-not-ready.json | 2 +- .../worktree-catalog-snapshot-unreadable.json | 2 +- .../goldens/worktree-catalog-snapshot.json | 2 +- .../goldens/worktree-home-catalog.json | 2 +- .../goldens/worktree-retired-names.json | 2 +- mobile/rpc-foundation/pilot-scenarios.json | 2 +- mobile/src/components/MobileHostCard.test.ts | 1 - .../src/host-edit-route-accessibility.test.ts | 6 +- mobile/src/host-edit-save-flow.test.ts | 37 +-- .../pairing-journal-mount-adapters.ts | 6 +- .../relay-credential-mount-adapters.ts | 4 +- mobile/src/transport/client-context.test.ts | 47 +++- .../transport/host-catalog-credential-join.ts | 17 +- .../src/transport/host-list-mutation-queue.ts | 7 +- mobile/src/transport/host-logical-client.ts | 2 +- .../src/transport/host-name-identity.test.ts | 10 +- .../src/transport/host-store-endpoint.test.ts | 117 ++++++++- mobile/src/transport/host-store.test.ts | 109 ++++---- mobile/src/transport/host-store.ts | 60 +++-- mobile/src/transport/host-store.web.ts | 7 +- .../mobile-direct-endpoint-probe.test.ts | 85 ++---- .../transport/mobile-direct-endpoint-probe.ts | 88 ++----- .../mobile-direct-probe-stop-budget.test.ts | 59 +---- .../transport/mobile-direct-return-probe.ts | 12 +- ...obile-endpoint-lifecycle-host-edit.test.ts | 244 ++++++++++++++++++ .../transport/mobile-endpoint-lifecycle.ts | 20 +- .../mobile-endpoint-supervisor-contract.ts | 8 +- ...e-endpoint-supervisor-direct-probe.test.ts | 5 +- .../mobile-endpoint-supervisor-nudge.test.ts | 29 ++- ...e-endpoint-supervisor-stalled-cell.test.ts | 6 +- .../mobile-endpoint-supervisor-support.ts | 29 +-- .../mobile-endpoint-supervisor-test-fakes.ts | 8 +- .../mobile-endpoint-supervisor.test.ts | 93 +++---- .../transport/mobile-endpoint-supervisor.ts | 26 +- .../mobile-relay-background-lifecycle.test.ts | 17 +- .../src/transport/mobile-relay-connect-url.ts | 7 + ...le-relay-direct-upgrade-controller.test.ts | 16 +- .../mobile-relay-direct-upgrade.test.ts | 19 +- .../transport/mobile-relay-direct-upgrade.ts | 17 +- .../src/transport/mobile-relay-e2ee-link.ts | 10 +- .../mobile-relay-host-overlay-store.test.ts | 129 ++++++--- .../mobile-relay-host-overlay-store.ts | 69 +++-- .../transport/mobile-relay-host-overlay.ts | 33 ++- .../transport/mobile-relay-invite-director.ts | 10 +- .../transport/mobile-relay-orphan-cleanup.ts | 6 +- .../mobile-relay-outage-escalation.test.ts | 5 +- .../mobile-relay-pairing-recovery.test.ts | 28 +- .../mobile-relay-pairing-recovery.ts | 30 +-- ...relay-pairing-rejection-escalation.test.ts | 5 +- .../transport/mobile-relay-physical-client.ts | 10 +- .../mobile-relay-runtime-failover.test.ts | 36 +-- .../mobile-relay-session-establisher.ts | 34 ++- .../paired-host-writer-census.test.ts | 39 +++ mobile/src/transport/pairing-relay-host.ts | 18 +- .../pre-profile-pairing-coordinator.test.ts | 32 +-- .../pre-profile-pairing-coordinator.ts | 12 +- .../relay-host-signed-out-supervisor.test.ts | 5 +- mobile/src/transport/types.ts | 18 +- 841 files changed, 1964 insertions(+), 1709 deletions(-) create mode 100644 mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts create mode 100644 mobile/src/transport/mobile-relay-connect-url.ts create mode 100644 mobile/src/transport/paired-host-writer-census.test.ts diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index af15f28ad20..b27faaf9520 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -17,7 +17,7 @@ import { colors, radii, spacing, typography } from '../../../src/theme/mobile-th import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store' import { displayHostEndpoint } from '../../../src/transport/host-endpoint' import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit' -import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context' +import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/client-context' import type { HostProfile } from '../../../src/transport/types' export default function EditHostScreen() { @@ -25,7 +25,7 @@ export default function EditHostScreen() { const insets = useSafeAreaInsets() const { hostId } = useLocalSearchParams<{ hostId: string }>() const primeHosts = usePrimeHosts() - const forceReconnectHost = useForceReconnect() + const refreshHostClient = useRefreshHostClient() const [host, setHost] = useState(null) const [loadError, setLoadError] = useState(null) @@ -130,10 +130,8 @@ export default function EditHostScreen() { router.back() if (nextEndpoint !== undefined) { - // Why: reconnect is a follow-on side effect of a save that already - // committed — its failure or a hang must not be reported as a save - // failure or block navigating back. - void forceReconnectHost?.(host.id).catch(() => {}) + // Why: the live client, even one riding the relay, and its primed profile hold the old address. + refreshHostClient(host.id) } } diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index 55471099e5f..a5bff9a1469 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index fc7ea66eca0..2a2f9fc217b 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index 98b48fdd688..2201ee3760c 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index f964c1a28b4..3380d08bd6c 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 942df4ffd3d..584dc33fcf7 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json index 6391a5e8a3c..084668fc2f7 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json index e1fdd21067e..76558ddfd1f 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index d2d76536ff1..637fbbb27ba 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 59508853b53..4a7fecf0243 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index 3c558612089..e8d4fd34cdc 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index a68e0337554..05c6cd9d402 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index 909b23172ce..4eadd875de7 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index 8ec6ca3ffc4..3d7e60c77d2 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index ec6cc9d9668..a57ccc91063 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index 5f5fbc606c8..987922474ff 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index 4b65472be52..aef37a07ed5 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index 98b00a167f4..20967718f4b 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index c877396079e..6cdc6b78b25 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index 3dca387cf68..e8d9e2015bb 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index d07f0f17927..c7b1b1f8a47 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index 70c5e0c5d40..bf81e2a2849 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index 4ec937a7cfc..e800c95bc75 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index 394897047ff..4a705fcc0ae 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json index 33570af989f..25f1aff4cab 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index 832cc339401..58bf869ab25 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index 3de080b6349..22d2645fbd9 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json index 35da905b4d9..f02db3bcf4f 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json index d4f1147b97c..51d108b4d67 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index f2c1cf2045d..8a263abb86e 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json index 0f39d1e497b..3b7b5554e00 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json index 0c9c5af4693..95bb1a35bdf 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index 075bc5a05cc..ad81a1f90d1 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index 74b969bc3d6..377ab7f30fb 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index 545d506604b..bcb5d9eef87 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index 4ca71821d39..8ec466a0669 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index 9b1909fb92f..8eb566781a3 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index 3ea26766365..8436eff2d05 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index 52bfa580b4d..41c7f615e42 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json index 1b187b74416..b3363700ba3 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json index 4f101dc938e..efa63b3d998 100644 --- a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json index c116428fbc8..42566ff54ce 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index 9f84bf37f0b..efb4b64b219 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index ac4b1efac37..ba19d4de4f1 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json index 18c0ea296db..80bf8c762d3 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index d2d2a042bab..4b60ec30b2c 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json index 15d1a782c2b..c94d8f8df3b 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json index f13b7b96bc2..629ac6878c5 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index 9db2ea75004..3aec65f9e2b 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index fc088e8152f..21aae6589db 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index d2af44da2e3..9c332f5508e 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index f97fcde68dc..d6717958b1e 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index cbc0e219a59..546835765cf 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index 3819cbc5c70..52fe62c2e0b 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index 3cc0e9686fb..f99113382dd 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json index aa650dc41ec..fd1d3444578 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index 8a0b86c9b19..82154035ccd 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index 991490b5a68..38689c6a741 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json index 0ad6e870494..dd8668b178e 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index 528f6f6ea14..fc0c99bb440 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json index 4536644fabc..81542177808 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index 1e11e3bdea8..515eefbb070 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index ec82d88923d..a2c2b81e3d3 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index edee94bea23..c4372f02ff6 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index cba9625cf4d..010169d86e3 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index 708ae3d8ff5..dbd5b10a795 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index 654734a8b56..a44954f83fc 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index eecef703ed9..ef593c8d7a5 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index 279d97c34f6..19937022044 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index 304e215d906..045e5f773d1 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index 0cd99cfea3d..d4169a8db0c 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 863ea20d0d1..395a570dbf9 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index e5f209c60ad..cbee7d9c27f 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index 2ed4291612a..c914cff468e 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index 82ddc0490e0..36ebaee3378 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index 9e4cec6ea27..65b587d98f2 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index e0885db66ae..3cbb13bf6cf 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index c47ae1338ba..f00062de9c6 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index 84ba8326b1a..034b415bb90 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index 59604060817..fc3552a26a9 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index 4213ec2ba39..5e81ff139a6 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index 4311d04732e..78808fa4fef 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json index 5fd79f91209..46a6bc13f10 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json index e1e605ec8fc..e35026c3e02 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json index 49ae0d7e662..a6efbb55d4e 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index a952c2f1c22..3c62df181fa 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index 0ef5b9a7107..522bde605d0 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index 4fab6aabbbd..1063b637e92 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index 2faa872fabf..693a8456f47 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index 8ed82ab367c..288de16ce31 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json index 87d14c75d51..e431cdf50cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json index e479c4e12e7..8bd26bb66de 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json index 40039c0d266..a377799281b 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json index 17b5d535486..d62a83ea7b3 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json index f9536456b20..1a50ec3cf83 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json index c96f9cba571..8f86b0396db 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json index b53f11178f2..19cf2be58ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json index 01192a5ad11..1ae43acd959 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json index d1a4bcde2f5..7830cba9404 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json index 3406570d845..7ee4a7e8d15 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json index f9d656b0d9f..ea17c64b0c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json index a47000279bc..95369979ae1 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json index 20b8478b685..c63b126406a 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json index cd252dcbae2..6d09f0058c2 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json index 541b816ed6c..09954e76ed8 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json index cb63d591b7d..cad7309bbcd 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json index 476633dcdf4..a7964dd6d64 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index 3212a72b254..07ced4a4e15 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index 484fdd1fc6b..5935cc26e55 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index ea7b9b35f5e..f2ded5eb6ec 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index b4c9fbb3f0c..5e556e84bc4 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json index 9a048b1baf5..6f955ea6107 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json index 43ee28e2985..e46c048bcf7 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json index 45e652894f7..4b7e7daccfb 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json index b07b71e98da..8cd459a6dea 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json index acbef18e1ae..d0309aab357 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json index 5062f9399a5..0b94838a04e 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json index 161e7cdb18f..f349642235d 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json index 0e89d2dec13..6fe81215c6c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json index 0cbc7670840..74b92ed6d15 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json index 28b1cd75b42..b76e1ab7926 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json index 06414c8fa7c..47d55014861 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json index f781f01f113..68af56c16f0 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json index 6f1cae00ae9..1c45531ff43 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json index 8434453384f..b861453dc8f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json index a42c95a7b82..9b9811763a9 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json index 3cde08a7331..65413da7547 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json index 883b213680b..62999b7049d 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json index a4dc093fbb3..361c4203d7f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json index 563df8d71fa..c3f9267b452 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json index a0d293c1dc9..18493ea2846 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index e24cef7b394..46bc933f03c 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index a9b59fbcd32..72fa26f689e 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index cf4db4e2da2..5b90d565c9f 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json index f19ed91eda1..49cf0e2c7b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json index 8e364723e61..a25621f1cef 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json index e2477dc7688..5003cd87ac2 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json index ac0b04c8766..5bc7755c672 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json index 362edc5e539..3b808329fab 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index e7afb911bfa..5fbd35ff7bf 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json index 7e1e8c4a09b..e18865057e0 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json index 94311f3b253..7e25f818498 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index 6da90af3d7e..a6be0ade2dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index 4a18e4886a4..0696186a6e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index 5c787df186c..d13615eec12 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json index acf5b472feb..01a4e6653a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json index 078f9fe8e31..df5d5d250f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json index 7fd9e35724f..305146f389c 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json index 1843db87010..e24ad921b59 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json index f2dc1a2d403..66307350eb4 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json index 5b5b41e2a47..598cd5fc4c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json index 13ba431e560..4d8d9bf633e 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json index b6c4508f84d..621e62ca93e 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json index 7383da5ffef..696fb9cd7a5 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json index 7b13a40b7b7..a146ff099ed 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json index be4567b6a6b..e62bf1da0f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json index d8d0c13c1c5..22672a7875a 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json index d09c2ba7257..e0c6bbdb4e0 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json index 588acd53d37..52b56c00f38 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json index 56980471b50..7af8c05f111 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json index 6e79740501f..a1dc3d613c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json index e09d7e1bece..dda7d8fddfa 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json index b27a3940077..ba541ff3aac 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json index a7cc077a298..18f9ccca79c 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json index 7ed1116c59b..f590354fb05 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json index 031c1d9bb26..91be4949224 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json index 50372b9d6df..650d06369c6 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json index 69b01080481..f63b0c9a8e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json index ab58a7da199..6e501679ec9 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json index 1cc3d71efcb..72800533d30 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index 054510d1684..fb1ef4e5de4 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index 43883704b78..b67f83d5e20 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index 39fb4f33744..9453088040c 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index 502a257adc1..4e211524f7b 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index ee374ba2b5b..4e22605638b 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index b6238a38dd6..e4b2f4650a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index 5edf9297691..6a091816bef 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index e8ae358e69f..443180a31d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index eb11b29a5f0..28719884e06 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index 60814b5852a..5f6a2793759 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index b65e39dade0..44be0eb215d 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index bedc0068d89..fdeb036e025 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index 368671f56b4..96f4ee2b443 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index 86d3f97631b..ca0fc55ffb2 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index 9fcd864013d..52b45e40336 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index bba497b965d..3e3a46aec41 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index e93d69e443f..c8debdf7a91 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index 12b2bb93ba4..3f02fbf5e23 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index 8e93ed265c9..ba7eb3dc199 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index a68caa0c65f..631d5c32574 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index 97c2da5393a..feb07d6b8df 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index d58f857f6ef..db2a3292918 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index c9410da2e46..34aa0587a2e 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index f9596f02843..512b437a183 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index f0fec51ab58..5a2740c5f72 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index d30f3c135c3..0e801224b8d 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index e9cb83c1aa6..21f2b3894b5 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json index c1e5552f718..57718ea57cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json index d31162e2e58..bf0077f3ca3 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json index dbbecc3c341..04bc0a4b9e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json index ddd012e08ae..b6e5023c1a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json index 7f91621772e..c7ddd727d56 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json index bb0d509288d..e8378a0c680 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json index 2e5c27863ae..09869ca3467 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json index f1a6502fe3c..b2473a35b6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json index f8744f2373e..9c7b0c2f96f 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json index 3c10217d224..95e53a76992 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json index 3b22abeb489..6e52c8c8b82 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json index 508254099b7..c5b7cbbbdb2 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json index e5d73e9bc26..6c3cf5b3d0e 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json index 41c96bd6654..490fb26c58e 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json index 456d70d5229..71f67c6409a 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json index 523b59649fa..88f5ad10373 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json index 4a78ba9941a..d79468633c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json index d6a9f8c3864..fd1bab06b8f 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json index ba902c136a6..839b41be4d0 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json index 44601d301fa..36b021ab4ab 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json index b2e62af8e68..39c85bc9bfb 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json index 5883cc11186..1b926be5a1b 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json index df44dd68281..ab060747113 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json index 9c887a694fc..2b56242e51d 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json index 7367d26f3fd..7ccf450ac81 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json index 60dc9f50a9c..8d79cc1be81 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json index ac517ac038d..dcb001dfdca 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "a28912c9abb97a227904723ff0de8162de31fce66c056da1813e0c18f6e01ccf", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json index 612641fa841..49a6a611f0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "ccd2ef7c617d13bdf5987f5f89fed6917206205268633b9ef061b92c3580d672", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json index 12ed0fe3cfb..dfbdf9a5b81 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "399fa8b85d9c2fc341ea2284a54aed278fd1a5b19a84cc9284c29d5a583bc519", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json index 0863b06ec7a..51e845b8fb3 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "14931cc23cd0e6d850f596c880014c606834898acbeee4abff3dc83a94b0c6c0", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index 847ca2faa3d..592d6869809 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json index fecaf04d061..c07dd8a13f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json @@ -3,10 +3,10 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "042d0f9ef57e2a18bf661b79f2f8f92a12125dbc0fc65dd8605f8cd6f7059d10", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json index 65a07363a94..d8096f4b193 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json @@ -3,10 +3,10 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "530aa1f2ddc6fce10d485c8a160ae3e695250b2e80e1e7fc3e8dacb9f5117347", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json index d253fdfc31a..c66b8114928 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json @@ -3,10 +3,10 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "18b1855f354c23cd5bb7af0db698f0a23d28208c3c6b7347f667bf6cd3ed612f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json index 161e4777c0b..3505c9e128f 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json @@ -3,10 +3,10 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "03355bc2696d02fed125d9f0e24c6c26c8df2f3709c1c5f4412aaf9317cd41d4", "platform": "darwin", "scenarioVersion": 1, @@ -131,6 +131,32 @@ } } }, + "232ca274f695": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "bundle": { + "current": { + "expiresAt": 1767830400000, + "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", + "token": "pending00000000000000000000000000000000001x", + "version": 4 + }, + "deviceToken": "device-token-1", + "hostId": "host-1", + "v": 1 + }, + "relay": { + "assignmentEpoch": 1, + "cellUrl": "https://cell.example", + "directorUrl": "https://director.example", + "e2eeFraming": 2, + "relayHostId": "relay-host-0001x", + "v": 1 + } + } + }, "240d8ec7e573": { "name": "pairing.getEndpoints#2", "ordinal": 5, @@ -307,53 +333,6 @@ } } }, - "590b3311b0c4": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "bundle": { - "current": { - "expiresAt": 1767830400000, - "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", - "token": "pending00000000000000000000000000000000001x", - "version": 4 - }, - "deviceToken": "device-token-1", - "hostId": "host-1", - "v": 1 - }, - "host": { - "deviceToken": "device-token-1", - "endpoint": "ws://192.168.1.10:8765", - "endpoints": [ - { - "id": "direct-primary", - "kind": "lan", - "url": "ws://192.168.1.10:8765" - }, - { - "id": "relay-primary", - "kind": "relay", - "url": "wss://cell.example/v1/connect/relay-host-0001x" - } - ], - "id": "host-1", - "lastConnected": 1767225600000, - "name": "Fixture host", - "publicKeyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - "relay": { - "assignmentEpoch": 1, - "cellUrl": "https://cell.example", - "directorUrl": "https://director.example", - "e2eeFraming": 2, - "relayHostId": "relay-host-0001x", - "v": 1 - }, - "relayHostId": "relay-host-0001x" - } - } - }, "691df8767d71": { "name": "journal-cleared", "ordinal": 9, @@ -678,7 +657,7 @@ "sender": ["6e14239ef339", "2fce658b56a1", "240d8ec7e573"], "payloads": ["c5d28dd067e4", "f41ffc93936f", "10098087f961"], "settlements": { - "upgrade": "590b3311b0c4" + "upgrade": "232ca274f695" }, "state": "980bbba0b617", "effects": ["aaef09eec3f3", "161baaed4df3", "691df8767d71"] diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json index 9abf285105f..6cb47b865f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json @@ -3,10 +3,10 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "d0c4dd34645308f30c0999ea74c16b53f20e9183832fdf016c3dc21434744b05", "platform": "darwin", "scenarioVersion": 1, @@ -66,6 +66,32 @@ "ordinal": 8, "value": "host-1" }, + "232ca274f695": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "bundle": { + "current": { + "expiresAt": 1767830400000, + "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", + "token": "pending00000000000000000000000000000000001x", + "version": 4 + }, + "deviceToken": "device-token-1", + "hostId": "host-1", + "v": 1 + }, + "relay": { + "assignmentEpoch": 1, + "cellUrl": "https://cell.example", + "directorUrl": "https://director.example", + "e2eeFraming": 2, + "relayHostId": "relay-host-0001x", + "v": 1 + } + } + }, "240d8ec7e573": { "name": "pairing.getEndpoints#2", "ordinal": 5, @@ -183,53 +209,6 @@ "journal": "present", "outcome": "failed: refused: outer refused" }, - "590b3311b0c4": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "bundle": { - "current": { - "expiresAt": 1767830400000, - "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", - "token": "pending00000000000000000000000000000000001x", - "version": 4 - }, - "deviceToken": "device-token-1", - "hostId": "host-1", - "v": 1 - }, - "host": { - "deviceToken": "device-token-1", - "endpoint": "ws://192.168.1.10:8765", - "endpoints": [ - { - "id": "direct-primary", - "kind": "lan", - "url": "ws://192.168.1.10:8765" - }, - { - "id": "relay-primary", - "kind": "relay", - "url": "wss://cell.example/v1/connect/relay-host-0001x" - } - ], - "id": "host-1", - "lastConnected": 1767225600000, - "name": "Fixture host", - "publicKeyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - "relay": { - "assignmentEpoch": 1, - "cellUrl": "https://cell.example", - "directorUrl": "https://director.example", - "e2eeFraming": 2, - "relayHostId": "relay-host-0001x", - "v": 1 - }, - "relayHostId": "relay-host-0001x" - } - } - }, "5d4b5d435fef": { "name": "pairing.getEndpoints#2", "ordinal": 5, @@ -673,7 +652,7 @@ "sender": ["6e14239ef339", "2fce658b56a1", "240d8ec7e573"], "payloads": ["c5d28dd067e4", "f41ffc93936f", "10098087f961"], "settlements": { - "upgrade": "590b3311b0c4" + "upgrade": "232ca274f695" }, "state": "980bbba0b617", "effects": ["aaef09eec3f3", "161baaed4df3", "691df8767d71"] diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json index 61a9dfa4be8..a8f9076a9ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json @@ -3,10 +3,10 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "0fe163f405373adbb1913dddd79d6d596bf88d69fc27c824ba5a2cd4c1406446", "platform": "darwin", "scenarioVersion": 1, @@ -101,6 +101,32 @@ } } }, + "232ca274f695": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "bundle": { + "current": { + "expiresAt": 1767830400000, + "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", + "token": "pending00000000000000000000000000000000001x", + "version": 4 + }, + "deviceToken": "device-token-1", + "hostId": "host-1", + "v": 1 + }, + "relay": { + "assignmentEpoch": 1, + "cellUrl": "https://cell.example", + "directorUrl": "https://director.example", + "e2eeFraming": 2, + "relayHostId": "relay-host-0001x", + "v": 1 + } + } + }, "240d8ec7e573": { "name": "pairing.getEndpoints#2", "ordinal": 5, @@ -208,53 +234,6 @@ "journal": "present", "outcome": "failed: refused: outer refused" }, - "590b3311b0c4": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "bundle": { - "current": { - "expiresAt": 1767830400000, - "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", - "token": "pending00000000000000000000000000000000001x", - "version": 4 - }, - "deviceToken": "device-token-1", - "hostId": "host-1", - "v": 1 - }, - "host": { - "deviceToken": "device-token-1", - "endpoint": "ws://192.168.1.10:8765", - "endpoints": [ - { - "id": "direct-primary", - "kind": "lan", - "url": "ws://192.168.1.10:8765" - }, - { - "id": "relay-primary", - "kind": "relay", - "url": "wss://cell.example/v1/connect/relay-host-0001x" - } - ], - "id": "host-1", - "lastConnected": 1767225600000, - "name": "Fixture host", - "publicKeyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - "relay": { - "assignmentEpoch": 1, - "cellUrl": "https://cell.example", - "directorUrl": "https://director.example", - "e2eeFraming": 2, - "relayHostId": "relay-host-0001x", - "v": 1 - }, - "relayHostId": "relay-host-0001x" - } - } - }, "65dfa1d22ba8": { "name": "pairing.provisionRelay#1", "ordinal": 3, @@ -688,7 +667,7 @@ "sender": ["6e14239ef339", "2fce658b56a1", "240d8ec7e573"], "payloads": ["c5d28dd067e4", "f41ffc93936f", "10098087f961"], "settlements": { - "upgrade": "590b3311b0c4" + "upgrade": "232ca274f695" }, "state": "980bbba0b617", "effects": ["aaef09eec3f3", "161baaed4df3", "691df8767d71"] diff --git a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json index 4184575793d..2926d88b6b0 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json @@ -3,10 +3,10 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "5aaa104a652d4f10cd48ab742112cf59fca22f52bbf85ee3716505a9642fbe37", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json index 3fb7066b1ba..14aaf5dd8fb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json index b978b65c12a..40a81667389 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json index e959bf5466d..471cc050388 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json index 191cddf8ffc..c1310d46987 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json index 545543cd356..9b65e28a357 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index d2a320ab621..a8d0336c035 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index 92a4c5e58fb..fac141996ad 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json index 773bf5107a0..fc99ff6b7a5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index f695faa4449..8491d188f41 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json index 373de909b87..5163ed413bb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json index fccca9f7de7..a847db45637 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json index 9515e1c5145..f0d0364cc5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json index f2683d727ff..d47a77b59a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json index 6fee790d44a..66e43ddce11 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json index 7989c55cfcb..341e65611c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json index edcb8b8d52b..052de7a011e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json index d91c73248de..649f363a848 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index 48bcc36a679..0eb3ced6a70 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index 58703598899..23344c275bb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index 712b6ad786c..f9ad70e2d04 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json index 37d4e005086..52ae3864556 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index f1f6fa81f3b..052a649ce97 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index fa26f91b6cf..084e04f02b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index b5611647006..78fc9460485 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json index 8860d8480a4..3dd0b230dc7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json index 3ffd233f6ce..2e32bd1fc69 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json index b282e8f5b4a..4ce3480e730 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json index ce66b9f2c1b..775a8510972 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json index 73a110d15a5..f8ba2492afb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json index 3e31e9adaae..f62f023686a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json index a9fc4d25fb5..3d35a99e397 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json index 38b6cc301be..1ad91eba964 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json index b795a983086..f76d26abf3f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json index bb38c3764fb..b93070adfb1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json index de2fbda4714..67d25521fe9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json index 08676f0295c..4ba9dfad544 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json index 730483d14e2..296e411bd14 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json index 8a41f46842b..6a6ca61066a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index 23c702cfa11..d9286c28002 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index 4427560b778..bedf832b5dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index 3348bf40f66..6b9542fcf32 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index 41d4ab5079a..69f3ad11916 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json index d2524f07f31..e63b89d8d52 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json index b0a486585f4..e9beddf0ee5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json index 96a6b6b8d52..f6b1dbf9a2f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json index 03f2cb2d95e..29894131a17 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json index f57cfa230dc..8e52e38b250 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json index 8279a3f952c..eb3d926ceac 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json index 4db6bf5c7fe..e5f867e9502 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json index 93c3cd2b28f..0877a240242 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index 1a040bd301b..8fbe10feef5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index 4554fc8f42f..a3e12ab3d38 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json index d4c4cbd0fa7..a7bd96c2b7e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json index 210d8b01658..1eb826ebf3b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json index 34c2bab0c93..903397c629a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json index 8eb2b7f9d13..6e332cb8689 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json index 8d8ba9535dc..881cf886aa8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json index 4c18fa07d1b..f960f787544 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json index 995f691e969..ba25cf37ec9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json index 72029e3612b..81503d53370 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json index 0c71ee0f1a7..d82c3f7778d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json index 58ef55fb4b1..71820264d8c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json index 5ceed2d1d74..4c904160153 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json index 31c6d4050a4..39dcf998602 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json index 6fdd6fbdc87..aa35f80342f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index 1b86ad3943a..424b42c40a5 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index 9be971f3bb2..ef180c07fa4 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index 47bc711c487..627eb962ff3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index a6e95e8218c..17fec1f983e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index 74831189ccf..102e659877d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index 16d7e3c6bd8..f2d021ce8f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index c1496f2eb01..c29bde2ed10 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index 44d3a3338a4..1bf68fab7ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index e5faa8e3ff9..cb23b2f5444 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index 4f6cf8b81a3..b872771c5db 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index 79a4ce0e465..10fc651943f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json index 21d05df46a9..52314874f24 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json index 8ec504f3e31..306c2eff808 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 224e6fe248d..c1a6b310105 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index fb899e2c3c9..c1e431ef833 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index e0fc1b68b5a..c2233430f47 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index 25790fd5fd5..e9101f46427 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index 6a059e8497a..4f17c3cbd48 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index 44b35ca55c2..1470afdce29 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index 1e8564b721c..8b2f8cd12c5 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index fa3a9bfe32d..f6deaee7619 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index 6b52b0c7d3c..0c003776096 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index e54820026eb..cb15881ed12 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index 45f693f7c9e..c42a5d4c511 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index 6659e042761..bcad45da786 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index 43e113af2be..ab64681e87d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index 5f70685c7b4..6dc04eb7ab6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index ebeaceb8a2b..7e5ff2d5b6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 4d881062645..98056689ba3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index ec0a6c855e7..1524e3cca30 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index a83bef1544e..98810a06cc9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index dc006d1d4ac..2628f651847 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index b060c392256..428812d4a58 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index cc1be720110..ae91d586227 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index 977ef457b24..82b8a29cf05 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json index bbdad42c0bd..b57ff2e399e 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json index 9934d939973..d136a003498 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json index 4b9304e018b..0bf4b8c2db0 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json index 0cdac41c7b7..e7e64ccc20d 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json index 25d20f45724..67ddeb4a940 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json index 04758eeaeea..81f1eb4cb0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json index bdcf7a20acd..e1f8d9e0c90 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json index cf97cac1fa9..c13c38b7da4 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json index cd06b5fecf0..b663a089e76 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json index bbf3dbd9ce0..4f65cb80a32 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json index f012772a853..1edf63e5e2f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json index f9f256f620f..91c3565c208 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json index eda4db077d1..fb386c94f72 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json index cb750bfad21..4c686549dd8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json index 30487de344b..5587d0e23f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json index ff4e387ee66..7528c063dcb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json index 171b0f67880..8a65c136228 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json index 1ad23b00f34..0f52c825d91 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json index 7c429394baa..672e796bdb9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json index 652667fe8df..ce7ffcbb1cf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json index 6bffa599566..9fd4cf59947 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json index 80941a520e0..806c28c1dc3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json index a63f55006ca..4e20eb50b48 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json index 8864e2bab3b..6b0b99c45fd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json index da47a7c322f..dc9584ceea7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json index fbcb5f118ef..b80d2847254 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json index faf8ec093b7..2bc0d7a7bca 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json index a28c8366700..566c4d97836 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json index fa3aa120fd9..9668343cc73 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json index d7cb3751cc6..b09a3a33306 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json index fd352bb21b5..47f0eab1820 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json index 96ce7f747dd..e15a12d4464 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json index efaf467a1c4..434f9c7e53a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json index 7ed2a3c36ca..91fe97db45c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json index 5e4f5169aa9..df318b399fe 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json index a4698564cdc..195d356fe42 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json index 94fdf74e916..bf504f7235d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json index 0ea32461fe3..0a259c1c539 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json index c339b0dcb33..88999336d6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json index e63c965f73e..90c562a2d2f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json index 2aa5ca0ef0d..547e6ca2930 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json index 874de723ad0..026a6d93756 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json index fb587853659..d116a214167 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json index da25973c6e7..06466d0a16b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json index ad1cc23c8de..079bad0a6ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json index f222d36e114..c1abb5846b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json index 2522ec3e752..9b9312c7965 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json index 47d4449c925..86eca3a0720 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json index a2d26d09c2e..3eafed93c8a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json index 865fe2ffd82..61b5453a181 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json index 2c3e0048b7c..81c2791cb64 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json index 2089edd0646..d2eef17be22 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json index 0e5bf5d2d30..c86f9f77cc5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json index 2d7d1253203..8cb6d048ed9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json index 8a478d57b7e..fab7f9a74ae 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json index e1c135b5699..105046bdee8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json index c7aa345f79f..a6d1e7d928f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json index 776905dd36b..e36fb8ef5ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json index 3126d7cd00c..f328515ab3e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json index 3d87fbdd783..042e19ba4da 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json index bd821ab36f5..45eb4b10e04 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json index bbdf26c3274..001d5d70a18 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json index 3ca293ee003..e5ee08270ac 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json index cb93a77cd96..78bc91dddd0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json index 2dda0bcac16..459d6469b84 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json index 9115c20437e..addd4680c2d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json index 04980c004f5..535f47a005e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json index c1bc6d52029..1210fd1978c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json index 9c7fa793d2a..0bdf0bedd92 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json index b0f04b290a9..6b8d269c517 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json index 5286d9a2aa1..04ef2305118 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json index aa3e4855046..b36102f02e5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json index e30179c1b83..ab82fbdf6b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json index 512bf6ce5d2..b8ff80fa8ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json index d7d246999be..67cd78b32dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json index 89f41930554..8684667f6e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index 33b19046f6d..128daba6571 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index 13121a83b9e..5b93ba7f8e4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index 6526189721d..729f958556f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index 79b255a765e..98a85b69c71 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index c974d5b6d73..9a0bfb80af6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json index 4cc6a77f830..6aa7e0529d3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json index 69fd50bfc0b..307542ab09b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json index caee5c7f76a..9755ac2a4ec 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json index 0b9880bd620..20abbb3fafe 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json index 8050558ebe2..dea90b5cbca 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json index 7694916b374..90d2e1ccee7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json index 587b666decd..5b6e2c2b8e6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json index f65a9a26c21..36dfc6b780c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json index 8b0e79deb4e..f9935ddd8ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json index 85517be9404..477371db448 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json index ef2cc678670..3a55325a76b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json index c41a4fe7b95..4a51345999d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json index 6335e2aff47..cb413f2b607 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json index 66e476f087f..e499888b594 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json index 294a195f836..c89ee7d9d24 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json index c505118727b..3b83b0e1da9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json index abf2962dddc..f8db871100d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json index 13bcbc2eb54..03ea809e2d4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index 87d9d11bdef..c2e59e9677f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json index 852361bf234..2bea4909183 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json index 157eef6cfb7..5fcbf069493 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json index 643e113e568..22c7a877ace 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json index fdf3c6d4ad2..3066f670d95 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json index 4702dc97c62..35cc0012757 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json index 2dc9a7dba4b..e501f96e59e 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json index 501b09d9d7b..05f0a84b225 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json index b7da174f32c..1e56cd81200 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json index 26e15478b56..3d5b7675a92 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json index 55f9ec52889..273970d36d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json index f244af8b9f8..d146554c978 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json index 8c4977f8aea..8ecf6b92a66 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json index f39d62c8cb0..2fd2ffbb50b 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json index 5ea2618400d..14a79fc1d50 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json index e6bddacee23..8bae81837da 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index 3f32a0049e9..7b6928f2a76 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index 0cee6befadb..67498f2d2ba 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json index bae64b3a31e..8e47d77c2bd 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json index e018de3b878..97bb15e8c5b 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json index 64d78f2c898..a3e2515a70a 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index d60f9f72820..dc96a010c46 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index 26761df7f21..7a5e9ac19ab 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json index b8aa1892281..7b8b9b7565c 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json index 69c4bbeb59b..15859b82106 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json index 5d340f8a2a5..d5b93bd71ba 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json index 4d1da7ef0e3..d3fb08c02a0 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json index 68da2307c37..78c270775f6 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json index 1d60b52f022..3c26b7d093a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json index 6f722e2c318..819bbd40ae2 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json index 5fcf7cc218c..a19f9027e4d 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json index 8f9ce577c28..7591427d87e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json index b4914c61149..7622f2cbe04 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json index 7aaca693862..b787a43c6b6 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json index eee6721a769..cb38b4dbbf4 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json index aa5478d0006..f11163afb62 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json index 7d1b36fbe43..63fbe211e87 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index 969ff83d8fd..4d8d37e4c25 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json index 71c4a461a9b..a8b0c62a30a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index 7a44b4715d6..8fbd0929be6 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json index a0b760ff9c0..61488a13e7e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json index e1ffeeb993a..0978e6b1feb 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json index 1d0c59bcad9..7b32b8b21b2 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json index ccbe9516f4a..449963a0c8e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index f1855e504df..51984600786 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index f4b36618919..631a86cf4a8 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index 66479ff78fe..d723f9c75d7 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index 4814f0e3d4b..15beb5e9994 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json index 17ee41e2c1c..5bd7e4375b9 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json index 9f772f5943c..0d55a9a3ddc 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index 1a176ad2fa7..2a0e4664054 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json index 5f5b1141417..76eff66efbf 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index 6c9ee1faa0f..f24172fce7b 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index 40aaf1a7bc3..ea83eda06d3 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index da64762c8d5..1fd713b3038 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index f64542e54bc..981e35a9ff8 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index 1a9e88d20f6..b108203fb0c 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index 9c943e3a70b..592791899f4 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json index 2cdc76ede29..625e22e02e6 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json index 2b4cf8bf85e..85a58f01d39 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json index c402372c5aa..b09c99df8d2 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index 7c56b965997..06d4e112770 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index 75fba0a646f..36342d7c935 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json index 8933cc65fbe..4d3e2495c8c 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "e4fb7aa3b071c1207f206adcc0f31e92b6bb98a80f86a0772e48b5cebcab24ff", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json index db89c034e0d..c87f6e0699f 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "5696ea2a62bb3f24902305f8bac0c4ae8f1e505f359db76fd69aabe353adae86", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json index 73c1f4ca8c8..a5984d7d569 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -3,10 +3,10 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "9ab72231bf97fbe7eed232019c56568a9411433835eae424628af62c7c6a10c1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index 5f93e074480..4a0ad095ce8 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index 65c65daff9c..40d92f2ab07 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index 2925ec5d3e4..fdd18807443 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index f4fb77dfdd2..771ad40c43a 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json index 696ad71ea2d..219377950c9 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 81f712c865f..2ccb360ac7d 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index 8a6dcedddc5..66322555362 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index 35f24e04b63..703f26a5e13 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index 128dbebe3a3..106361e4ff5 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index 0215c70d997..9ef1aa51b27 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index b81c1fc3b9a..b543ba96494 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index c6d08140fe7..161f7930fa1 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index 448a0dbfb99..b4e6cff961b 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json index 1c7fc365779..fdcf3765394 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json +++ b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index b84c3b1e60d..537c0e85aa3 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json index 8eec5ec397c..3a517fbf461 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json +++ b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 6dc74856c02..69a99df1eb3 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index ab24c7e4745..cdd857540b6 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index e4b3155bb50..03597c5372e 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index febd43b12ce..791819fbddc 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index 977a6ca81d8..83036b13499 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index 4c60fda3475..16af2331366 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json index 64811dd1626..c4a3c593c94 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json index dd4ffd3c57b..f3aad7bb5a4 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json +++ b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index 8f763e01ea6..93b6a6c9dc7 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -3,10 +3,10 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "a92bccd183127829b6dfd85add28e42370d54990f63214940b1c584f7fde56a9", "platform": "darwin", "scenarioVersion": 1, @@ -23,6 +23,32 @@ "ordinal": 8, "value": "host-1" }, + "232ca274f695": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "bundle": { + "current": { + "expiresAt": 1767830400000, + "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", + "token": "pending00000000000000000000000000000000001x", + "version": 4 + }, + "deviceToken": "device-token-1", + "hostId": "host-1", + "v": 1 + }, + "relay": { + "assignmentEpoch": 1, + "cellUrl": "https://cell.example", + "directorUrl": "https://director.example", + "e2eeFraming": 2, + "relayHostId": "relay-host-0001x", + "v": 1 + } + } + }, "240d8ec7e573": { "name": "pairing.getEndpoints#2", "ordinal": 5, @@ -116,53 +142,6 @@ } } }, - "590b3311b0c4": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "bundle": { - "current": { - "expiresAt": 1767830400000, - "hash": "7ehR_WuQWkxwOFJdkHfI5jEg4DeVYWImtDuip60LHK4", - "token": "pending00000000000000000000000000000000001x", - "version": 4 - }, - "deviceToken": "device-token-1", - "hostId": "host-1", - "v": 1 - }, - "host": { - "deviceToken": "device-token-1", - "endpoint": "ws://192.168.1.10:8765", - "endpoints": [ - { - "id": "direct-primary", - "kind": "lan", - "url": "ws://192.168.1.10:8765" - }, - { - "id": "relay-primary", - "kind": "relay", - "url": "wss://cell.example/v1/connect/relay-host-0001x" - } - ], - "id": "host-1", - "lastConnected": 1767225600000, - "name": "Fixture host", - "publicKeyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - "relay": { - "assignmentEpoch": 1, - "cellUrl": "https://cell.example", - "directorUrl": "https://director.example", - "e2eeFraming": 2, - "relayHostId": "relay-host-0001x", - "v": 1 - }, - "relayHostId": "relay-host-0001x" - } - } - }, "691df8767d71": { "name": "journal-cleared", "ordinal": 9, @@ -248,7 +227,7 @@ "sender": ["6e14239ef339", "2fce658b56a1", "240d8ec7e573"], "payloads": ["c5d28dd067e4", "f41ffc93936f", "10098087f961"], "settlements": { - "upgrade": "590b3311b0c4" + "upgrade": "232ca274f695" }, "state": "980bbba0b617", "effects": ["aaef09eec3f3", "161baaed4df3", "691df8767d71"] diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json index 98f5ce13728..76f917d8293 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json @@ -3,10 +3,10 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "7d5cad367e76767b039fc5ebc15837930f02c5e1bed33ae7ca4695bae56287bd", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json index d6b3824bf30..2e919e297f0 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -3,10 +3,10 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "8dc3fd27c5720276608ca8743990e4f57d94eab84f620e9aa42702a4686fd5a9", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json index 8bef0cd8c5c..834c367a057 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -3,10 +3,10 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "e33d584229530c716ecdc44d198b95fcfb4dfd9468fba7d5222ee3f122950197", + "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "7e54c3af4b8b8e6eac007267fb96620283b735491883c505401c79656d920ca6", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json index c72ddb96667..44f5ef5d11f 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -3,10 +3,10 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "c786fb19f9593ee60238e42813561edf6f5e976fcf217dff8de977a333fe8451", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json index 37b89127caa..ba932154341 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -3,10 +3,10 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", - "adapterSha256": "651e75383caf1b30c329dec2d5d4f0da5358c410402d03cbb087f39600d7a4d2", + "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "96df784c1d56de3bdd04afe20ab019339c7d9a616528ca2617e2fffe4f0157c8", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index 14c91caf5ab..4290bcfdba3 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json index 459bd85ffd2..b7707ad5fd5 100644 --- a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index d7cb7b6c362..e4194123e1f 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index e6cd41ea394..ec2ee81e68a 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index d4651db8a02..8c09baa9fff 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index 8d4810e2ed5..f9e616bcab2 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index 99af3421734..d3c7f3b3300 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index 86fadc0c11a..4b80caec7c9 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index 2df87b5e6bf..2a75f8b0cb2 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index f3495bf340e..ab20c3dbc56 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index ea6be585eda..a12ee0aad84 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index 782589ece49..00a64b4802b 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index dea28ff8f3a..3d310ab8b80 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index 4bd8b13caf8..32407af777c 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index aa16a192cfd..09a1b90fdf6 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index 743726c466c..86cc36f05f6 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index c81cc99c9f0..7ff59a3d959 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index b9307957882..92fe5944390 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 02dfe0e525c..3b64151c55b 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index adcd6c17ead..0e13433fc22 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index c40965a7d28..51f8a0c20f4 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index 5d92f7e0edc..b1d00507731 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json index 9a353202bc1..451e4b41a4b 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index ef34c349947..1c87c3aedbe 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index 3809e363ca3..522891bce56 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index f4887f73863..7ce45d7f714 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index fdf228ace86..493ecbc4ef5 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index 49e625b3e11..3347d9709e6 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index 72bc6a1c43d..c17a15f2be4 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index eab530014cd..bbfe575d693 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index 2fafba76e50..6e81cbc02ac 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index 15ab4728e2e..61b62c268df 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index 963a5a009c8..277b096d819 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index 357090c75b8..ef619dd7c91 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index cfb9808535d..89bd5cf43a9 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index 1171b1f1abb..11c2223667f 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index b03376ee631..d06a2b81f98 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index e62d3553486..225c5f8c6bb 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index b1a955f8063..75fc0bc8163 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 7e1609c6dfd..6dcbdfc417d 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index f7b9c640533..d3ff348fbbe 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index 4343c2e8765..4615be9ef4c 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index f1bd6b92d59..2cbfa79877f 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index c1078704ae2..249649a940e 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index fa51204de4f..e53fd61cad6 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index b0250ca2297..162449348c3 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 4d733fe616b..11c912847ad 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index 9c55ab71ca1..b152860d0f7 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index 4c87f468a63..3ad598bf809 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index 2d0befaa201..b3926c263e6 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index 42c9b4f886b..6a96cff79aa 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 709267c90da..91e6e5c7f9c 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index 209c539d92a..a34efacbaf8 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index f8aa7413a1d..74aa3b505c0 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index 3f8b7015078..1c49ff2f43a 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index ab0740b210a..c61a7764b01 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index 4b1ae82eb2b..bf22310c8cb 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json index 9a7f3f18608..50a47a05734 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index 785d27b4e12..d5855e0fffd 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index daa9842a039..fdd4bc82a7c 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index 17193952eb9..dbbfcd83bfa 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index 72fcac8f0d1..79edf50623a 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index e5710dcb14a..0e06f819650 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index ed09356a66a..2fc63a5a256 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index a314b2f9451..cc56d0cf94a 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index 6e97863751f..347d0680a0d 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index aa5ca98a446..571cfd1ef31 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json index 3f826d83b36..7fdd660bc28 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index bbadbe0e1f9..e73e9c09565 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index 9bc3289fe3e..58de71b1d24 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index 313188d3cd0..5c3d4f3fe18 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index dcf9df96485..78694a36ff8 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index 835d3de14b1..45439043dbe 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index 7f7dfa18a08..47477148d4e 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json index 6c197c7ae72..1cf678f2413 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index cd133f5da9f..cda4911f0cb 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index acaea8cba60..a4413ca0872 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json index 27c04907019..3822ac0a84a 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json index 089a8321263..53c90f2ae81 100644 --- a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json +++ b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json index f1b46371acb..92743968b15 100644 --- a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json +++ b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json index 55d929a8fdf..61503132255 100644 --- a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json +++ b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json index 8b3b11a237b..d6157441f56 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index ad6695ece1d..fd07d336255 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json index 72511ec075f..ea6a9a4a7e1 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json index c2c40b07c90..d63a2e300bf 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json index 5ea59e25e38..82e7213d8b8 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index 0b26a0ae1ea..e3725a5ce28 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index dbf49d7ac77..a376d3006b5 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index 65125105cc6..08158247ad1 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index 291d3d4e215..873b10fa9ed 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index b79bd82b0b2..f913c85984e 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index f4b1a5670bc..ed64c17b263 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index 38d721b3cc3..7e04ed1d340 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json index 8bc8b65c458..bae9383b322 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json index 22bfd8d776a..dbe4c4e7fdd 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json index 63a282211f4..8b8ad3bc1fa 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json index db36e60c1df..8b958d70a67 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json index 66a8d010a2f..45de20c77ce 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json index 43957207deb..3f993f999eb 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json index c3db575187b..ccc5afb8ee0 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json index a6112ca691e..4265e7188d5 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index 2ea340c9428..82d09225a16 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index cf309185363..4269b1b7857 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 491e0e9e18a..1c984c35c72 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index d1f7a90cfa8..921342520e4 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index 9cf30c8a118..96dd68d7f18 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index 700ea43d05e..61228b65873 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index c8ef39133d9..ce96cf093cf 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index 60693dd76b9..0d75d26cc5c 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index a66ce1e4ce4..a8fc051ec45 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index 81d5eeea762..3c37692974b 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index 3be0d46136a..4110eb31134 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index 559130bc477..8d773d7353d 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index e021d7ab450..9bfdd98381f 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index de8ac267bcd..7e84926a040 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index 14ceb1efc61..2fc87827a18 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 61052713a2a..e9d551b4e2a 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index 152f040c646..a542bc97107 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index e24e3d403ea..c84bcae68df 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 68f4f9837f4..308988d82a8 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 733a131e486..23f2d905f9c 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index 85145546469..300b8bd38f5 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index 9687e6e8324..acce2130571 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index 5d0556f977d..d54fa729c36 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index fdd98543b85..5d5a93826fe 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index 867bd2fddd8..a44f85a613f 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index a94d3bdf461..d822f3def93 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index 68339422657..4b7879a5b7a 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index 7c9fe7e82ad..7882c28fd75 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index 1cc5b75b615..30726bf8da2 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index b89795ab7b0..aef7cf226b6 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index fadef8b0f3a..987ad45fec5 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index 2ea1de712e5..fc8f1f45b8b 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index fe7920f39be..f7fdf4e7a37 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index a7b4c593585..f96bbbcbd67 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index 4afb1fd7562..9efe504aa9a 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index 9a3c3dafde7..a551c66c019 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index a79cfdb2cfe..9b4a806e12c 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index 57d13e2ee6e..89dbfae21cb 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index 90572ff87b0..315c35fa286 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 4cfed3477a7..0f3f283987d 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index 081d28486a0..85658a24529 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index 02586fc6443..095370f8d7f 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index 8891f4f0f1d..bfb9046559a 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index 4ee211b4c78..f44d5acafa8 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json index 921bf0847b0..41c7b139fb1 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index a0c31c84540..7a1ff3e67e4 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index e4c189745d6..ad401ae33b6 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index 5c29bb8fdb0..2e235e66637 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json index ef015ef0c71..6d7a84b6967 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index 892e08aea03..3a1a6c85ee3 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json index ac5f900e38e..9fa69b7ad10 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json index 0336e23fe27..79432b50a5b 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index c75c28edb7d..333bc2d9864 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index c20bcda3f33..e516187e248 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index 7d845d1838a..012234b0b7e 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json index 48649b36f2b..c5587972db0 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index 6213d2f9af7..cc740b81da7 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index eae1215c052..480c7bfcfa8 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index 316c610fad0..a18c0fed97d 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", diff --git a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json index 0decfef4021..e42eb977ed3 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index 537418d4329..72b1ba62971 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index 61aabf47aa3..c90abba07ea 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index ee247754bcd..e39932ed38f 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index bdf802d1d6c..e4189ade8ea 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index f17eaaef73d..0bbc3da2459 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index cadde16acb1..e523025d4ce 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index 774ea8fc606..e4ab35fa7ee 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index cea013dd156..6a2223d85e3 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index 94f5f1807ed..4f8cc1c6eb6 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index 466898ae45a..4e6257eb97c 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index e703b5a060d..dd762f743c0 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index 61c1bdae0cf..dd7b3900682 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json index ca11ed6f47c..0837bc71bf7 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index 1ddd8851344..d447ea548a5 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index 7c361f1d997..9e44f301562 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index 433649183a6..6c28c1a242e 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index c8e5bad1c8a..857c600e8e1 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index 59d8065cbfb..3ec53124ab3 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index 20745fbfc8c..af22a977705 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json index ac1dc62f924..94ae1971e65 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index be839f84ccc..97e8453c19d 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json index 26662179c5b..c1102c05a51 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index f60a340e6fa..3f1c693325c 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json index bd20b8e746f..c2c015534c3 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index 3434fb57e59..f203e5e426c 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 76728744a9b..69502f036f5 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index ced46d3f947..edf5030935c 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index c6f9670e7b7..5be02a07fc1 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index ae59f792565..5ad84c81948 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json index e339ebff4df..031509457ee 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index f14ba1b8359..0a8e93d9bd5 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index 5203e230522..eae52d4b240 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index efe812657fb..966b7a299b2 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json index 10276852439..bf50947d1ae 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index 1160bfd353b..f1e51ca70fb 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index a7e2566b06c..7e6217099be 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index 293d0fb682b..5b65e5813d9 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index 4854d7b8d3a..98d6aa2487e 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index b0429fdb595..9fed9423d6d 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 7033c1531de..7f2ace39852 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index 58f28a73fbd..c064f744d16 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index 782a83622ed..9ef180cc08f 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index 9de7692a92b..44d2975452e 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json index a48e89de1d9..1c78bdc3121 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json index 2f4b58e69b6..c658deda3dc 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index 0f205c2e16d..c4965b1fcab 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index cf0f54e2e68..fbf658fbabf 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json index d64ec18237b..dfd27044274 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json index 230a31e798c..a11b8975379 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json index 26463bebe14..7d64712a6e3 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index 915690c2dcc..cc902dc1484 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index d08f7425e7c..9eee68f75f1 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json index 2fa90b96689..d56f731f230 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json index ed3d571ae5d..9746648d883 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index 8d8deee35bf..53356530c19 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json index bb64ee9edf1..4fc1823c764 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json index 3324c401b7a..3e3b9e77399 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json index 8b746ad85b5..5048b46135b 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json index 816364f8ef6..c505e9a72d5 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json index 3852a6f8d7a..40ca469ae80 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json index b5306d491f0..329d8fd3db8 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json index e5676bddd33..00ba0637e55 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json index 6bf7b0f35a0..0f86d726fd3 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 74c468088d3..8b50cf561f2 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 9f85167d7e7..28ccb52750f 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index b271b649f95..eb700e30d2e 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json index 0c0fa3ab18a..f1e2c3f7986 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json index ed6fa5322e6..f32bce00b7a 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json index 56655ec2ab0..219bbe04291 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json index c2cf5aee5e2..8bfe1d0fbeb 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index d1c9253370a..ab7574ba726 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json index 354f4b117ff..1d3ee12c979 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json index a29c422369d..7559096ef7d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json index e4670e286b1..965dfc32451 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index fa09275fc5b..b43dbf986db 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json index 382dedf9932..58679803407 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index 35840154913..6fc5d731e2f 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json index ba65fd02c67..00ec2395088 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json index 1adbe084565..9cff92c1122 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json index 81c4db07b44..d3d9e67abe6 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json index bebda2cf8c5..d618c53bfec 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json index a37318e44c8..c6d4c97de04 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json index b051f020d85..a3013a38ca6 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json index 760f763c775..c31d03b21dc 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json index d2b49cb0d28..17b3b5d3d60 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json index 01a9541cf3a..9b3e0ed37ba 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index 1906fef1633..a0a1ea0d736 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json index b86f6d82138..1112ba2916e 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index f98c9a9d0c2..6c80428924e 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json index 5d14e70584b..d5dfbd46d96 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index 2ae1a4a8d37..8defc8c0387 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index 4efd8c3da1c..5bf629b53ff 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json index 9da6e290ff7..9df4dca2456 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index 623f82a5801..ccec33c897b 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index d8a2d5c78bf..457dbafc2bb 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index 3797ec591cf..4693a9969a3 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index c1c0964adda..04471c374fa 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index fb86117fba0..4c494bf5887 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "80e0bee23b8153f5db195a0d8c1db114f28bd336", + "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", "scenarios": [ { "id": "b1", diff --git a/mobile/src/components/MobileHostCard.test.ts b/mobile/src/components/MobileHostCard.test.ts index e29ab50bfab..9a5354e8bf7 100644 --- a/mobile/src/components/MobileHostCard.test.ts +++ b/mobile/src/components/MobileHostCard.test.ts @@ -342,7 +342,6 @@ describe('MobileHostCard', () => { deviceToken: 'token', publicKeyB64: 'key', lastConnected: 1, - relayHostId: 'AbCdEf0123_-xyZ9', relay: { v: 1 as const, directorUrl: 'https://relay-staging.onorca.dev', diff --git a/mobile/src/host-edit-route-accessibility.test.ts b/mobile/src/host-edit-route-accessibility.test.ts index d5fc20521a9..12d4ae230e1 100644 --- a/mobile/src/host-edit-route-accessibility.test.ts +++ b/mobile/src/host-edit-route-accessibility.test.ts @@ -5,7 +5,7 @@ import EditHostScreen from '../app/h/[hostId]/edit' const dependencies = vi.hoisted(() => ({ back: vi.fn(), - forceReconnectHost: vi.fn(), + refreshHostClient: vi.fn(), loadHosts: vi.fn(), primeHosts: vi.fn(), updateHostNameAndEndpoint: vi.fn() @@ -42,8 +42,8 @@ vi.mock('./transport/host-store', () => ({ })) vi.mock('./transport/client-context', () => ({ - useForceReconnect: () => dependencies.forceReconnectHost, - usePrimeHosts: () => dependencies.primeHosts + usePrimeHosts: () => dependencies.primeHosts, + useRefreshHostClient: () => dependencies.refreshHostClient })) async function renderEditHostRoute(): Promise { diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts index 835af6330f3..0c55da0cb0f 100644 --- a/mobile/src/host-edit-save-flow.test.ts +++ b/mobile/src/host-edit-save-flow.test.ts @@ -5,7 +5,7 @@ import EditHostScreen from '../app/h/[hostId]/edit' const dependencies = vi.hoisted(() => ({ back: vi.fn(), - forceReconnectHost: vi.fn(), + refreshHostClient: vi.fn(), loadHosts: vi.fn(), primeHosts: vi.fn(), updateHostNameAndEndpoint: vi.fn(), @@ -43,8 +43,8 @@ vi.mock('./transport/host-store', () => ({ })) vi.mock('./transport/client-context', () => ({ - useForceReconnect: () => dependencies.forceReconnectHost, - usePrimeHosts: () => dependencies.primeHosts + usePrimeHosts: () => dependencies.primeHosts, + useRefreshHostClient: () => dependencies.refreshHostClient })) const HOST_FIXTURE = { @@ -120,7 +120,7 @@ describe('edit host handleSave', () => { beforeEach(() => { dependencies.hostId = 'host-1' dependencies.back.mockReset() - dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.refreshHostClient.mockReset() dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) dependencies.primeHosts.mockReset() dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) @@ -143,7 +143,7 @@ describe('edit host handleSave', () => { expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { personalName: 'Home Desk' }) - expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.refreshHostClient).not.toHaveBeenCalled() expect(dependencies.back).toHaveBeenCalledTimes(1) act(() => renderer.unmount()) @@ -157,7 +157,7 @@ describe('edit host handleSave', () => { expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', { endpoint: 'ws://192.168.1.20:6768' }) - expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.refreshHostClient).toHaveBeenCalledWith('host-1') expect(dependencies.back).toHaveBeenCalledTimes(1) act(() => renderer.unmount()) @@ -174,7 +174,7 @@ describe('edit host handleSave', () => { personalName: 'Home Desk', endpoint: 'ws://192.168.1.20:6768' }) - expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') + expect(dependencies.refreshHostClient).toHaveBeenCalledWith('host-1') expect(dependencies.back).toHaveBeenCalledTimes(1) act(() => renderer.unmount()) @@ -185,7 +185,7 @@ describe('edit host handleSave', () => { await pressSave(renderer) expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled() - expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.refreshHostClient).not.toHaveBeenCalled() expect(dependencies.back).toHaveBeenCalledTimes(1) act(() => renderer.unmount()) @@ -198,7 +198,7 @@ describe('edit host handleSave', () => { await pressSave(renderer) expect(findText(renderer, 'Host not found')).toBe(true) - expect(dependencies.forceReconnectHost).not.toHaveBeenCalled() + expect(dependencies.refreshHostClient).not.toHaveBeenCalled() expect(dependencies.back).not.toHaveBeenCalled() act(() => renderer.unmount()) @@ -218,23 +218,6 @@ describe('edit host handleSave', () => { act(() => renderer.unmount()) }) - it('still navigates back and shows no error when the post-save reconnect rejects', async () => { - dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed')) - const renderer = await renderEditHostRoute() - setFieldValue(renderer, 'Address', '192.168.1.20:6768') - await pressSave(renderer) - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - - expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1') - expect(dependencies.back).toHaveBeenCalledTimes(1) - expect(findText(renderer, 'connect failed')).toBe(false) - - act(() => renderer.unmount()) - }) - it('ignores a second Save trigger while a save is already in flight', async () => { let resolveSave: () => void = () => {} dependencies.updateHostNameAndEndpoint.mockImplementationOnce( @@ -269,7 +252,7 @@ describe('edit host load() error states', () => { beforeEach(() => { dependencies.hostId = 'host-1' dependencies.back.mockReset() - dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined) + dependencies.refreshHostClient.mockReset() dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE]) dependencies.primeHosts.mockReset() dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined) diff --git a/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts index 64a6c8715b5..26cd6d0a0d3 100644 --- a/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts @@ -51,7 +51,7 @@ export function pairingJournalMountAdapters( effect('bundle-written', { version: written.current.version }) }, loadHosts: async () => [], - saveHost: async () => { + savePairedHost: async () => { effect('host-saved', HOST_ID) }, connectRelay: () => candidateClient(client, effect, 'relay'), @@ -95,8 +95,8 @@ export function pairingJournalMountAdapters( connectRelay: () => candidateClient(client, effect, 'relay'), resolveInviteDirector: async () => pairingRelay(), resolveHostIdentity: async () => ({ id: HOST_ID, name: 'Fixture host' }), - saveHost: async (host: { relayHostId?: string }) => { - savedHost = host.relayHostId ?? 'direct-only' + savePairedHost: async (host: { relay?: { relayHostId: string } }) => { + savedHost = host.relay?.relayHostId ?? 'direct-only' effect('host-saved', savedHost) }, saveJournal: async () => { diff --git a/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts index 6cf359e7fb4..75a11afadf9 100644 --- a/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts @@ -106,7 +106,7 @@ export function relayCredentialMountAdapters( writeBundle: async (written: { current: { version: number } }) => { effect('bundle-written', { version: written.current.version }) }, - saveHost: async () => { + setRelayRouting: async () => { effect('host-saved', HOST_ID) }, deleteBundle: async () => { @@ -116,7 +116,7 @@ export function relayCredentialMountAdapters( }) started.then( (result) => { - outcome = result === null ? 'declined' : result.host.relayHostId + outcome = result === null ? 'declined' : result.relay.relayHostId }, (error: unknown) => { outcome = `failed: ${error instanceof Error ? error.message : String(error)}` diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 0d65be5a63f..36e33600837 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -45,7 +45,9 @@ import { RpcClientProvider, useDisconnectHostClient, useForceReconnect, - useHostClient + useHostClient, + usePrimeHosts, + useRefreshHostClient } from './client-context' import { useAllHostClients } from './use-all-host-clients' import { useRelayRecoveryStatus } from './client-context-connection-metrics' @@ -415,6 +417,49 @@ describe('useHostClient', () => { } }) + it('refreshes a Relay-active owned client onto the saved row after an endpoint edit', async () => { + const relayClient = makeFakeClient('connected', 'relay') + const replacement = makeFakeClient('connecting', 'tailscale') + connectMock.mockReturnValueOnce(relayClient).mockReturnValueOnce(replacement) + loadHostsMock.mockResolvedValue([HOST]) + const edited = { ...HOST, endpoint: 'ws://100.101.102.103:6768' } + + const states: ConnectionState[] = [] + let refreshHostClient: ((hostId: string) => void) | null = null + let primeHosts: ((hosts: (typeof HOST)[]) => void) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + refreshHostClient = useRefreshHostClient() + primeHosts = usePrimeHosts() + states.push(useHostClient(HOST.id).state) + return null + } + + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + // A failed post-save re-prime leaves the pre-edit profile cached. + act(() => primeHosts?.([HOST])) + loadHostsMock.mockResolvedValue([edited]) + states.length = 0 + + await act(async () => { + refreshHostClient?.(HOST.id) + await Promise.resolve() + }) + + expect(relayClient.closeMock).toHaveBeenCalled() + expect(relayClient.notifyForeground).not.toHaveBeenCalled() + expect(connectMock.mock.calls[1]?.[0]).toEqual(edited) + // The owner reads amber through the rebuild, never a grey flash. + expect(states).not.toContain('disconnected') + } finally { + act(() => renderer?.unmount()) + } + }) + it('rebuilds a pairing-rejected Relay client so re-pairing credentials are re-read', async () => { const rejectedRelayClient = makeFakeClient('disconnected', 'relay') const replacement = makeFakeClient('connecting', 'tailscale') diff --git a/mobile/src/transport/host-catalog-credential-join.ts b/mobile/src/transport/host-catalog-credential-join.ts index e914742ff4e..229889b37b7 100644 --- a/mobile/src/transport/host-catalog-credential-join.ts +++ b/mobile/src/transport/host-catalog-credential-join.ts @@ -1,10 +1,10 @@ -import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import type { HostListSnapshot } from './host-list-load-sharing' import type { HostCatalogEntry, StoredHostProfile } from './types' export async function joinHostCatalogCredentials(args: { storedHosts: readonly StoredHostProfile[] - overlays: ReadonlyMap + relays: ReadonlyMap tokenCache: Map readToken: (hostId: string) => Promise getRevision: () => number @@ -31,17 +31,8 @@ export async function joinHostCatalogCredentials(args: { args.tokenCache.set(stored.id, token) } } - const overlay = args.overlays.get(stored.id) - const base = { - ...stored, - ...(overlay - ? { - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, - relay: overlay.relay - } - : {}) - } + const relay = args.relays.get(stored.id) + const base = { ...stored, ...(relay ? { relay } : {}) } const profile = token ? { ...base, deviceToken: token } : null catalog.push({ ...base, credentialStatus, profile }) if (profile) { diff --git a/mobile/src/transport/host-list-mutation-queue.ts b/mobile/src/transport/host-list-mutation-queue.ts index 5ed1a06f5ec..c695f362f3d 100644 --- a/mobile/src/transport/host-list-mutation-queue.ts +++ b/mobile/src/transport/host-list-mutation-queue.ts @@ -10,9 +10,12 @@ export function hostListMutationsSettled(): Promise { return hostListMutation } -export function enqueueHostListMutation(operation: () => Promise): Promise { +export function enqueueHostListMutation(operation: () => Promise): Promise { const mutation = hostListMutation.then(operation) - hostListMutation = mutation.catch(() => {}) + hostListMutation = mutation.then( + () => {}, + () => {} + ) return mutation } diff --git a/mobile/src/transport/host-logical-client.ts b/mobile/src/transport/host-logical-client.ts index 7682fc9591b..19d5e8d5ebc 100644 --- a/mobile/src/transport/host-logical-client.ts +++ b/mobile/src/transport/host-logical-client.ts @@ -10,7 +10,7 @@ export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSin // direct socket remains a replaceable first physical generation. const logical = createStableLogicalRpcClient( connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }), - directPathForEndpoint(host, host.endpoint) + directPathForEndpoint(host.endpoint) ) if (Platform.OS === 'web') { return logical diff --git a/mobile/src/transport/host-name-identity.test.ts b/mobile/src/transport/host-name-identity.test.ts index 8c67d86bdec..07277f7b90d 100644 --- a/mobile/src/transport/host-name-identity.test.ts +++ b/mobile/src/transport/host-name-identity.test.ts @@ -35,7 +35,7 @@ vi.mock('./host-credential-cleanup', () => ({ import { loadHosts, resetHostStoreForTests, - saveHost, + savePairedHost, updateHostDescriptor, updateHostNameAndEndpoint } from './host-store' @@ -176,7 +176,7 @@ describe('host name identity', () => { describe('re-pair', () => { it('preserves the override and descriptor a pairing save knows nothing about', async () => { await updateHostDescriptor(TYPED_HOST.id, { machineName: 'm4airs-Air', platform: 'darwin' }) - await saveHost({ + await savePairedHost({ id: TYPED_HOST.id, name: 'Windows-Low Spec', endpoint: 'ws://10.0.0.9:6768', @@ -198,7 +198,7 @@ describe('host name identity', () => { machineName: 'm4airs-Air', platform: 'darwin' }) - await saveHost({ + await savePairedHost({ id: GENERATED_HOST.id, name: 'Host 1', endpoint: GENERATED_HOST.endpoint, @@ -211,11 +211,11 @@ describe('host name identity', () => { it('does not roll back identity changed since a connection took its profile snapshot', async () => { await updateHostDescriptor(TYPED_HOST.id, { machineName: 'Studio', platform: 'darwin' }) - // A connection holds this for its lifetime and re-saves it on relay credential rotation. + // A connection holds this for its lifetime; a save built from it must not roll identity back. const snapshot = (await loadHosts()).find(({ id }) => id === TYPED_HOST.id)! await updateHostNameAndEndpoint(TYPED_HOST.id, { personalName: null }) await updateHostDescriptor(TYPED_HOST.id, { machineName: 'Studio 2', platform: 'darwin' }) - await saveHost({ ...snapshot, lastConnected: 99 }) + await savePairedHost({ ...snapshot, lastConnected: 99 }) const record = stored().find(({ id }) => id === TYPED_HOST.id) expect(record?.personalName).toBeUndefined() expect(record).toMatchObject({ diff --git a/mobile/src/transport/host-store-endpoint.test.ts b/mobile/src/transport/host-store-endpoint.test.ts index 8553f66e851..59035e0d2b0 100644 --- a/mobile/src/transport/host-store-endpoint.test.ts +++ b/mobile/src/transport/host-store-endpoint.test.ts @@ -1,6 +1,14 @@ import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { updateHostNameAndEndpoint } from './host-store' +import { + loadHosts, + RelayRoutingHostRemovedError, + resetHostStoreForTests, + setRelayRouting, + updateHostNameAndEndpoint +} from './host-store' +import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -104,3 +112,110 @@ describe('updateHostNameAndEndpoint', () => { expect(AsyncStorage.setItem).not.toHaveBeenCalled() }) }) + +describe('relay routing after a host edit', () => { + const OVERLAY_KEY = 'orca:mobile-relay:host-overlays:v2' + const OLD_ENDPOINT = 'ws://192.168.1.10:6768' + const NEW_ENDPOINT = 'ws://192.168.1.20:6768' + const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const + } + const moved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 } + const storage = new Map() + + function writesTo(key: string): unknown[] { + return vi.mocked(AsyncStorage.setItem).mock.calls.filter(([written]) => written === key) + } + + beforeEach(() => { + resetHostStoreForTests() + resetMobileRelayHostOverlayStoreForTests() + storage.clear() + storage.set( + 'orca:hosts', + JSON.stringify([ + { id: 'host-1', name: 'Desk', endpoint: OLD_ENDPOINT, publicKeyB64: 'pk', lastConnected: 1 } + ]) + ) + // An older build's record: a copy of the row's address beside the relay. + storage.set( + OVERLAY_KEY, + JSON.stringify([ + { + v: 2, + hostId: 'host-1', + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: OLD_ENDPOINT }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay + } + ]) + ) + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => storage.get(key) ?? null) + vi.mocked(AsyncStorage.setItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { + storage.set(key, value) + }) + vi.mocked(SecureStore.getItemAsync).mockResolvedValue('device-token') + vi.mocked(SecureStore.setItemAsync).mockReset() + }) + + it('loads only the edited address, whatever an older overlay stored', async () => { + await updateHostNameAndEndpoint('host-1', { endpoint: NEW_ENDPOINT }) + + const [host] = await loadHosts() + expect(host).toEqual({ + id: 'host-1', + name: 'Desk', + personalName: 'Desk', + endpoint: NEW_ENDPOINT, + publicKeyB64: 'pk', + lastConnected: 1, + deviceToken: 'device-token', + relay + }) + }) + + it('keeps an edited endpoint and the device token when relay routing is learned later', async () => { + await updateHostNameAndEndpoint('host-1', { personalName: 'Renamed', endpoint: NEW_ENDPOINT }) + const hostsAfterEdit = storage.get('orca:hosts') + + await setRelayRouting('host-1', moved) + + expect(storage.get('orca:hosts')).toBe(hostsAfterEdit) + expect(SecureStore.setItemAsync).not.toHaveBeenCalled() + const [host] = await loadHosts() + expect(host).toMatchObject({ name: 'Renamed', endpoint: NEW_ENDPOINT, relay: moved }) + }) + + it('does not rewrite storage when relay resolution repeats the stored routing', async () => { + await setRelayRouting('host-1', relay) + vi.mocked(AsyncStorage.setItem).mockClear() + + await setRelayRouting('host-1', relay) + + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + expect(SecureStore.setItemAsync).not.toHaveBeenCalled() + }) + + it('refuses to write routing for a removed host and leaves no overlay behind', async () => { + storage.set('orca:hosts', '[]') + storage.set(OVERLAY_KEY, '[]') + + await expect(setRelayRouting('host-1', relay)).rejects.toBeInstanceOf( + RelayRoutingHostRemovedError + ) + + expect(writesTo(OVERLAY_KEY)).toEqual([]) + expect(writesTo('orca:hosts')).toEqual([]) + await expect(loadHosts()).resolves.toEqual([]) + }) +}) diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index bb13e23dc4d..60269c01d5e 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import { + toStoredMobileRelayHostOverlay, + type MobileRelayHostOverlay +} from './mobile-relay-host-overlay' const asyncStorageMock = vi.hoisted(() => ({ getItem: vi.fn(), @@ -41,12 +44,12 @@ vi.mock('./host-credential-cleanup', () => ({ import { loadHostCatalog, loadHosts, - MobileRelayUpgradeHostRemovedError, + RelayRoutingHostRemovedError, removeHost, resolvePairingHostIdentity, resetHostStoreForTests, - saveHost, - saveExistingHostRelayUpgrade, + savePairedHost, + setRelayRouting, updateHostNameAndEndpoint, updateLastConnected } from './host-store' @@ -69,6 +72,14 @@ const HOST_TWO = { publicKeyB64: 'key-2', lastConnected: 0 } +const HOST_ONE_RELAY = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} const HOST_ONE_RELAY_BUNDLE = { v: 1 as const, hostId: HOST_ONE.id, @@ -175,7 +186,7 @@ describe('host-store list mutations', () => { }) it('keeps existing metadata when pairing a distinct host', async () => { - await saveHost({ + await savePairedHost({ id: 'host-new', name: 'Host 3', endpoint: 'ws://127.0.0.1:3', @@ -204,7 +215,7 @@ describe('host-store list mutations', () => { { ...HOST_TWO, id: 'host-duplicate', publicKeyB64: HOST_ONE.publicKeyB64 } ]) - await saveHost({ + await savePairedHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' @@ -227,7 +238,11 @@ describe('host-store list mutations', () => { secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed')) await expect( - saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + savePairedHost({ + ...HOST_ONE, + endpoint: 'ws://127.0.0.1:3', + deviceToken: 'replacement-token' + }) ).rejects.toThrow('keychain write failed') expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) @@ -246,7 +261,11 @@ describe('host-store list mutations', () => { asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) await expect( - saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + savePairedHost({ + ...HOST_ONE, + endpoint: 'ws://127.0.0.1:3', + deviceToken: 'replacement-token' + }) ).rejects.toThrow('metadata write failed') expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) @@ -264,7 +283,7 @@ describe('host-store list mutations', () => { } asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) - await expect(saveHost(replacement)).rejects.toThrow('metadata write failed') + await expect(savePairedHost(replacement)).rejects.toThrow('metadata write failed') expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id) @@ -281,10 +300,10 @@ describe('host-store list mutations', () => { publicKeyB64: HOST_ONE.publicKeyB64 } storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) - await saveHost({ ...HOST_ONE, deviceToken: 'token-a' }) + await savePairedHost({ ...HOST_ONE, deviceToken: 'token-a' }) const staleCleanup = scheduledCleanup(duplicate.id) - await saveHost({ ...duplicate, deviceToken: 'token-b' }) + await savePairedHost({ ...duplicate, deviceToken: 'token-b' }) await staleCleanup(duplicate.id) expect(JSON.parse(storedHostsRaw)).toEqual([duplicate]) @@ -390,26 +409,19 @@ describe('host-store list mutations', () => { url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' } ], - relayHostId: 'AbCdEf0123_-xyZ9', - relay: { - v: 1, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 - } + relayHostId: HOST_ONE_RELAY.relayHostId, + relay: HOST_ONE_RELAY } storedOverlayRaw = JSON.stringify([overlay]) - await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + await savePairedHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) expect(JSON.parse(storedOverlayRaw!)).toEqual([]) expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() }) it('does not touch relay storage when saving a new direct-only host', async () => { - await saveHost({ + await savePairedHost({ id: 'host-new', name: 'New Host', endpoint: 'ws://127.0.0.1:3', @@ -423,7 +435,7 @@ describe('host-store list mutations', () => { }) it('keeps the normal iOS save on the existing default keychain service', async () => { - await saveHost({ + await savePairedHost({ id: 'host-new', name: 'New Host', endpoint: 'ws://127.0.0.1:3', @@ -467,7 +479,7 @@ describe('host-store list mutations', () => { }) ) - const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + const save = savePairedHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce()) await removeHost(HOST_ONE.id) releaseTokenWrite() @@ -488,7 +500,7 @@ describe('host-store list mutations', () => { } recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable')) - await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable') + await expect(savePairedHost(replacement)).rejects.toThrow('intent storage unavailable') expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() @@ -518,15 +530,8 @@ describe('host-store list mutations', () => { url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' } ], - relayHostId: 'AbCdEf0123_-xyZ9', - relay: { - v: 1, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 - } + relayHostId: HOST_ONE_RELAY.relayHostId, + relay: HOST_ONE_RELAY } storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) let releaseCleanup: () => void = () => {} @@ -538,38 +543,32 @@ describe('host-store list mutations', () => { const hostsLoad = loadHosts() await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled()) - await saveHost({ + await savePairedHost({ ...HOST_ONE, id: 'removed-by-old-build', publicKeyB64: 'restored-key', deviceToken: 'restored-token', - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, relay: overlay.relay }) releaseCleanup() const hosts = await hostsLoad - expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, - relay: overlay.relay - }) + expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ relay: overlay.relay }) expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) - expect(JSON.parse(storedOverlayRaw!)).toContainEqual({ - ...overlay, - hostId: 'removed-by-old-build' - }) + expect(JSON.parse(storedOverlayRaw!)).toContainEqual( + toStoredMobileRelayHostOverlay('removed-by-old-build', HOST_ONE_RELAY) + ) }) it('refuses to resurrect a removed host during relay upgrade publication', async () => { storedHostsRaw = JSON.stringify([HOST_TWO]) - await expect( - saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' }) - ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + await expect(setRelayRouting(HOST_ONE.id, HOST_ONE_RELAY)).rejects.toBeInstanceOf( + RelayRoutingHostRemovedError + ) expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(storedOverlayRaw).toBeNull() expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() }) @@ -705,7 +704,7 @@ describe('host-store list mutations', () => { expect(hosts.find((host) => host.id === HOST_ONE.id)?.name).toBe('Living Room Mac') }) - it('does not share a host-list pass started before saveHost commits its token', async () => { + it('does not share a host-list pass started before savePairedHost commits its token', async () => { const newHost = { id: 'host-new', name: 'New Host', @@ -736,7 +735,7 @@ describe('host-store list mutations', () => { return key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null }) - const save = saveHost(newHost) + const save = savePairedHost(newHost) await vi.waitFor(() => { expect(secureStoreMock.setItemAsync).toHaveBeenCalled() }) @@ -784,7 +783,7 @@ describe('host-store list mutations', () => { await vi.waitFor(() => { expect(secureStoreMock.getItemAsync).toHaveBeenCalled() }) - await saveHost(newHost) + await savePairedHost(newHost) resolvePrewriteTokenRead('token-old') await parkedLoad @@ -876,7 +875,7 @@ describe('host-store pairing save after an Android encryption rejection', () => } ) - await expect(saveHost(NEW_HOST)).resolves.toBeUndefined() + await expect(savePairedHost(NEW_HOST)).resolves.toBeUndefined() expect(written.get('orca.pairing.v1')).toBe('device-token') expect(JSON.parse(storedHostsRaw)).toEqual([ @@ -893,7 +892,7 @@ describe('host-store pairing save after an Android encryption rejection', () => it('still surfaces the failure when no keystore alias can accept the token', async () => { secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) - await expect(saveHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION) + await expect(savePairedHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION) }) it('serves the rotated token to loadHosts so the saved host survives a relaunch', async () => { @@ -906,7 +905,7 @@ describe('host-store pairing save after an Android encryption rejection', () => written.set(options.keychainService, value) } ) - await saveHost(NEW_HOST) + await savePairedHost(NEW_HOST) // Why: a fresh process has no token cache, so the host list has to come back off the rotated alias. resetHostStoreForTests() secureStoreMock.getItemAsync.mockImplementation( diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 32f910cd27d..f34b3866d69 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,3 +1,4 @@ +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { HostProfileSchema } from './types' import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' @@ -13,10 +14,10 @@ import { scheduleHostCredentialCleanup } from './host-credential-cleanup' import { - loadMobileRelayHostOverlayState, - removeMobileRelayHostOverlay, - removeMobileRelayHostOverlays, - saveMobileRelayHostOverlay + loadMobileRelayHostRoutingState, + removeMobileRelayHostRouting, + removeMobileRelayHostRoutings, + saveMobileRelayHostRouting } from './mobile-relay-host-overlay-store' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' import { @@ -63,7 +64,7 @@ async function doLoadHostListSnapshot(): Promise if (!storedHosts) { return { catalog: [], profiles: [] } } - const overlayState = await loadMobileRelayHostOverlayState( + const overlayState = await loadMobileRelayHostRoutingState( new Set(storedHosts.map(({ id }) => id)) ) const orphanWriteRevisions = new Map( @@ -77,7 +78,7 @@ async function doLoadHostListSnapshot(): Promise }) return joinHostCatalogCredentials({ storedHosts, - overlays: overlayState.overlays, + relays: overlayState.relays, tokenCache, readToken: readHostDeviceToken, getRevision: hostListLoads.getHostListLoadRevision @@ -140,7 +141,7 @@ function removeOrphanOverlayIfUnpaired(hostId: string): Promise { return enqueueHostListMutation(async () => { const hosts = await readStoredHostProfilesForMutation() if (!hosts.some(({ id }) => id === hostId)) { - await removeMobileRelayHostOverlay(hostId) + await removeMobileRelayHostRouting(hostId) } }) } @@ -148,14 +149,28 @@ function removeOrphanOverlayIfUnpaired(hostId: string): Promise { // The page's host-store sibling keeps its own no-op, so only the native store reaches persistence. export { updateHostDescriptor } from './host-descriptor-persistence' -export class MobileRelayUpgradeHostRemovedError extends Error {} +export class RelayRoutingHostRemovedError extends Error {} -export const saveHost = (host: HostProfile): Promise => persistHost(host, false) +/** + * Relay routing learned after pairing (re-resolution, rotation, direct upgrade). Routing only; the + * row and token belong to pairing and Edit Host. + */ +export async function setRelayRouting(hostId: string, relay: MobileRelayEndpoint): Promise { + const wrote = await enqueueHostListMutation(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (!hosts.some(({ id }) => id === hostId)) { + // Why: an in-flight relay learner must not resurrect a host the user removed. + throw new RelayRoutingHostRemovedError('mobile relay host was removed') + } + return saveMobileRelayHostRouting(hostId, relay) + }) + if (wrote) { + hostListLoads.dropSharedHostListLoad() + } +} -export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise => - persistHost(host, true) - -async function persistHost(host: HostProfile, requireExisting: boolean): Promise { +/** Pairing only (a census fences the importers): it creates or re-pairs the whole host. */ +export async function savePairedHost(host: HostProfile): Promise { const validated = HostProfileSchema.parse(host) const stored = toStoredHostProfile(validated) const duplicateHostIds = new Set() @@ -179,9 +194,6 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise .map((candidate) => candidate.id === stored.id ? mergeHostNameIdentity(stored, candidate) : candidate ) - } else if (requireExisting) { - // Why: an in-flight relay upgrade must not resurrect a host the user removed. - throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') } else { next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] } @@ -217,23 +229,17 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } // Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative. cancelCleanupForStoredHost(stored.id) - if (validated.endpoints) { - await saveMobileRelayHostOverlay({ - v: 2, - hostId: stored.id, - endpoints: validated.endpoints, - relayHostId: validated.relayHostId, - relay: validated.relay - }) + if (validated.relay) { + await saveMobileRelayHostRouting(stored.id, validated.relay) hostListLoads.dropSharedHostListLoad() } const overlayRemovalIds = [...duplicateHostIds] - if (!validated.endpoints && updatedExistingHost) { + if (!validated.relay && updatedExistingHost) { overlayRemovalIds.push(stored.id) } if (overlayRemovalIds.length > 0) { // Why: reusing an id for direct-only re-pairing must not retain routing metadata from the previous transport state. - await removeMobileRelayHostOverlays(overlayRemovalIds) + await removeMobileRelayHostRoutings(overlayRemovalIds) hostListLoads.dropSharedHostListLoad() } for (const duplicateHostId of duplicateHostIds) { @@ -265,7 +271,7 @@ export async function removeHost(hostId: string): Promise { } tokenCache.delete(hostId) try { - await removeMobileRelayHostOverlay(hostId) + await removeMobileRelayHostRouting(hostId) hostListLoads.dropSharedHostListLoad() } catch { // Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry. diff --git a/mobile/src/transport/host-store.web.ts b/mobile/src/transport/host-store.web.ts index 8c9d54e588c..fdadca91b86 100644 --- a/mobile/src/transport/host-store.web.ts +++ b/mobile/src/transport/host-store.web.ts @@ -3,6 +3,7 @@ // this host up paints "Host not found" over the host the shell just opened it for. What crosses // instead is `init.host`: the profile the screens read, without the credential the bridge carries. import { readPageHostProfile } from '../mobile-web-shell/bridge/page-host-profile' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import type { HostCatalogEntry, HostProfile } from './types' /** @@ -25,8 +26,10 @@ export const loadHostCatalog = (): Promise => ) /** Pairing happened natively before this document existed, and the page never re-does it. */ -export const saveHost = (_host: HostProfile): Promise => Promise.resolve() -export const saveExistingHostRelayUpgrade = (_host: HostProfile): Promise => Promise.resolve() +export const savePairedHost = (_host: HostProfile): Promise => Promise.resolve() +/** Relay routing belongs to the native endpoint lifecycle, which the page does not run. */ +export const setRelayRouting = (_hostId: string, _relay: MobileRelayEndpoint): Promise => + Promise.resolve() export const removeHost = (_hostId: string): Promise => Promise.resolve() /** A native write the page drops: recency orders the app's host list, which the page does not show. */ diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts index 4049b4b073b..044c06e2e26 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from './rpc-client' -import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' -import type { ConnectionState, HostProfile, RpcResponse } from './types' +import { + directPathForEndpoint, + openAuthenticatedDirectEndpoint +} from './mobile-direct-endpoint-probe' +import type { ConnectionState, RpcResponse } from './types' class FakeClient implements RpcClient { readonly sendRequest = vi.fn(async (): Promise => ({ @@ -34,42 +37,14 @@ class FakeClient implements RpcClient { } } -const host: HostProfile = { - id: 'host-1', - name: 'Blue Whale', - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'device-token', - publicKeyB64: 'A'.repeat(44), - lastConnected: 1, - endpoints: [ - { id: 'lan', kind: 'lan', url: 'ws://192.168.1.10:6768' }, - { id: 'tailscale', kind: 'tailscale', url: 'ws://100.64.0.2:6768' } - ] -} - describe('mobile direct endpoint probe', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) - it('uses the first authenticated candidate without waiting for a stale primary', async () => { - const clients = new Map() - const openDirect = vi.fn((endpoint: string) => { - const client = new FakeClient('connecting') - clients.set(endpoint, client) - if (endpoint.includes('100.64.0.2')) { - setTimeout(() => client.publishState('connected'), 100) - } - return client - }) - - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) - await vi.advanceTimersByTimeAsync(100) - const result = await probing - - expect(result?.path).toBe('tailscale') - expect(openDirect).toHaveBeenCalledTimes(2) - expect(clients.get(host.endpoint)?.close).toHaveBeenCalledOnce() - expect(result?.client.close).not.toHaveBeenCalled() + it('classifies a tailnet endpoint apart from a LAN one', () => { + expect(directPathForEndpoint('ws://100.64.0.2:6768')).toBe('tailscale') + expect(directPathForEndpoint('wss://desk.tail1234.ts.net:6768')).toBe('tailscale') + expect(directPathForEndpoint('ws://192.168.1.10:6768')).toBe('lan') }) it('fails a whole dead LAN in seconds instead of holding the 12s bound', async () => { @@ -84,15 +59,13 @@ describe('mobile direct endpoint probe', () => { return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(20) await vi.advanceTimersByTimeAsync(2_000) await expect(probing).resolves.toBeNull() - expect(clients).toHaveLength(2) - for (const client of clients) { - expect(client.close).toHaveBeenCalledOnce() - } + expect(clients).toHaveLength(1) + expect(clients[0]!.close).toHaveBeenCalledOnce() // No 12s timer is left behind to fire into a settled probe. expect(vi.getTimerCount()).toBe(0) }) @@ -100,41 +73,37 @@ describe('mobile direct endpoint probe', () => { it('rides out one access-point flap that the first redial recovers', async () => { // 'reconnecting' is published on any socket close, so a single RST on the first // dial must not book a direct failure and its 60s cooldown. - const openDirect = vi.fn((endpoint: string) => { + const openDirect = vi.fn(() => { const client = new FakeClient('connecting') - if (endpoint.includes('100.64.0.2')) { - setTimeout(() => client.publishState('reconnecting'), 20) - setTimeout(() => client.publishState('connected'), 600) - } + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('connected'), 600) return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(600) const result = await probing - expect(result?.path).toBe('tailscale') - expect(result?.client.close).not.toHaveBeenCalled() + expect(result).not.toBeNull() + expect(result?.close).not.toHaveBeenCalled() }) it('extends the grace once when the redial reaches a handshake', async () => { // The redial fires at 500ms, but 'connected' waits on the Noise handshake and a // capability RPC, so real work needs more than one grace window. - const openDirect = vi.fn((endpoint: string) => { + const openDirect = vi.fn(() => { const client = new FakeClient('connecting') - if (endpoint.includes('100.64.0.2')) { - setTimeout(() => client.publishState('reconnecting'), 20) - setTimeout(() => client.publishState('handshaking'), 1_500) - // Past the first grace window: only the re-arm keeps this probe alive. - setTimeout(() => client.publishState('connected'), 3_000) - } + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('handshaking'), 1_500) + // Past the first grace window: only the re-arm keeps this probe alive. + setTimeout(() => client.publishState('connected'), 3_000) return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(3_000) - expect((await probing)?.path).toBe('tailscale') + expect(await probing).not.toBeNull() }) it('fails a handshake that stalls, one grace after it started', async () => { @@ -147,7 +116,7 @@ describe('mobile direct endpoint probe', () => { return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(3_499) let settled = false void probing.then(() => { @@ -168,7 +137,7 @@ describe('mobile direct endpoint probe', () => { return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(2_019) let settled = false void probing.then(() => { diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.ts b/mobile/src/transport/mobile-direct-endpoint-probe.ts index 038f73b93b8..58ce9c74a0a 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.ts @@ -1,21 +1,7 @@ import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' -import type { HostProfile } from './types' -function directEndpointUrls(host: HostProfile): string[] { - const endpoints = - host.endpoints?.filter(({ kind }) => kind !== 'relay').map(({ url }) => url) ?? [] - return [...new Set([host.endpoint, ...endpoints])] -} - -export function directPathForEndpoint( - host: HostProfile, - endpoint: string -): Exclude { - const configured = host.endpoints?.find((candidate) => candidate.url === endpoint) - if (configured?.kind === 'tailscale') { - return 'tailscale' - } +export function directPathForEndpoint(endpoint: string): Exclude { try { const hostname = new URL(endpoint).hostname if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) { @@ -98,62 +84,28 @@ function waitForAuthenticatedSession( } export async function openAuthenticatedDirectEndpoint( - host: HostProfile, - openDirect: (endpoint: string) => RpcClient, + openDirect: () => RpcClient, timeoutMs: number, signal?: AbortSignal -): Promise<{ client: RpcClient; path: Exclude } | null> { +): Promise { if (signal?.aborted) { return null } - const endpoints = directEndpointUrls(host) - return await new Promise((resolve) => { - const clients = new Set() - let remaining = endpoints.length - let settled = false - const rejectCandidate = (): void => { - remaining-- - if (!settled && remaining === 0) { - settled = true - resolve(null) - } - } - for (const endpoint of endpoints) { - let client: RpcClient - try { - client = openDirect(endpoint) - } catch { - rejectCandidate() - continue - } - clients.add(client) - void waitForAuthenticatedSession(client, timeoutMs, signal).then( - () => { - if (signal?.aborted) { - client.close() - rejectCandidate() - return - } - if (settled) { - client.close() - return - } - settled = true - for (const candidate of clients) { - if (candidate !== client) { - candidate.close() - } - } - resolve({ client, path: directPathForEndpoint(host, endpoint) }) - }, - () => { - if (settled) { - return - } - client.close() - rejectCandidate() - } - ) - } - }) + let client: RpcClient + try { + client = openDirect() + } catch { + return null + } + try { + await waitForAuthenticatedSession(client, timeoutMs, signal) + } catch { + client.close() + return null + } + if (signal?.aborted) { + client.close() + return null + } + return client } diff --git a/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts b/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts index 535790ed480..409863bb992 100644 --- a/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts +++ b/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts @@ -3,7 +3,8 @@ import { dependencies, FakeLogicalClient, FakeSession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import { createStableLogicalRpcClient } from './stable-logical-rpc-client' @@ -17,7 +18,7 @@ it('closes in-flight candidates and clears their timeout when the owner stops', const candidate = new FakeSession('connecting') const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies({ openDirect: vi.fn(() => candidate) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(15_000) expect(deps.openDirect).toHaveBeenCalledOnce() @@ -42,7 +43,7 @@ it('closes an authenticated candidate when stop races its completion', async () const candidate = new FakeSession('connecting') const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies({ openDirect: vi.fn(() => candidate) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(15_000) candidate.publishState('connected') @@ -63,7 +64,7 @@ it('preserves an in-flight probe across a transient background pause', async () const candidate = new FakeSession('connecting') const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies({ openDirect: vi.fn(() => candidate) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(15_000) supervisor.setForeground(false) @@ -79,54 +80,14 @@ it('preserves an in-flight probe across a transient background pause', async () } }) -it('releases every candidate when multiple endpoint probes are pending', async () => { - vi.useFakeTimers() - try { - const candidates: FakeSession[] = [] - const logical = new FakeLogicalClient('connected', 'relay') - const deps = dependencies({ - openDirect: vi.fn(() => { - const candidate = new FakeSession('connecting') - candidates.push(candidate) - return candidate - }) - }) - const supervisor = new MobileEndpointSupervisor( - logical, - { - ...host, - endpoints: [{ id: 'alternate', kind: 'tailscale', url: 'ws://100.64.0.2:6768' }] - }, - deps - ) - await supervisor.start() - await vi.advanceTimersByTimeAsync(15_000) - expect(candidates).toHaveLength(2) - supervisor.stop() - await vi.advanceTimersByTimeAsync(0) - expect(vi.getTimerCount()).toBe(0) - for (const candidate of candidates) { - expect(candidate.close).toHaveBeenCalledOnce() - candidate.publishState('connected') - } - await vi.advanceTimersByTimeAsync(60_000) - expect(logical.migrateTo).not.toHaveBeenCalled() - expect(deps.openDirect).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.restoreAllMocks() - vi.useRealTimers() - } -}) - it.each([false, true])( 'fences migration finishing after stop (already swapped: %s)', async (alreadySwapped) => { vi.useFakeTimers() try { const recordedMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration') - const relay = new FakeSession('connected') - const logical = createStableLogicalRpcClient(relay, 'relay') + const relaySession = new FakeSession('connected') + const logical = createStableLogicalRpcClient(relaySession, 'relay') const candidates: FakeSession[] = [] const deps = dependencies({ openDirect: vi.fn(() => { @@ -135,7 +96,7 @@ it.each([false, true])( return candidate }) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const migrate = logical.migrateTo.bind(logical) let release!: () => void const pending = new Promise((resolve) => { @@ -153,7 +114,7 @@ it.each([false, true])( await supervisor.start() await vi.advanceTimersByTimeAsync(60_000) expect(migration).toHaveBeenCalledOnce() - const requestsBeforeStop = relay.sendRequest.mock.calls.length + const requestsBeforeStop = relaySession.sendRequest.mock.calls.length const candidateRequestsBeforeStop = candidates[3].sendRequest.mock.calls.length const migrationsBeforeStop = recordedMigration.mock.calls.length supervisor.stop() @@ -161,7 +122,7 @@ it.each([false, true])( await vi.advanceTimersByTimeAsync(0) expect(logical.getActivePath()).toBe(alreadySwapped ? 'lan' : 'relay') expect(logical.getGeneration()).toBe(alreadySwapped ? 2 : 1) - expect(relay.sendRequest).toHaveBeenCalledTimes(requestsBeforeStop) + expect(relaySession.sendRequest).toHaveBeenCalledTimes(requestsBeforeStop) expect(candidates[3].sendRequest).toHaveBeenCalledTimes(candidateRequestsBeforeStop) expect(recordedMigration).toHaveBeenCalledTimes(migrationsBeforeStop) expect(candidates[3].close).toHaveBeenCalledTimes(alreadySwapped ? 0 : 1) diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts index c3b3464a3ba..3552805f7ee 100644 --- a/mobile/src/transport/mobile-direct-return-probe.ts +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -2,7 +2,6 @@ import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import type { RpcClient } from './rpc-client' import type { ScheduleTimer } from './timer-scheduler' -import type { HostProfile } from './types' import type { MobileConnectionPath } from './stable-logical-rpc-client' const DIRECT_PROBE_INTERVAL_MS = 15_000 @@ -20,11 +19,11 @@ export class DirectReturnProbe { now: () => number setTimer: ScheduleTimer clearTimer: typeof clearTimeout - openDirect: (endpoint: string) => RpcClient + openDirect: () => RpcClient + directPath: Exclude }, private readonly hooks: { hysteresis: MobileEndpointHysteresis - host: () => HostProfile canSchedule: () => boolean canAttempt: () => boolean beginOperation: () => void @@ -75,7 +74,6 @@ export class DirectReturnProbe { let successful: Awaited> = null try { successful = await openAuthenticatedDirectEndpoint( - this.hooks.host(), this.deps.openDirect, 12_000, controller.signal @@ -88,14 +86,14 @@ export class DirectReturnProbe { return } if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) { - successful.client.close() + successful.close() return } const candidate = successful // Migration owns the candidate, including closing it if cutover is canceled. successful = null try { - await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped) + await this.hooks.migrate(candidate, this.deps.directPath, () => this.stopped) } catch (error) { if (this.stopped) { return @@ -109,7 +107,7 @@ export class DirectReturnProbe { await this.hooks.onDirectMigrated() } finally { this.activeProbe = null - successful?.client.close() + successful?.close() // Why: a relay drop or backoff timer can arrive while the probe owns the // operation mutex; afterProbe releases it and replays deferred recovery. this.hooks.afterProbe() diff --git a/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts b/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts new file mode 100644 index 00000000000..b676e979188 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' + +const storage = vi.hoisted(() => new Map()) +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key) + }) +})) +const secureStoreMock = vi.hoisted(() => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + getItemAsync: vi.fn(async () => 'device-token'), + setItemAsync: vi.fn(async () => {}), + deleteItemAsync: vi.fn(async () => {}) +})) +const connectMock = vi.hoisted(() => vi.fn()) +const openRelayMock = vi.hoisted(() => vi.fn()) +const resolveRelayMock = vi.hoisted(() => vi.fn()) +const readBundleMock = vi.hoisted(() => vi.fn()) +const writeBundleMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock })) +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +vi.mock('expo-secure-store', () => secureStoreMock) +vi.mock('./rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + connect: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('./mobile-relay-rpc-session', async (importOriginal) => ({ + ...(await importOriginal()), + connectMobileRelayRpcSession: (...args: unknown[]) => openRelayMock(...args) +})) +vi.mock('./mobile-relay-resume-director', async (importOriginal) => ({ + ...(await importOriginal()), + resolveMobileRelayEndpoint: (...args: unknown[]) => resolveRelayMock(...args) +})) +vi.mock('./mobile-relay-credential-bundle', async (importOriginal) => ({ + ...(await importOriginal()), + readMobileRelayCredentialBundle: (...args: unknown[]) => readBundleMock(...args), + writeMobileRelayCredentialBundle: (...args: unknown[]) => writeBundleMock(...args) +})) + +import { loadHosts, resetHostStoreForTests, updateHostNameAndEndpoint } from './host-store' +import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle' +import { + bundle, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host, + mockCredentialRotation, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' +import { createMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' +import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' + +const OVERLAY_KEY = 'orca:mobile-relay:host-overlays:v2' +const EDITED_ENDPOINT = 'ws://192.168.1.20:6768' +const resolved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 } + +// Starts a relay host whose first dial hits the wrong cell, leaving director resolution pending. +async function startWithPendingResolution(): Promise<{ + logical: FakeLogicalClient + lifecycle: ReturnType + settle: (value: MobileRelayEndpoint) => void +}> { + let settle: (value: MobileRelayEndpoint) => void = () => {} + resolveRelayMock.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }) + ) + openRelayMock + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409))) + .mockReturnValue(new FakeRelaySession('connected')) + const logical = new FakeLogicalClient('disconnected', 'lan') + const lifecycle = startMobileEndpointLifecycle(logical, host, () => {}) + await vi.waitFor(() => expect(resolveRelayMock).toHaveBeenCalledOnce()) + return { logical, lifecycle, settle: (value) => settle(value) } +} + +function overlayWrites(): number { + return asyncStorageMock.setItem.mock.calls.filter(([key]) => key === OVERLAY_KEY).length +} + +async function expectEditKept(expectedRelay: MobileRelayEndpoint): Promise { + const [saved] = await loadHosts() + expect(saved).toMatchObject({ + name: 'Renamed', + endpoint: EDITED_ENDPOINT, + deviceToken: 'device-token', + relay: expectedRelay + }) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() +} + +describe('mobile endpoint lifecycle host edits', () => { + beforeEach(() => { + vi.clearAllMocks() + storage.clear() + resetHostStoreForTests() + resetMobileRelayHostOverlayStoreForTests() + const { id, name, endpoint, publicKeyB64, lastConnected } = host + storage.set('orca:hosts', JSON.stringify([{ id, name, endpoint, publicKeyB64, lastConnected }])) + storage.set( + OVERLAY_KEY, + JSON.stringify([ + { + v: 2, + hostId: id, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: endpoint }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay + } + ]) + ) + connectMock.mockImplementation(() => new FakeSession('disconnected')) + readBundleMock.mockResolvedValue(bundle) + }) + + it('keeps an edit made while relay resolution was pending', async () => { + const { lifecycle, settle } = await startWithPendingResolution() + + await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) + settle(resolved) + await vi.waitFor(() => expect(openRelayMock).toHaveBeenCalledTimes(2)) + + await expectEditKept(resolved) + lifecycle.stop() + }) + + it('does not persist a resolution that settles after stop', async () => { + const { logical, lifecycle, settle } = await startWithPendingResolution() + await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) + const writesBeforeSettle = asyncStorageMock.setItem.mock.calls.length + + lifecycle.stop() + const recoveryPathCalls = logical.setRecoveryPath.mock.calls.length + settle(resolved) + // The withdrawn dial clears the recovery path only after any persistence has run. + await vi.waitFor(() => + expect(logical.setRecoveryPath).toHaveBeenCalledTimes(recoveryPathCalls + 1) + ) + + expect(asyncStorageMock.setItem).toHaveBeenCalledTimes(writesBeforeSettle) + await expectEditKept(relay) + expect(openRelayMock).toHaveBeenCalledOnce() + }) + + it('keeps an edit made before a credential rotation publishes its relay', async () => { + readBundleMock.mockResolvedValue({ + ...bundle, + current: { ...bundle.current, expiresAt: Date.now() + 60_000 } + }) + const logical = new FakeLogicalClient('connected', 'lan') + mockCredentialRotation(logical) + const lifecycle = startMobileEndpointLifecycle(logical, host, () => {}) + await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) + + logical.publishState('connected') + await vi.waitFor(() => expect(overlayWrites()).toBe(1)) + + await expectEditKept(relay) + lifecycle.stop() + }) + + it('does not persist relay routing from a rotation that finishes after stop', async () => { + readBundleMock.mockResolvedValue({ + ...bundle, + current: { ...bundle.current, expiresAt: Date.now() + 60_000 } + }) + let finishCredentialWrite: () => void = () => {} + writeBundleMock.mockResolvedValueOnce().mockReturnValueOnce( + new Promise((resolve) => { + finishCredentialWrite = resolve + }) + ) + const logical = new FakeLogicalClient('connected', 'lan') + mockCredentialRotation(logical) + const lifecycle = startMobileEndpointLifecycle(logical, host, () => {}) + await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) + + logical.publishState('connected') + await vi.waitFor(() => expect(writeBundleMock).toHaveBeenCalledTimes(2)) + lifecycle.stop() + finishCredentialWrite() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The rotated bundle itself stays durable; only the stale relay routing write is skipped. + expect(writeBundleMock).toHaveBeenCalledTimes(2) + expect(overlayWrites()).toBe(0) + await expectEditKept(relay) + }) + + it('keeps an edit made while a direct-only host was being upgraded to relay', async () => { + storage.set(OVERLAY_KEY, '[]') + const { relay: _relay, ...directHost } = host + const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(3) + ) + const installed = { + v: 1, + reqId: journal.reqId, + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: 9_999_999 + } + const client = new FakeSession('connected') + client.sendRequest.mockResolvedValue({ + id: 'rpc', + ok: true, + result: { + v: 1, + relay, + installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: installed } + }, + _meta: { runtimeId: 'runtime-1' } + }) + await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) + + // `directHost` is the controller's pre-edit snapshot; only the default routing writer runs. + await upgradeDirectMobileRelay({ + client, + host: directHost, + dependencies: { + readJournal: async () => journal, + clearJournal: async () => {}, + writeBundle: async () => {} + } + }) + + await expectEditKept(relay) + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index b7cab59c49a..3f727d4e731 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -1,5 +1,6 @@ import * as ExpoCrypto from 'expo-crypto' import type { ConnectionLogSink, ForegroundNudgeReason, HostProfile } from './types' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { connect } from './rpc-client' import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' @@ -8,8 +9,9 @@ import { readMobileRelayCredentialBundle, writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' -import { saveHost } from './host-store' +import { setRelayRouting } from './host-store' import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' +import { directPathForEndpoint } from './mobile-direct-endpoint-probe' import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -33,11 +35,11 @@ export function startMobileEndpointLifecycle( let foreground = true let owner: EndpointOwner - const startSupervisor = async (host: HostProfile): Promise => { + const startSupervisor = async (relay: MobileRelayEndpoint): Promise => { if (stopped) { return } - const supervisor = createSupervisor(logical, host, onLog) + const supervisor = createSupervisor(logical, initialHost, relay, onLog) owner.stop() owner = supervisor supervisor.setForeground(foreground) @@ -45,7 +47,7 @@ export function startMobileEndpointLifecycle( } if (initialHost.relay) { - owner = createSupervisor(logical, initialHost, onLog) + owner = createSupervisor(logical, initialHost, initialHost.relay, onLog) void owner.start() } else { owner = new MobileRelayDirectUpgradeController(logical, initialHost, { @@ -55,7 +57,7 @@ export function startMobileEndpointLifecycle( host, dependencies: { randomBytes: ExpoCrypto.getRandomBytes } }), - onUpgraded: ({ host }) => startSupervisor(host) + onUpgraded: ({ relay }) => startSupervisor(relay) }) void owner.start() } @@ -83,10 +85,12 @@ export function startMobileEndpointLifecycle( function createSupervisor( logical: StableLogicalRpcClient, host: HostProfile, + relay: MobileRelayEndpoint, onLog: ConnectionLogSink ): MobileEndpointSupervisor { - return new MobileEndpointSupervisor(logical, host, { - openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + return new MobileEndpointSupervisor(logical, host.id, relay, { + openDirect: () => connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + directPath: directPathForEndpoint(host.endpoint), openRelay: (relay, credential, confirmReqId, onHostCloseReason) => connectMobileRelayRpcSession({ relay, @@ -101,7 +105,7 @@ function createSupervisor( resolveRelay: resolveMobileRelayEndpoint, readBundle: readMobileRelayCredentialBundle, writeBundle: writeMobileRelayCredentialBundle, - saveHost, + setRelayRouting, onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index 247c2ec051e..81620c54ece 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -4,11 +4,13 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ScheduleTimer } from './timer-scheduler' -import type { ConnectionLogSink, HostProfile } from './types' +import type { ConnectionLogSink } from './types' export type MobileEndpointSupervisorDependencies = { - openDirect: (endpoint: string) => RpcClient + openDirect: () => RpcClient + directPath: Exclude openRelay: ( relay: MobileRelayEndpoint, credential: { token: string; version: number }, @@ -18,7 +20,7 @@ export type MobileEndpointSupervisorDependencies = { resolveRelay: typeof resolveMobileRelayEndpoint readBundle: (hostId: string) => Promise writeBundle: (bundle: MobileRelayCredentialBundle) => Promise - saveHost: (host: HostProfile) => Promise + setRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise now: () => number randomBytes: (length: number) => Uint8Array setTimer: ScheduleTimer diff --git a/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts index 3ee52fc7ddf..d0ca76d0dcc 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts @@ -5,7 +5,8 @@ import { FakeLogicalClient, FakeRelaySession, FakeSession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) @@ -27,7 +28,7 @@ describe('mobile endpoint supervisor direct probe', () => { const direct = new FakeSession('connecting') const openRelay = vi.fn(() => new FakeRelaySession('connected')) const deps = dependencies({ openDirect: vi.fn(() => direct), openRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() // Foreground return: the probe dials direct at once, the dead LAN answers with diff --git a/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts index 9db4ea3b9f7..86ea50e5c45 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts @@ -6,7 +6,8 @@ import { FakeLogicalClient, FakeRelaySession, FakeSession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher' @@ -33,7 +34,7 @@ describe('mobile endpoint supervisor nudges', () => { const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('disconnected')) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(deps.openRelay).toHaveBeenCalledOnce() @@ -62,7 +63,7 @@ describe('mobile endpoint supervisor nudges', () => { // Deterministic full jitter: fraction 0.5 → half the backoff window. randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(openRelay).toHaveBeenCalledOnce() @@ -100,7 +101,7 @@ describe('mobile endpoint supervisor nudges', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -119,7 +120,7 @@ describe('mobile endpoint supervisor nudges', () => { it('leaves physical relay probing to the session watchdog', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.nudge('focus') @@ -138,7 +139,7 @@ describe('mobile endpoint supervisor nudges', () => { it('does not suspend a relay from one focus RPC failure', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.nudge('focus') @@ -164,7 +165,7 @@ describe('mobile endpoint supervisor nudges', () => { }) ) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(deps.openRelay).toHaveBeenCalledOnce() @@ -195,7 +196,7 @@ describe('mobile endpoint supervisor nudges', () => { readBundle: vi.fn(async () => expired), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.nudge('network-change') @@ -221,9 +222,11 @@ describe('mobile endpoint supervisor nudges', () => { writeBundle: vi.fn(async () => {}), isActive: () => true, isForeground: () => true, - relay: () => host.relay, + isStopped: () => false, + hostId: host.id, + relay, resolveRelay: vi.fn(async ({ relay: endpoint }) => endpoint), - persistResolvedRelay: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), bundle: () => bundle, adoptBundle: vi.fn(), recordMigration: vi.fn(), @@ -260,9 +263,11 @@ describe('mobile endpoint supervisor nudges', () => { writeBundle: vi.fn(async () => {}), isActive: () => active, isForeground: () => active, - relay: () => host.relay, + isStopped: () => false, + hostId: host.id, + relay, resolveRelay: vi.fn(async ({ relay: endpoint }) => endpoint), - persistResolvedRelay: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), bundle: () => bundle, adoptBundle: vi.fn(), recordMigration: vi.fn(), diff --git a/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts index be4050cee5b..b0d567ace28 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts @@ -43,7 +43,8 @@ describe('relay dial against a cell that took the dial and stalled', () => { const onLog = vi.fn() const supervisor = new MobileEndpointSupervisor( logical, - host, + host.id, + relay, dependencies({ openRelay, resolveRelay, onLog }) ) @@ -67,7 +68,8 @@ describe('relay dial against a cell that took the dial and stalled', () => { const resolveRelay = vi.fn(async () => relay) const supervisor = new MobileEndpointSupervisor( logical, - host, + host.id, + relay, dependencies({ openRelay, resolveRelay }) ) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts index 1a2c00f12de..e4294f56fcd 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-support.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -3,7 +3,6 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { ReplacementAuthenticationTimeoutError } from './replacement-session-authentication' import type { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { HostProfile } from './types' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' // Why: a suspect session that survived a failed replacement dial must come down, @@ -41,10 +40,10 @@ export class RelayDialAbortedError extends Error { // cell assignment, persist it durably, then dial once more. export async function dialRelayThroughDirectorFallback(args: { resumeToken: string - relay: () => HostProfile['relay'] + relay: () => MobileRelayEndpoint dial: () => Promise resolveRelay: (input: { - relay: NonNullable + relay: MobileRelayEndpoint resumeToken: string }) => Promise persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise @@ -54,8 +53,7 @@ export async function dialRelayThroughDirectorFallback(args: { if ( first.ok || first.error instanceof RelayDialAbortedError || - !isDirectorResolutionFailure(first.error) || - !relay + !isDirectorResolutionFailure(first.error) ) { return first } @@ -80,27 +78,6 @@ export function isDirectorResolutionFailure(error: Error): boolean { ) } -export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string { - const url = new URL(relay.cellUrl) - url.protocol = 'wss:' - url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` - return url.toString() -} - -export async function persistRelayHost( - host: HostProfile, - relay: MobileRelayEndpoint, - saveHost: (host: HostProfile) => Promise -): Promise { - const endpoints = [ - ...(host.endpoints ?? [{ id: 'direct-primary', kind: 'lan' as const, url: host.endpoint }]) - ].filter(({ kind }) => kind !== 'relay') - endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }) - const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay } - await saveHost(updated) - return updated -} - export function encodeBase64Url(value: Uint8Array): string { let binary = '' for (const byte of value) { diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 3507127b2a8..70bf1f3aae2 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -187,11 +187,6 @@ export const host: HostProfile = { deviceToken: 'device-token', publicKeyB64: 'A'.repeat(44), lastConnected: 1, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, - { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } - ], - relayHostId: relay.relayHostId, relay } export const bundle: MobileRelayCredentialBundle = { @@ -215,7 +210,8 @@ export function dependencies( resolveRelay: vi.fn(async ({ relay }) => relay), readBundle: vi.fn(async () => bundle), writeBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), + directPath: 'lan', now: Date.now, randomBytes: (length) => new Uint8Array(length).fill(1), setTimer: defaultScheduleTimer, diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 10ef892a479..367a7867a34 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -31,7 +31,7 @@ describe('mobile endpoint supervisor', () => { it('fails over to a confirmed relay session and persists its renewed expiry', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -51,7 +51,7 @@ describe('mobile endpoint supervisor', () => { it('fails over when the direct retry loop publishes reconnecting', async () => { const logical = new FakeLogicalClient('connecting', 'lan') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('handshaking') @@ -77,7 +77,7 @@ describe('mobile endpoint supervisor', () => { it('fails over when direct is already reconnecting before startup completes', async () => { const logical = new FakeLogicalClient('reconnecting', 'lan') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -102,7 +102,7 @@ describe('mobile endpoint supervisor', () => { .mockResolvedValueOnce(null) .mockReturnValueOnce(credentialReadPending) const deps = dependencies({ readBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const starting = supervisor.start() await vi.waitFor(() => expect(readBundle).toHaveBeenCalledTimes(2)) @@ -120,7 +120,7 @@ describe('mobile endpoint supervisor', () => { const logical = new FakeLogicalClient('reconnecting', 'lan') const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } } const deps = dependencies({ readBundle: vi.fn(async () => expired) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -136,7 +136,7 @@ describe('mobile endpoint supervisor', () => { openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -155,7 +155,7 @@ describe('mobile endpoint supervisor', () => { openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(openRelay).toHaveBeenCalledOnce() @@ -180,7 +180,7 @@ describe('mobile endpoint supervisor', () => { openRelay, resolveRelay: vi.fn(async () => resolved) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -191,16 +191,14 @@ describe('mobile endpoint supervisor', () => { expect.any(String), expect.any(Function) ) - expect(deps.saveHost).toHaveBeenCalledWith( - expect.objectContaining({ relay: resolved, endpoint: host.endpoint }) - ) + expect(deps.setRelayRouting).toHaveBeenCalledWith(host.id, resolved) supervisor.stop() }) it('promotes direct only after repeated foreground authenticated probes and dwell', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(45_000) @@ -219,7 +217,7 @@ describe('mobile endpoint supervisor', () => { openDirect: vi.fn(() => direct), openRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() // Start the probe, then drop the active relay while the probe owns the @@ -245,7 +243,7 @@ describe('mobile endpoint supervisor', () => { onLog, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('disconnected') @@ -270,7 +268,7 @@ describe('mobile endpoint supervisor', () => { const logical = new FakeLogicalClient('connected', 'relay') const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } } const deps = dependencies({ readBundle: vi.fn(async () => expired) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('disconnected') @@ -289,7 +287,7 @@ describe('mobile endpoint supervisor', () => { openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('disconnected') @@ -321,7 +319,7 @@ describe('mobile endpoint supervisor', () => { openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -350,7 +348,7 @@ describe('mobile endpoint supervisor', () => { openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(openRelay).toHaveBeenCalledTimes(2) @@ -383,7 +381,7 @@ describe('mobile endpoint supervisor', () => { ), writeBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() writeBundle.mockClear() @@ -409,7 +407,7 @@ describe('mobile endpoint supervisor', () => { openRelay, resolveRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -431,7 +429,7 @@ describe('mobile endpoint supervisor', () => { throw new Error('secure store unavailable') }) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(openRelay).toHaveBeenCalledOnce() @@ -447,7 +445,7 @@ describe('mobile endpoint supervisor', () => { openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(vi.getTimerCount()).toBe(1) @@ -472,7 +470,7 @@ describe('mobile endpoint supervisor', () => { writeBundle: vi.fn(() => writePending), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const starting = supervisor.start() await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce()) @@ -493,7 +491,7 @@ describe('mobile endpoint supervisor', () => { openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() @@ -512,7 +510,7 @@ describe('mobile endpoint supervisor', () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4401))) const deps = dependencies({ openRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(true) @@ -544,7 +542,7 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(credentialWritePending) mockCredentialRotation(logical) const deps = dependencies({ openRelay, writeBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('connected') @@ -594,7 +592,7 @@ describe('mobile endpoint supervisor', () => { openRelay, writeBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('connected') @@ -630,7 +628,7 @@ describe('mobile endpoint supervisor', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const starting = supervisor.start() await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce()) @@ -659,14 +657,14 @@ describe('mobile endpoint supervisor', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(60_000) await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce()) supervisor.setForeground(false) finishResolve?.(relay) - await vi.waitFor(() => expect(deps.saveHost).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(deps.setRelayRouting).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(0) expect(openRelay).toHaveBeenCalledTimes(2) @@ -685,7 +683,7 @@ describe('mobile endpoint supervisor', () => { finishWrite = resolve }) const deps = dependencies({ writeBundle: vi.fn(() => writePending) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const starting = supervisor.start() await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce()) @@ -706,7 +704,7 @@ describe('mobile endpoint supervisor', () => { openRelay, openDirect: vi.fn(() => new FakeSession('disconnected')) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(60_000) @@ -728,7 +726,7 @@ describe('mobile endpoint supervisor', () => { openRelay, openDirect: vi.fn(() => new FakeSession('disconnected')) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(openRelay).toHaveBeenCalledTimes(1) @@ -752,7 +750,7 @@ describe('mobile endpoint supervisor', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(60_000) @@ -778,7 +776,7 @@ describe('mobile endpoint supervisor', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(60_000) @@ -807,7 +805,7 @@ describe('mobile endpoint supervisor', () => { openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(900) @@ -828,7 +826,7 @@ describe('mobile endpoint supervisor', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() const afterStart = openRelay.mock.calls.length @@ -844,7 +842,7 @@ describe('mobile endpoint supervisor', () => { it('races a relay dial when the direct dial stalls unauthenticated', async () => { const logical = new FakeLogicalClient('connecting', 'lan') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(2_499) @@ -866,7 +864,7 @@ describe('mobile endpoint supervisor', () => { it('cancels the grace race when the direct dial authenticates first', async () => { const logical = new FakeLogicalClient('connecting', 'lan') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() logical.publishState('connected') @@ -878,23 +876,10 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) - it('never races a relay dial against a desktop with no relay endpoint', async () => { - const logical = new FakeLogicalClient('connecting', 'lan') - const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, { ...host, relay: undefined }, deps) - - await supervisor.start() - await vi.advanceTimersByTimeAsync(5_000) - - expect(deps.openRelay).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - supervisor.stop() - }) - it('drops the pending grace race when the phone backgrounds', async () => { const logical = new FakeLogicalClient('connecting', 'lan') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -909,7 +894,7 @@ describe('mobile endpoint supervisor', () => { const logical = new FakeLogicalClient('connecting', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(2_500) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 9ba12f35112..736e1427af8 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -5,7 +5,6 @@ import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import { liveRelayLeaseExpiry, - persistRelayHost, suspendRelayIfStillConnected } from './mobile-endpoint-supervisor-support' import { selectDialableRelayCredentials } from './mobile-relay-credential-selection' @@ -20,7 +19,8 @@ import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer' import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher' import * as recoveryPresentation from './mobile-relay-recovery-presentation' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { ForegroundNudgeReason, HostProfile } from './types' +import type { ForegroundNudgeReason } from './types' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace' import { logRelayConnected, @@ -54,7 +54,8 @@ export class MobileEndpointSupervisor { constructor( private readonly logical: StableLogicalRpcClient, - private host: HostProfile, + private readonly hostId: string, + relay: MobileRelayEndpoint, private readonly dependencies: MobileEndpointSupervisorDependencies ) { this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), { @@ -93,11 +94,11 @@ export class MobileEndpointSupervisor { writeBundle: dependencies.writeBundle, isActive: () => this.isActive(), isForeground: () => this.backgroundGrace.isForeground(), - relay: () => this.host.relay, + isStopped: () => this.stopped, + hostId, + relay, resolveRelay: dependencies.resolveRelay, - persistResolvedRelay: async (resolved) => { - this.host = await persistRelayHost(this.host, resolved, dependencies.saveHost) - }, + setRelayRouting: dependencies.setRelayRouting, bundle: () => this.bundle, adoptBundle: (bundle) => (this.bundle = bundle), recordMigration: () => { @@ -116,7 +117,6 @@ export class MobileEndpointSupervisor { }) this.directProbe = new DirectReturnProbe(dependencies, { hysteresis: this.hysteresis, - host: () => this.host, canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay', canAttempt: () => this.isActive() && !this.operationInFlight, beginOperation: () => (this.operationInFlight = true), @@ -148,8 +148,8 @@ export class MobileEndpointSupervisor { } async start(): Promise { - this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null) - if (this.stopped || !this.host.relay) { + this.bundle = await this.dependencies.readBundle(this.hostId).catch(() => null) + if (this.stopped) { return } if (!this.bundle) { @@ -211,7 +211,7 @@ export class MobileEndpointSupervisor { // shared cooldown and any session left stale-'connected' by a half-open socket // comes down; lease rotation clears it because armRetry owns its own retry. private async recoverRelay(forceReplacement = false, ownsRecovery = false): Promise { - if (!this.isActive() || !this.host.relay) { + if (!this.isActive()) { return } if (this.operationInFlight) { @@ -247,7 +247,7 @@ export class MobileEndpointSupervisor { const selection = await selectDialableRelayCredentials({ bundle: this.bundle, controller: this.relayReconnect, - readBundle: () => this.dependencies.readBundle(this.host.id), + readBundle: () => this.dependencies.readBundle(this.hostId), onAdoptedFresherBundle: () => this.logRelay('adopted fresher durable credential bundle') }) this.bundle = selection.bundle @@ -325,7 +325,7 @@ export class MobileEndpointSupervisor { this.bundle = result.bundle // Why: a scheduled rotation can finish after the old credential enters the rejection gate. credentialRefreshed = true - this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost) + await this.sessionEstablisher.adoptRelay(result.relay) } catch { // Why: pending material remains durable; the next authenticated direct // opportunity must reconcile it before creating another install key. diff --git a/mobile/src/transport/mobile-relay-background-lifecycle.test.ts b/mobile/src/transport/mobile-relay-background-lifecycle.test.ts index 76bca72426d..53e44f383b8 100644 --- a/mobile/src/transport/mobile-relay-background-lifecycle.test.ts +++ b/mobile/src/transport/mobile-relay-background-lifecycle.test.ts @@ -4,7 +4,8 @@ import { FakeLogicalClient, FakeRelaySession, FakeSession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' @@ -23,7 +24,7 @@ describe('mobile Relay background lifecycle', () => { it('retains a relay session across a quick background and foreground', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -43,7 +44,7 @@ describe('mobile Relay background lifecycle', () => { it('releases a retained relay after 30 seconds and recovers only on foreground', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -64,7 +65,7 @@ describe('mobile Relay background lifecycle', () => { it('enforces an overdue grace on foreground when the background timer was suspended', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -81,7 +82,7 @@ describe('mobile Relay background lifecycle', () => { it('recovers after a retained relay fails while backgrounded', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -101,7 +102,7 @@ describe('mobile Relay background lifecycle', () => { it('does not retain or suspend a healthy direct session in the background', async () => { const logical = new FakeLogicalClient('connected', 'tailscale') const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -120,7 +121,7 @@ describe('mobile Relay background lifecycle', () => { openDirect: vi.fn(() => new FakeSession('disconnected')), openRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(40_000) @@ -147,7 +148,7 @@ describe('mobile Relay background lifecycle', () => { openRelay, writeBundle: vi.fn(() => writeStarted) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const starting = supervisor.start() await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce()) diff --git a/mobile/src/transport/mobile-relay-connect-url.ts b/mobile/src/transport/mobile-relay-connect-url.ts new file mode 100644 index 00000000000..5f83812764b --- /dev/null +++ b/mobile/src/transport/mobile-relay-connect-url.ts @@ -0,0 +1,7 @@ +/** The relay's phone connect socket for `relayHostId`, on a cell or on the director. */ +export function relayConnectWebSocketUrl(baseUrl: string, relayHostId: string): string { + const url = new URL(baseUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relayHostId)}` + return url.toString() +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts index 28fb7c307f5..0d99c7d9429 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts @@ -14,17 +14,13 @@ const directHost: HostProfile = { } const upgraded = { - host: { - ...directHost, + relay: { + v: 1 as const, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl: 'https://c1.relay-staging.onorca.dev', + assignmentEpoch: 4, relayHostId: 'AbCdEf0123_-xyZ9', - relay: { - v: 1 as const, - directorUrl: 'https://relay-staging.onorca.dev', - cellUrl: 'https://c1.relay-staging.onorca.dev', - assignmentEpoch: 4, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 as const - } + e2eeFraming: 2 as const }, bundle: { v: 1 as const, diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts index 13d020ec239..d76e887b299 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' -import { MobileRelayUpgradeHostRemovedError } from './host-store' +import { RelayRoutingHostRemovedError } from './host-store' import { createMobileRelayDirectUpgradeJournal, type MobileRelayDirectUpgradeJournal @@ -64,7 +64,7 @@ function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) { }), writeBundle: vi.fn(async () => {}), deleteBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), randomBytes: (length: number) => new Uint8Array(length).fill(7) } } @@ -113,8 +113,9 @@ describe('existing direct pairing relay upgrade', () => { reqId: journal!.reqId, newResumeTokenHash: journal!.pendingResumeTokenHash }) - expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveHost) - expect(result?.host.relay).toEqual(relay) + expect(deps.writeBundle).toHaveBeenCalledBefore(deps.setRelayRouting) + expect(deps.setRelayRouting).toHaveBeenCalledWith(host.id, relay) + expect(result?.relay).toEqual(relay) expect(deps.clearJournal).toHaveBeenCalledWith(host.id) }) @@ -153,7 +154,7 @@ describe('existing direct pairing relay upgrade', () => { await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() expect(deps.clearJournal).toHaveBeenCalledWith(host.id) expect(deps.writeBundle).not.toHaveBeenCalled() - expect(deps.saveHost).not.toHaveBeenCalled() + expect(deps.setRelayRouting).not.toHaveBeenCalled() }) // Why 'forbidden': a desktop that predates pairing.getEndpoints has it on neither its mobile @@ -176,7 +177,7 @@ describe('existing direct pairing relay upgrade', () => { await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() expect(deps.clearJournal).toHaveBeenCalledWith(host.id) expect(deps.writeBundle).not.toHaveBeenCalled() - expect(deps.saveHost).not.toHaveBeenCalled() + expect(deps.setRelayRouting).not.toHaveBeenCalled() }) it('retains the durable journal when relay registration is temporarily unavailable', async () => { @@ -196,8 +197,8 @@ describe('existing direct pairing relay upgrade', () => { ) const committed = installed(journal) const deps = dependencies(journal) - deps.saveHost.mockRejectedValue( - new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + deps.setRelayRouting.mockRejectedValue( + new RelayRoutingHostRemovedError('mobile relay upgrade host was removed') ) const client = clientWith([ success({ @@ -209,7 +210,7 @@ describe('existing direct pairing relay upgrade', () => { await expect( upgradeDirectMobileRelay({ client, host, dependencies: deps }) - ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + ).rejects.toBeInstanceOf(RelayRoutingHostRemovedError) expect(deps.deleteBundle).toHaveBeenCalledWith(host.id) expect(deps.clearJournal).toHaveBeenCalledWith(host.id) }) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.ts b/mobile/src/transport/mobile-relay-direct-upgrade.ts index 221d5a5d6c1..30b2a76e681 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.ts @@ -1,10 +1,10 @@ import * as ExpoCrypto from 'expo-crypto' import type { DeviceCredentialInstalled, + MobileRelayEndpoint, PairingGetEndpointsResult } from '../../../src/shared/mobile-relay-credential-contract' -import { MobileRelayUpgradeHostRemovedError, saveExistingHostRelayUpgrade } from './host-store' -import { persistRelayHost } from './mobile-endpoint-supervisor-support' +import { RelayRoutingHostRemovedError, setRelayRouting } from './host-store' import { MobileRelayCredentialBundleSchema, deleteMobileRelayCredentialBundle, @@ -27,7 +27,7 @@ import type { HostProfile } from './types' import { isPairingRelayRpcUnavailable } from './pairing-relay-rpc-unavailable' export type MobileRelayDirectUpgradeResult = { - host: HostProfile + relay: MobileRelayEndpoint bundle: MobileRelayCredentialBundle } @@ -36,7 +36,7 @@ type Dependencies = { writeJournal: typeof writeMobileRelayDirectUpgradeJournal clearJournal: typeof deleteMobileRelayDirectUpgradeJournal writeBundle: typeof writeMobileRelayCredentialBundle - saveHost: typeof saveExistingHostRelayUpgrade + setRelayRouting: typeof setRelayRouting deleteBundle: typeof deleteMobileRelayCredentialBundle randomBytes: (length: number) => Uint8Array } @@ -54,7 +54,7 @@ export async function upgradeDirectMobileRelay(args: { writeJournal: writeMobileRelayDirectUpgradeJournal, clearJournal: deleteMobileRelayDirectUpgradeJournal, writeBundle: writeMobileRelayCredentialBundle, - saveHost: saveExistingHostRelayUpgrade, + setRelayRouting, deleteBundle: deleteMobileRelayCredentialBundle, randomBytes: ExpoCrypto.getRandomBytes, ...args.dependencies @@ -120,18 +120,17 @@ async function publishCommitted( }) // Why: the overlay must never advertise relay without its matching credential. await dependencies.writeBundle(bundle) - let updatedHost: HostProfile try { - updatedHost = await persistRelayHost(host, endpoints.relay, dependencies.saveHost) + await dependencies.setRelayRouting(host.id, endpoints.relay) } catch (error) { - if (error instanceof MobileRelayUpgradeHostRemovedError) { + if (error instanceof RelayRoutingHostRemovedError) { await dependencies.deleteBundle(host.id) await dependencies.clearJournal(host.id) } throw error } await dependencies.clearJournal(host.id) - return { host: updatedHost, bundle } + return { relay: endpoints.relay, bundle } } async function getEndpoints( diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 9b1f7a9a355..c2f24f71823 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -9,6 +9,7 @@ import { import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' // Native WebSockets normally emit close immediately after error; bound the // missing-close case so a dead socket cannot leave recovery pending forever. @@ -54,7 +55,7 @@ export class MobileRelayE2eeLink { constructor(options: MobileRelayE2eeLinkOptions) { this.options = options this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))( - relaySocketUrl(options.endpoint) + relayConnectWebSocketUrl(options.endpoint.cellUrl, options.endpoint.relayHostId) ) const session = MobileE2EEV2ClientSession.create({ desktopPublicKeyB64: options.desktopPublicKeyB64, @@ -188,13 +189,6 @@ export class MobileRelayE2eeLink { } } -function relaySocketUrl(endpoint: { cellUrl: string; relayHostId: string }): string { - const url = new URL(endpoint.cellUrl) - url.protocol = 'wss:' - url.pathname = `/v1/connect/${encodeURIComponent(endpoint.relayHostId)}` - return url.toString() -} - function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts index 812d61ebb1d..d2053b7a515 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' const asyncStorage = vi.hoisted(() => ({ getItem: vi.fn(), @@ -8,35 +10,82 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) import { - loadMobileRelayHostOverlays, - removeMobileRelayHostOverlays, + loadMobileRelayHostRoutingState, + removeMobileRelayHostRoutings, resetMobileRelayHostOverlayStoreForTests, - saveMobileRelayHostOverlay + saveMobileRelayHostRouting } from './mobile-relay-host-overlay-store' import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' const STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' -const OVERLAY: MobileRelayHostOverlay = { +const RELAY = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} +const RELAY_ONLY_OVERLAY: MobileRelayHostOverlay = { v: 2, hostId: 'host-1', endpoints: [ - { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' } ], - relayHostId: 'AbCdEf0123_-xyZ9', - relay: { - v: 1, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 - } + relayHostId: RELAY.relayHostId, + relay: RELAY } +// What builds before the relay-routing split wrote: a copy of the row's address beside the relay. +const LEGACY_OVERLAY: MobileRelayHostOverlay = { + ...RELAY_ONLY_OVERLAY, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + ...RELAY_ONLY_OVERLAY.endpoints + ] +} + +// Frozen copy of the overlay schema shipped builds parse with (relay endpoint schema is shared); +// they drop a record it rejects. +const ShippedOverlaySchema = z + .object({ + v: z.literal(2), + hostId: z.string().min(1), + endpoints: z + .array( + z + .object({ + id: z.string().min(1).max(128), + kind: z.enum(['lan', 'tailscale', 'relay']), + url: z.string().min(1).max(2048) + }) + .strict() + ) + .min(1) + .max(16), + relayHostId: z + .string() + .regex(/^[A-Za-z0-9_-]{16}$/) + .optional(), + relay: MobileRelayEndpointSchema.optional() + }) + .strict() + .superRefine((overlay, context) => { + if ((overlay.relayHostId === undefined) !== (overlay.relay === undefined)) { + context.addIssue({ code: 'custom', message: 'Relay identity and endpoint must coexist' }) + return + } + if (overlay.relay && overlay.relay.relayHostId !== overlay.relayHostId) { + context.addIssue({ code: 'custom', message: 'Relay host identity mismatch' }) + } + const relayEndpointCount = overlay.endpoints.filter(({ kind }) => kind === 'relay').length + if (relayEndpointCount !== (overlay.relay ? 1 : 0)) { + context.addIssue({ code: 'custom', message: 'Expected exactly one relay endpoint' }) + } + }) describe('mobile relay host overlay store', () => { let stored: string | null @@ -55,35 +104,57 @@ describe('mobile relay host overlay store', () => { }) }) - it('round-trips v2 metadata in a namespace legacy builds do not rewrite', async () => { - await saveMobileRelayHostOverlay(OVERLAY) + it('round-trips relay routing in a namespace legacy builds do not rewrite', async () => { + await expect(saveMobileRelayHostRouting('host-1', RELAY)).resolves.toBe(true) - await expect(loadMobileRelayHostOverlays(new Set(['host-1']))).resolves.toEqual( - new Map([['host-1', OVERLAY]]) - ) - expect(asyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String)) + await expect(loadMobileRelayHostRoutingState(new Set(['host-1']))).resolves.toEqual({ + relays: new Map([['host-1', RELAY]]), + orphanHostIds: [] + }) + expect(JSON.parse(stored!)).toEqual([RELAY_ONLY_OVERLAY]) + }) + + it('writes a record the shipped overlay schema still accepts', async () => { + await saveMobileRelayHostRouting('host-1', RELAY) + + const [record] = JSON.parse(stored!) + expect(ShippedOverlaySchema.safeParse(record).success).toBe(true) + }) + + it('ignores a direct address an older build stored and drops it on the next routing write', async () => { + stored = JSON.stringify([LEGACY_OVERLAY]) + + await expect(loadMobileRelayHostRoutingState(new Set(['host-1']))).resolves.toEqual({ + relays: new Map([['host-1', RELAY]]), + orphanHostIds: [] + }) + await expect(saveMobileRelayHostRouting('host-1', RELAY)).resolves.toBe(true) + expect(JSON.parse(stored!)).toEqual([RELAY_ONLY_OVERLAY]) }) it('never overlays or resurrects a host whose legacy base was removed', async () => { - stored = JSON.stringify([OVERLAY]) + stored = JSON.stringify([LEGACY_OVERLAY]) - await expect(loadMobileRelayHostOverlays(new Set())).resolves.toEqual(new Map()) + await expect(loadMobileRelayHostRoutingState(new Set())).resolves.toEqual({ + relays: new Map(), + orphanHostIds: ['host-1'] + }) expect(asyncStorage.setItem).not.toHaveBeenCalled() - expect(JSON.parse(stored)).toEqual([OVERLAY]) + expect(JSON.parse(stored)).toEqual([LEGACY_OVERLAY]) }) it('refuses to overwrite unreadable overlay storage', async () => { stored = '{' - await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) + await expect(saveMobileRelayHostRouting('host-1', RELAY)).rejects.toThrow(/unreadable/) expect(asyncStorage.setItem).not.toHaveBeenCalled() }) it('removes requested overlays in one storage write', async () => { - const second = { ...OVERLAY, hostId: 'host-2' } - stored = JSON.stringify([OVERLAY, second]) + const second = { ...RELAY_ONLY_OVERLAY, hostId: 'host-2' } + stored = JSON.stringify([RELAY_ONLY_OVERLAY, second]) - await expect(removeMobileRelayHostOverlays(['host-1', 'host-missing'])).resolves.toBeUndefined() + await expect(removeMobileRelayHostRoutings(['host-1', 'host-missing'])).resolves.toBeUndefined() expect(JSON.parse(stored!)).toEqual([second]) expect(asyncStorage.getItem).toHaveBeenCalledOnce() @@ -91,9 +162,9 @@ describe('mobile relay host overlay store', () => { }) it('skips the storage write when no requested overlay exists', async () => { - stored = JSON.stringify([OVERLAY]) + stored = JSON.stringify([RELAY_ONLY_OVERLAY]) - await expect(removeMobileRelayHostOverlays(['host-missing'])).resolves.toBeUndefined() + await expect(removeMobileRelayHostRoutings(['host-missing'])).resolves.toBeUndefined() expect(asyncStorage.getItem).toHaveBeenCalledOnce() expect(asyncStorage.setItem).not.toHaveBeenCalled() diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 4960fbec3f0..6dedb3eadb5 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -1,11 +1,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { MobileRelayHostOverlaySchema, + toStoredMobileRelayHostOverlay, type MobileRelayHostOverlay } from './mobile-relay-host-overlay' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' -let overlayMutation: Promise = Promise.resolve() +let overlayMutation: Promise = Promise.resolve() function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { if (raw === null) { @@ -37,74 +39,71 @@ async function readOverlaysForMutation(): Promise { async function mutateOverlays( update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[] -): Promise { +): Promise { const mutation = overlayMutation.then(async () => { const current = await readOverlaysForMutation() const next = update(current) - // Why: direct-only saves commonly have no overlay to remove; avoid a full - // AsyncStorage write when cleanup leaves the durable list unchanged. - if (next !== current) { - await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + // Why: an update handing back the list it read changed nothing; skip the full AsyncStorage write. + if (next === current) { + return false } + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + return true }) overlayMutation = mutation.catch(() => {}) return mutation } -export async function loadMobileRelayHostOverlays( +export async function loadMobileRelayHostRoutingState( existingHostIds: ReadonlySet -): Promise> { - return (await loadMobileRelayHostOverlayState(existingHostIds)).overlays -} - -export async function loadMobileRelayHostOverlayState( - existingHostIds: ReadonlySet -): Promise<{ overlays: Map; orphanHostIds: string[] }> { +): Promise<{ relays: Map; orphanHostIds: string[] }> { await overlayMutation const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? [] - const active = new Map() + const relays = new Map() const orphanHostIds: string[] = [] for (const overlay of overlays) { // Why: an older app can remove the legacy base without knowing this // namespace; never let the retained overlay resurrect that host later. - if (existingHostIds.has(overlay.hostId)) { - active.set(overlay.hostId, overlay) - } else { + if (!existingHostIds.has(overlay.hostId)) { orphanHostIds.push(overlay.hostId) + } else if (overlay.relay) { + relays.set(overlay.hostId, overlay.relay) } } - return { overlays: active, orphanHostIds } + return { relays, orphanHostIds } } -export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay): Promise { - const validated = MobileRelayHostOverlaySchema.parse(overlay) +/** Resolves whether storage changed. */ +export function saveMobileRelayHostRouting( + hostId: string, + relay: MobileRelayEndpoint +): Promise { + const validated = toStoredMobileRelayHostOverlay(hostId, relay) return mutateOverlays((overlays) => { - const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId) + const index = overlays.findIndex((overlay) => overlay.hostId === hostId) if (index === -1) { return [...overlays, validated] } + // Why: a failing relay loop re-resolves the same cell every retry. Both sides are this + // schema's output, so key order matches; an older record's direct entry fails the compare. + if (JSON.stringify(overlays[index]) === JSON.stringify(validated)) { + return overlays + } const next = overlays.slice() next[index] = validated return next }) } -export function removeMobileRelayHostOverlay(hostId: string): Promise { - return removeMobileRelayHostOverlays([hostId]) +export function removeMobileRelayHostRouting(hostId: string): Promise { + return removeMobileRelayHostRoutings([hostId]) } -export function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise { +export async function removeMobileRelayHostRoutings(hostIds: readonly string[]): Promise { const targets = new Set(hostIds) - let removed = false - return mutateOverlays((overlays) => { - const next = overlays.filter((overlay) => { - if (!targets.has(overlay.hostId)) { - return true - } - removed = true - return false - }) - return removed ? next : overlays + await mutateOverlays((overlays) => { + const next = overlays.filter((overlay) => !targets.has(overlay.hostId)) + return next.length === overlays.length ? overlays : next }) } diff --git a/mobile/src/transport/mobile-relay-host-overlay.ts b/mobile/src/transport/mobile-relay-host-overlay.ts index 59c407c9ee3..8076e7a0ad0 100644 --- a/mobile/src/transport/mobile-relay-host-overlay.ts +++ b/mobile/src/transport/mobile-relay-host-overlay.ts @@ -1,7 +1,11 @@ import { z } from 'zod' -import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' +import { + MobileRelayEndpointSchema, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' -export const MobileAccessEndpointSchema = z +const MobileAccessEndpointSchema = z .object({ id: z.string().min(1).max(128), kind: z.enum(['lan', 'tailscale', 'relay']), @@ -9,6 +13,10 @@ export const MobileAccessEndpointSchema = z }) .strict() +/** + * The stored record, which shipped builds also read and write. Only `relay` is authoritative here: + * `endpoints` and `relayHostId` are derived copies kept so an older build still parses the record. + */ export const MobileRelayHostOverlaySchema = z .object({ v: z.literal(2), @@ -43,5 +51,24 @@ export const MobileRelayHostOverlaySchema = z } }) -export type MobileAccessEndpoint = z.infer export type MobileRelayHostOverlay = z.infer + +// Why no direct entry: the host row owns the address, and a copy here outlived every Edit Host. +export function toStoredMobileRelayHostOverlay( + hostId: string, + relay: MobileRelayEndpoint +): MobileRelayHostOverlay { + return MobileRelayHostOverlaySchema.parse({ + v: 2, + hostId, + endpoints: [ + { + id: 'relay-primary', + kind: 'relay', + url: relayConnectWebSocketUrl(relay.cellUrl, relay.relayHostId) + } + ], + relayHostId: relay.relayHostId, + relay + }) +} diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts index 408f83c8c09..5ae76024054 100644 --- a/mobile/src/transport/mobile-relay-invite-director.ts +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -1,4 +1,5 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' export class RelayDirectorMoveNotNewerError extends Error { @@ -28,7 +29,7 @@ export function resolvePairingInviteThroughDirector(args: { createSocket?: (url: string) => WebSocket }): Promise { const socket = (args.createSocket ?? ((url) => new WebSocket(url)))( - directorWebSocketUrl(args.relay) + relayConnectWebSocketUrl(args.relay.directorUrl, args.relay.relayHostId) ) return new Promise((resolve, reject) => { let settled = false @@ -101,10 +102,3 @@ export function resolvePairingInviteThroughDirector(args: { } }) } - -export function directorWebSocketUrl(relay: PairingRelay): string { - const url = new URL(relay.directorUrl) - url.protocol = 'wss:' - url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` - return url.toString() -} diff --git a/mobile/src/transport/mobile-relay-orphan-cleanup.ts b/mobile/src/transport/mobile-relay-orphan-cleanup.ts index fd57f01f9c9..14929890f4f 100644 --- a/mobile/src/transport/mobile-relay-orphan-cleanup.ts +++ b/mobile/src/transport/mobile-relay-orphan-cleanup.ts @@ -1,14 +1,14 @@ import { scheduleHostCredentialCleanup } from './host-credential-cleanup' -import { removeMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' +import { removeMobileRelayHostRouting } from './mobile-relay-host-overlay-store' export async function scheduleOrphanedMobileRelayCleanup(args: { hostIds: string[] deleteCredential: (hostId: string) => Promise scheduleCleanup?: typeof scheduleHostCredentialCleanup - removeOverlay?: typeof removeMobileRelayHostOverlay + removeOverlay?: typeof removeMobileRelayHostRouting }): Promise { const scheduleCleanup = args.scheduleCleanup ?? scheduleHostCredentialCleanup - const removeOverlay = args.removeOverlay ?? removeMobileRelayHostOverlay + const removeOverlay = args.removeOverlay ?? removeMobileRelayHostRouting for (const hostId of new Set(args.hostIds)) { try { // Why: an older build may remove the legacy host while retaining the v2 diff --git a/mobile/src/transport/mobile-relay-outage-escalation.test.ts b/mobile/src/transport/mobile-relay-outage-escalation.test.ts index 93694f1efac..09eac93bf4f 100644 --- a/mobile/src/transport/mobile-relay-outage-escalation.test.ts +++ b/mobile/src/transport/mobile-relay-outage-escalation.test.ts @@ -5,7 +5,8 @@ import { dependencies, FakeRelaySession, FakeSession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' import { RelayOuterError } from './mobile-relay-e2ee-link' import { createStableLogicalRpcClient } from './stable-logical-rpc-client' @@ -40,7 +41,7 @@ describe('continuous Relay outage escalation', () => { openRelay, randomBytes: () => new Uint8Array([0, 0]) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(openRelay).toHaveBeenCalledOnce() diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 96d71b37680..e9024e23c24 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -6,7 +6,7 @@ import { resetMobileRelayPairingRecoveryForTests } from './mobile-relay-pairing-recovery' import type { PairingCandidateClient } from './mobile-relay-physical-client' -import type { PairingOffer, RpcResponse } from './types' +import type { HostProfile, PairingOffer, RpcResponse } from './types' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() })) @@ -92,6 +92,7 @@ function dependencies(args: { journal: ReturnType connectRelay: ReturnType bundle?: MobileRelayCredentialBundle | null + hosts?: HostProfile[] }) { return { loadJournal: vi.fn(async () => args.journal), @@ -101,8 +102,8 @@ function dependencies(args: { clearJournal: vi.fn(async () => {}), readCredentialBundle: vi.fn(async () => args.bundle ?? null), writeCredentialBundle: vi.fn(async () => {}), - loadHosts: vi.fn(async () => []), - saveHost: vi.fn(async () => {}), + loadHosts: vi.fn(async (): Promise => args.hosts ?? []), + savePairedHost: vi.fn(async () => {}), connectRelay: args.connectRelay, resolveInviteDirector: vi.fn(async () => { throw new Error('director not needed') @@ -139,7 +140,26 @@ describe('mobile relay pairing recovery', () => { }) ) expect(deps.writeCredentialBundle).toHaveBeenCalledOnce() - expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.savePairedHost).toHaveBeenCalledOnce() + expect(deps.clearJournal).toHaveBeenCalledOnce() + }) + + it('clears a journal whose relay routing the host already carries, without dialing', async () => { + const saved = journal() + const connectRelay = vi.fn() + const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: 'host-1', + deviceToken: offer.deviceToken, + current: { token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1, expiresAt: now + 60_000 } + } + const { relay } = endpoints(saved, { state: 'not-found' }) + const hosts = [{ ...saved.metadata.host, deviceToken: offer.deviceToken, relay }] + const deps = dependencies({ journal: saved, connectRelay, bundle, hosts }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered') + expect(connectRelay).not.toHaveBeenCalled() + expect(deps.savePairedHost).not.toHaveBeenCalled() expect(deps.clearJournal).toHaveBeenCalledOnce() }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index a6ecd6bbd5a..c93baec9c0d 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -1,11 +1,10 @@ import { Platform } from 'react-native' import type { DeviceCredentialInstalled, - MobileRelayEndpoint, PairingGetEndpointsResult } from '../../../src/shared/mobile-relay-credential-contract' import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' -import { loadHosts, saveHost } from './host-store' +import { loadHosts, savePairedHost } from './host-store' import { promotePairingJournalCredential, readMobileRelayCredentialBundle, @@ -24,7 +23,7 @@ import { type PairingCandidateClient } from './mobile-relay-physical-client' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' -import type { HostProfile } from './types' +import { relayHost } from './pairing-relay-host' import { relayCredentialProvision, relayPairingEndpointsRead @@ -39,7 +38,7 @@ type RecoveryDependencies = { readCredentialBundle: typeof readMobileRelayCredentialBundle writeCredentialBundle: typeof writeMobileRelayCredentialBundle loadHosts: typeof loadHosts - saveHost: typeof saveHost + savePairedHost: typeof savePairedHost connectRelay: typeof connectMobileRelayForPairing resolveInviteDirector: typeof resolvePairingInviteThroughDirector now: () => number @@ -53,7 +52,7 @@ const defaultDependencies: RecoveryDependencies = { readCredentialBundle: readMobileRelayCredentialBundle, writeCredentialBundle: writeMobileRelayCredentialBundle, loadHosts, - saveHost, + savePairedHost, connectRelay: connectMobileRelayForPairing, resolveInviteDirector: resolvePairingInviteThroughDirector, now: Date.now, @@ -97,7 +96,7 @@ async function runRecovery( const bundle = await dependencies.readCredentialBundle(journal.metadata.host.id).catch(() => null) const hosts = await dependencies.loadHosts().catch(() => []) const existing = hosts.find(({ id }) => id === journal!.metadata.host.id) - if (existing?.relayHostId === journal.metadata.relay.relayHostId && bundle) { + if (existing?.relay?.relayHostId === journal.metadata.relay.relayHostId && bundle) { await dependencies.clearJournal(journal.metadata.journalId) return 'recovered' } @@ -266,27 +265,10 @@ async function publishCommitted( await dependencies.writeCredentialBundle( promotePairingJournalCredential({ journal: reconciledJournal, installed }) ) - await dependencies.saveHost(relayHost(reconciledJournal, endpoints.relay)) + await dependencies.savePairedHost(relayHost(reconciledJournal, endpoints.relay)) await dependencies.clearJournal(journal.metadata.journalId) } -function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile { - const host = journal.metadata.host - const url = new URL(relay.cellUrl) - url.protocol = 'wss:' - url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` - return { - ...host, - deviceToken: journal.secrets.deviceToken, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: host.endpoint }, - { id: 'relay-primary', kind: 'relay', url: url.toString() } - ], - relayHostId: relay.relayHostId, - relay - } -} - function pairingRelay(journal: MobileRelayPairingJournal): PairingRelay { return { ...journal.metadata.relay, inviteToken: journal.secrets.inviteToken } } diff --git a/mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts b/mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts index 89a0f6f0ef1..4fff86c4605 100644 --- a/mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts @@ -6,7 +6,8 @@ import { dependencies, FakeRelaySession, FakeSession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' @@ -65,7 +66,7 @@ describe('continuous Relay pairing-rejection escalation', () => { randomBytes: () => new Uint8Array([0, 0]), onLog: () => {} }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) return { activeRelay, logical, openRelay, supervisor } } diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 9e3e4c259f8..3df30542ffa 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -7,6 +7,7 @@ import { isRpcResponse } from './rpc-response-shape' import { redactSocketEndpoint } from './socket-event-debug' import type { ConnectionLogSink, RpcResponse } from './types' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' @@ -34,7 +35,7 @@ export function connectMobileRelayForPairing(args: { onLog?: ConnectionLogSink }): PairingCandidateClient { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 - const socketUrl = relayPhoneWebSocketUrl(args.relay) + const socketUrl = relayConnectWebSocketUrl(args.relay.cellUrl, args.relay.relayHostId) const log = createPairingRelayLogger(args.onLog) const cellHost = redactSocketEndpoint(socketUrl) log('info', 'Relay: dialing cell', cellHost) @@ -208,13 +209,6 @@ export function connectMobileRelayForPairing(args: { } } -export function relayPhoneWebSocketUrl(relay: PairingRelay): string { - const url = new URL(relay.cellUrl) - url.protocol = 'wss:' - url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` - return url.toString() -} - function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index e7e299209ed..0a9cc8590c9 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -199,15 +199,6 @@ const host: HostProfile = { deviceToken: 'device-token', publicKeyB64: 'A'.repeat(44), lastConnected: 1, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: DIRECT_ENDPOINT }, - { - id: 'relay-primary', - kind: 'relay', - url: 'wss://relay-c1.onorca.dev/v1/connect/id' - } - ], - relayHostId: relay.relayHostId, relay } @@ -234,7 +225,8 @@ function dependencies( resolveRelay: vi.fn(async ({ relay }) => relay), readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)), writeBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), + directPath: 'lan', now: Date.now, randomBytes: (length: number) => new Uint8Array(length), setTimer: (handler, ms) => setTimeout(handler, ms), @@ -265,7 +257,7 @@ describe('relay runtime recovery without direct connectivity', () => { .fn(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) .mockResolvedValueOnce(bundleWith(2, Number.MAX_SAFE_INTEGER)) const deps = dependencies({ openRelay, readBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -290,7 +282,7 @@ describe('relay runtime recovery without direct connectivity', () => { .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) .mockResolvedValueOnce(null) const deps = dependencies({ readBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) // Pre-fix, a null first read killed relay recovery for the process lifetime. await supervisor.start() @@ -306,7 +298,7 @@ describe('relay runtime recovery without direct connectivity', () => { const expired = bundleWith(2, Date.now() - 1) const readBundle = vi.fn(async () => expired) const deps = dependencies({ readBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -333,7 +325,7 @@ describe('relay runtime recovery without direct connectivity', () => { ) .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -356,7 +348,7 @@ describe('relay runtime recovery without direct connectivity', () => { .mockResolvedValueOnce(expired) .mockResolvedValueOnce(expired) const deps = dependencies({ readBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -386,7 +378,7 @@ describe('relay runtime recovery without direct connectivity', () => { .fn(async () => bundleWith(1, Number.MAX_SAFE_INTEGER)) .mockResolvedValueOnce(bundleWith(4, Number.MAX_SAFE_INTEGER)) const deps = dependencies({ openRelay, readBundle }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -414,7 +406,7 @@ describe('relay runtime recovery without direct connectivity', () => { // confuse the churn measurement by migrating back to direct. const openDirect = vi.fn(() => new FakeSession('disconnected')) const deps = dependencies({ openRelay, openDirect }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -435,7 +427,7 @@ describe('relay runtime recovery without direct connectivity', () => { ) .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(0) @@ -453,7 +445,7 @@ describe('relay runtime recovery without direct connectivity', () => { it('restarts Relay promptly after the background grace expires', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('disconnected')) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() expect(deps.openRelay).not.toHaveBeenCalled() @@ -476,7 +468,7 @@ describe('relay runtime recovery without direct connectivity', () => { it('recovers an expired background Relay through the app-resume manual retry nudge', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('disconnected')) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() supervisor.setForeground(false) @@ -539,7 +531,7 @@ describe('failover with a real direct rpc-client', () => { 'tailscale' as MobileConnectionPath ) const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) await supervisor.start() await vi.advanceTimersByTimeAsync(3_000) @@ -561,7 +553,7 @@ describe('failover with a real direct rpc-client', () => { return bundleWith(2, Number.MAX_SAFE_INTEGER) }) }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) const started = supervisor.start() await vi.advanceTimersByTimeAsync(300) diff --git a/mobile/src/transport/mobile-relay-session-establisher.ts b/mobile/src/transport/mobile-relay-session-establisher.ts index e50a977500a..7e616002102 100644 --- a/mobile/src/transport/mobile-relay-session-establisher.ts +++ b/mobile/src/transport/mobile-relay-session-establisher.ts @@ -12,7 +12,6 @@ import type { RelayReconnectController } from './mobile-relay-reconnect-controll import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason' -import type { HostProfile } from './types' type EstablishResult = { ok: true } | { ok: false; error: Error } @@ -23,8 +22,10 @@ function directWon(logical: StableLogicalRpcClient): boolean { // Turns one relay credential into the active runtime session: resolve the cell // assignment if the director rejects the cached one, open the cell socket, // migrate the logical client onto it, then persist the resume confirmation and -// re-arm the supervisor's timers. +// re-arm the supervisor's timers. Owns the relay it dials. export class MobileRelaySessionEstablisher { + private relay: MobileRelayEndpoint + constructor( private readonly args: { logical: StableLogicalRpcClient @@ -34,9 +35,11 @@ export class MobileRelaySessionEstablisher { writeBundle: (bundle: MobileRelayCredentialBundle) => Promise isActive: () => boolean isForeground: () => boolean - relay: () => HostProfile['relay'] + isStopped: () => boolean + hostId: string + relay: MobileRelayEndpoint resolveRelay: MobileEndpointSupervisorDependencies['resolveRelay'] - persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise + setRelayRouting: MobileEndpointSupervisorDependencies['setRelayRouting'] bundle: () => MobileRelayCredentialBundle | null adoptBundle: (bundle: MobileRelayCredentialBundle) => void // Hysteresis stamp + rotation-pending clear + recovery log line. @@ -47,7 +50,19 @@ export class MobileRelaySessionEstablisher { onBookkeepingError: (error: Error) => void onDialFailure: (error: Error) => void } - ) {} + ) { + this.relay = args.relay + } + + // Routing only; the row and token belong to pairing and Edit Host. + async adoptRelay(relay: MobileRelayEndpoint): Promise { + // Why: a stopped supervisor's host may be removed or re-paired; its successor owns routing. + if (this.args.isStopped()) { + return + } + await this.args.setRelayRouting(this.args.hostId, relay) + this.relay = relay + } // Tries each eligible credential until one establishes. Only a grace-repairable // failure (BAD_OUTER_CREDENTIAL) moves on to the next credential. @@ -81,10 +96,10 @@ export class MobileRelaySessionEstablisher { dial(credential: { token: string; version: number }): Promise { return dialRelayThroughDirectorFallback({ resumeToken: credential.token, - relay: this.args.relay, + relay: () => this.relay, dial: () => this.establish(credential), resolveRelay: this.args.resolveRelay, - persistResolvedRelay: this.args.persistResolvedRelay + persistResolvedRelay: (resolved) => this.adoptRelay(resolved) }) } @@ -93,14 +108,13 @@ export class MobileRelaySessionEstablisher { version: number }): Promise { const { args } = this - const relay = args.relay() const bundle = args.bundle() // Why: director resolution and grace fallback can finish after background/stop. - if (!args.isActive() || !relay || !bundle) { + if (!args.isActive() || !bundle) { return { ok: false, error: new RelayDialAbortedError() } } const session = args.openRelay( - relay, + this.relay, credential, `confirm-${encodeBase64Url(args.randomBytes(16))}`, // Asserted on the controller's latch, not on the dial result: the close diff --git a/mobile/src/transport/paired-host-writer-census.test.ts b/mobile/src/transport/paired-host-writer-census.test.ts new file mode 100644 index 00000000000..aa7ac0c4f56 --- /dev/null +++ b/mobile/src/transport/paired-host-writer-census.test.ts @@ -0,0 +1,39 @@ +/** + * Who may write a whole host profile: pairing, which creates or re-pairs it. + * + * A relay learner (supervisor, credential rotation, direct upgrade) holds a snapshot taken when its + * connection opened, so a full-profile save from it reverts any Edit Host made since. Those write + * through `setRelayRouting`, which takes no profile. + */ +import { readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { censusSourceFiles } from '../test-support/census-source-files' + +const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) + +const PAIRING_WRITERS = [ + 'src/transport/mobile-relay-pairing-recovery.ts', + 'src/transport/pre-profile-pairing-coordinator.ts' +] + +// A value import of `savePairedHost` from the host store, at any relative depth. +const IMPORTS_PAIRED_HOST_WRITER = + /import\s*\{[^}]*\bsavePairedHost\b[^}]*\}\s*from\s*['"](?:\.{1,2}\/)+(?:[\w-]+\/)*host-store['"]/ + +function importingFiles(): string[] { + return ['src', 'app'] + .flatMap((directory) => censusSourceFiles(join(MOBILE_DIR, directory))) + .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) + .filter((file) => IMPORTS_PAIRED_HOST_WRITER.test(readFileSync(file, 'utf8'))) + .map((file) => relative(MOBILE_DIR, file)) + .sort() +} + +describe('full host profile writes', () => { + it('are reachable only from pairing', () => { + // Also the presence precondition: both creators must match, or the matcher is broken. + expect(importingFiles()).toEqual(PAIRING_WRITERS) + }) +}) diff --git a/mobile/src/transport/pairing-relay-host.ts b/mobile/src/transport/pairing-relay-host.ts index 4954b079fc9..a8ce6a96534 100644 --- a/mobile/src/transport/pairing-relay-host.ts +++ b/mobile/src/transport/pairing-relay-host.ts @@ -10,23 +10,7 @@ export function relayHost( relay: MobileRelayEndpoint ): HostProfile { const host = journal.metadata.host - return { - ...host, - deviceToken: journal.secrets.deviceToken, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: host.endpoint }, - { id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) } - ], - relayHostId: relay.relayHostId, - relay - } -} - -export function relayWebSocketUrl(relay: MobileRelayEndpoint): string { - const url = new URL(relay.cellUrl) - url.protocol = 'wss:' - url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` - return url.toString() + return { ...host, deviceToken: journal.secrets.deviceToken, relay } } export function assertCommittedInstall( diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index bff67eadf2c..0a4484f59c3 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -108,7 +108,7 @@ function dependencies(client: RpcClient, events: string[]) { id: hostId, name: 'Blue Whale' })), - saveHost: vi.fn(async (_host: HostProfile) => { + savePairedHost: vi.fn(async (_host: HostProfile) => { events.push('save-host') }), saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => { @@ -171,7 +171,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledWith({ + expect(deps.savePairedHost).toHaveBeenCalledWith({ id: `host-${now}`, name: 'Blue Whale', endpoint: directOffer.endpoint, @@ -201,7 +201,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: 'host-existing' }) - expect(deps.saveHost).toHaveBeenCalledWith({ + expect(deps.savePairedHost).toHaveBeenCalledWith({ id: 'host-existing', name: 'Studio Mac', endpoint: directOffer.endpoint, @@ -238,7 +238,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.savePairedHost).toHaveBeenCalledOnce() expect(deps.recordDescriptorFromStatus).not.toHaveBeenCalled() }) @@ -256,7 +256,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.savePairedHost).toHaveBeenCalledOnce() }) it('journals before connecting and publishes only after authoritative direct install', async () => { @@ -327,19 +327,11 @@ describe('pre-profile pairing coordinator', () => { reqId: journal!.metadata.installReqId, newResumeTokenHash: journal!.metadata.pendingResumeTokenHash }) - expect(deps.saveHost).toHaveBeenCalledWith( + expect(deps.savePairedHost).toHaveBeenCalledWith( expect.objectContaining({ id: `host-${now}`, endpoint: directOffer.endpoint, - relayHostId: relayOffer.relay!.relayHostId, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: directOffer.endpoint }, - { - id: 'relay-primary', - kind: 'relay', - url: `wss://relay-c1.onorca.dev/v1/connect/${relayOffer.relay!.relayHostId}` - } - ] + relay: expect.objectContaining({ relayHostId: relayOffer.relay!.relayHostId }) }) ) }) @@ -356,8 +348,8 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledWith( - expect.not.objectContaining({ endpoints: expect.anything() }) + expect(deps.savePairedHost).toHaveBeenCalledWith( + expect.not.objectContaining({ relay: expect.anything() }) ) expect(events).toEqual([ 'save-journal', @@ -388,8 +380,8 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledWith( - expect.not.objectContaining({ endpoints: expect.anything() }) + expect(deps.savePairedHost).toHaveBeenCalledWith( + expect.not.objectContaining({ relay: expect.anything() }) ) expect(events).toEqual([ 'save-journal', @@ -550,6 +542,6 @@ describe('pre-profile pairing coordinator', () => { await expect(attempt.result).rejects.toThrow(/cancelled/) expect(client.close).toHaveBeenCalledOnce() - expect(deps.saveHost).not.toHaveBeenCalled() + expect(deps.savePairedHost).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 0939938bec3..58b1c793118 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -1,6 +1,6 @@ import { Platform } from 'react-native' import { connect, type ConnectOptions } from './rpc-client' -import { resolvePairingHostIdentity, saveHost } from './host-store' +import { resolvePairingHostIdentity, savePairedHost } from './host-store' import type { HostProfile, PairingOffer } from './types' import { isPairingRelayRpcUnavailable } from './pairing-relay-rpc-unavailable' import { @@ -45,7 +45,7 @@ type Dependencies = { connectRelay: typeof connectMobileRelayForPairing resolveInviteDirector: typeof resolvePairingInviteThroughDirector resolveHostIdentity: typeof resolvePairingHostIdentity - saveHost: typeof saveHost + savePairedHost: typeof savePairedHost saveJournal: typeof saveMobileRelayPairingJournal updateJournal: typeof updateMobileRelayPairingJournal clearJournal: typeof clearMobileRelayPairingJournal @@ -60,7 +60,7 @@ const defaultDependencies: Dependencies = { connectRelay: connectMobileRelayForPairing, resolveInviteDirector: resolvePairingInviteThroughDirector, resolveHostIdentity: resolvePairingHostIdentity, - saveHost, + savePairedHost, saveJournal: saveMobileRelayPairingJournal, updateJournal: updateMobileRelayPairingJournal, clearJournal: clearMobileRelayPairingJournal, @@ -206,7 +206,7 @@ async function runPairing( assertActive(isDisposed) if (!journal) { - await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + await dependencies.savePairedHost(baseHost(offer, hostId, hostName, now)) recordWinnerDescriptor(dependencies, hostId, winner.status) return { hostId } } @@ -231,7 +231,7 @@ async function runPairing( // Why: this commits a LAN-only host instead of failing, so the refusal code is the only // record of why the phone never got a relay endpoint. log('info', 'Relay: desktop will not serve relay pairing', provision.error.code) - await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + await dependencies.savePairedHost(baseHost(offer, hostId, hostName, now)) await dependencies.clearJournal(journal.metadata.journalId) recordWinnerDescriptor(dependencies, hostId, winner.status) return { hostId } @@ -247,7 +247,7 @@ async function runPairing( } assertActive(isDisposed) await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed })) - await dependencies.saveHost(relayHost(journal, endpoints.relay)) + await dependencies.savePairedHost(relayHost(journal, endpoints.relay)) await dependencies.clearJournal(journal.metadata.journalId) recordWinnerDescriptor(dependencies, hostId, winner.status) return { hostId } diff --git a/mobile/src/transport/relay-host-signed-out-supervisor.test.ts b/mobile/src/transport/relay-host-signed-out-supervisor.test.ts index 1c07b4b7f6b..b2f08ad1980 100644 --- a/mobile/src/transport/relay-host-signed-out-supervisor.test.ts +++ b/mobile/src/transport/relay-host-signed-out-supervisor.test.ts @@ -6,7 +6,8 @@ import { dependencies, FakeLogicalClient, FakeRelaySession, - host + host, + relay } from './mobile-endpoint-supervisor-test-fakes' import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' @@ -37,7 +38,7 @@ describe('a signed-out desktop reaches the phone verdict', () => { ) }) }) - return { logical, supervisor: new MobileEndpointSupervisor(logical, host, deps) } + return { logical, supervisor: new MobileEndpointSupervisor(logical, host.id, relay, deps) } } it('latches the sign-out the cell reported', async () => { diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 0ed86a7a986..d9c9271c79d 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -4,11 +4,9 @@ import { type PairingOffer } from '../../../src/shared/mobile-relay-pairing-offer' import { - MobileAccessEndpointSchema, - type MobileAccessEndpoint, - type MobileRelayHostOverlay -} from './mobile-relay-host-overlay' -import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' + MobileRelayEndpointSchema, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' import { NODE_PLATFORM_NAMES } from './mobile-runtime-host-platform' import { salvagedOptional } from '../../../src/shared/zod-salvage' @@ -116,9 +114,8 @@ export type HostProfile = { deviceToken: string publicKeyB64: string lastConnected: number - endpoints?: MobileAccessEndpoint[] - relayHostId?: MobileRelayHostOverlay['relayHostId'] - relay?: MobileRelayHostOverlay['relay'] + /** Relay routing, joined from the overlay; only `setRelayRouting` and pairing write it. */ + relay?: MobileRelayEndpoint } export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing' @@ -148,11 +145,6 @@ export const HostProfileSchema = z.object({ deviceToken: z.string().min(1), publicKeyB64: z.string().min(1), lastConnected: z.number().finite(), - endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16).optional(), - relayHostId: z - .string() - .regex(/^[A-Za-z0-9_-]{16}$/) - .optional(), relay: MobileRelayEndpointSchema.optional() }) From 77bc0d77d81f46e26ccb52d94a7c99da319b2d83 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:40:53 -0700 Subject: [PATCH 023/387] fix(editor): highlight scoped dotenv filenames on desktop and mobile (#22416) Co-authored-by: willydallas Co-authored-by: Wooseong Kim --- mobile/src/session/mobile-file-language.ts | 7 ++- mobile/src/session/mobile-file-syntax.test.ts | 59 +++++++++++++++++++ src/renderer/src/lib/language-detect.test.ts | 31 ++++++++++ src/renderer/src/lib/language-detect.ts | 7 ++- 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/mobile-file-language.ts b/mobile/src/session/mobile-file-language.ts index 03b368c9411..a40e4e4d5c6 100644 --- a/mobile/src/session/mobile-file-language.ts +++ b/mobile/src/session/mobile-file-language.ts @@ -89,5 +89,10 @@ export function detectMobileFileLanguage(filePath: string, preferredLanguage?: s return exact } - return EXT_TO_LANGUAGE[extname(filename).toLowerCase()] ?? 'plaintext' + const lowerName = filename.toLowerCase() + // Scoped dotenv names fall back to INI only when no specific extension matches. + return ( + EXT_TO_LANGUAGE[extname(filename).toLowerCase()] ?? + (lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext') + ) } diff --git a/mobile/src/session/mobile-file-syntax.test.ts b/mobile/src/session/mobile-file-syntax.test.ts index f01a240e10f..821a1261960 100644 --- a/mobile/src/session/mobile-file-syntax.test.ts +++ b/mobile/src/session/mobile-file-syntax.test.ts @@ -9,6 +9,65 @@ import { import type { MobileDiffLine } from './mobile-diff-lines' describe('mobile file syntax highlighting', () => { + it.each([ + ['.env', 'ini'], + ['.env.local', 'ini'], + ['.env.development', 'ini'], + ['.env.production', 'ini'], + ['.env.functions.local', 'ini'], + ['.env.staging', 'ini'], + ['.env.test.example', 'ini'], + ['config/.env.development.local', 'ini'], + ['.ENV', 'ini'], + ['.ENV.STAGING', 'ini'], + ['C:\\repo\\.EnV.FUNCTIONS.LOCAL', 'ini'], + ['\\\\server\\share\\.env.test.example', 'ini'], + ['.env.sh', 'shell'], + ['.ENV.SH', 'shell'], + ['.env.json', 'json'], + ['.env.local.ts', 'typescript'], + ['.env/CMakeLists.txt', 'cmake'], + ['C:\\repo\\.env.local\\Dockerfile', 'dockerfile'], + ['.envrc', 'plaintext'], + ['.environment', 'plaintext'], + ['env.staging', 'plaintext'], + ['dev.env', 'plaintext'], + ['other.env.local', 'plaintext'], + ['..env.local', 'plaintext'], + ['.env.staging/readme', 'plaintext'], + ['C:\\repo\\.env.local\\notes', 'plaintext'], + ['', 'plaintext'] + ])('detects dotenv names without overriding specific mappings: %s', (filePath, expected) => { + expect(detectMobileFileLanguage(filePath)).toBe(expected) + }) + + it('keeps a mobile language preference ahead of the dotenv fallback', () => { + expect(detectMobileFileLanguage('.env.staging', ' JSON ')).toBe('json') + expect(detectMobileFileLanguage('.env.staging', ' plaintext ')).toBe('ini') + }) + + it('highlights dotenv file and diff contents with the bundled ini grammar', () => { + const language = resolveMobileSyntaxLanguage('.env.functions.local', 'plaintext') + const content = '# Local settings\nAPI_URL="https://example.test"\nPORT=54321' + expect(language).toBe('ini') + const file = highlightMobileCode(content, language) + expect(file.highlighted).toBe(true) + expect(file.segments.map((segment) => segment.text).join('')).toBe(content) + expect(file.segments).toEqual( + expect.arrayContaining([ + { text: '# Local settings', kind: 'comment' }, + { text: 'API_URL', kind: 'variable' }, + { text: '"https://example.test"', kind: 'string' } + ]) + ) + const [line] = highlightMobileDiffLines( + [{ kind: 'add', text: 'PORT=54321', newLineNumber: 3 }], + language + ) + expect(line).toMatchObject({ kind: 'add', newLineNumber: 3, highlighted: true }) + expect(line?.segments).toContainEqual({ text: 'PORT', kind: 'variable' }) + }) + it('detects common source languages from file paths', () => { expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript') expect(detectMobileFileLanguage('config/vitest.config.mts')).toBe('typescript') diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index 7ed3f5c3b0a..e00c46e8efa 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -89,4 +89,35 @@ describe('detectLanguage', () => { expect(detectLanguage('config/tsconfig.jsonc')).toBe('json') expect(detectLanguage('notes/scratch.unknownext')).toBe('plaintext') }) + it.each([ + ['.env', 'ini'], + ['.env.local', 'ini'], + ['.env.development', 'ini'], + ['.env.production', 'ini'], + ['.env.functions.local', 'ini'], + ['.env.staging', 'ini'], + ['.env.test.example', 'ini'], + ['config/.env.development.local', 'ini'], + ['.ENV', 'ini'], + ['.ENV.STAGING', 'ini'], + ['C:\\repo\\.EnV.FUNCTIONS.LOCAL', 'ini'], + ['\\\\server\\share\\.env.test.example', 'ini'], + ['.env.sh', 'shell'], + ['.ENV.SH', 'shell'], + ['.env.json', 'json'], + ['.env.local.ts', 'typescript'], + ['.env/CMakeLists.txt', 'cmake'], + ['C:\\repo\\.env.local\\Dockerfile', 'dockerfile'], + ['.envrc', 'plaintext'], + ['.environment', 'plaintext'], + ['env.staging', 'plaintext'], + ['dev.env', 'plaintext'], + ['other.env.local', 'plaintext'], + ['..env.local', 'plaintext'], + ['.env.staging/readme', 'plaintext'], + ['C:\\repo\\.env.local\\notes', 'plaintext'], + ['', 'plaintext'] + ])('detects dotenv names without overriding specific mappings: %s', (filePath, expected) => { + expect(detectLanguage(filePath)).toBe(expected) + }) }) diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index e2738b1b404..0668b9d8bac 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -132,5 +132,10 @@ export function detectLanguage(filePath: string): string { // Check extension const ext = extname(filename).toLowerCase() - return EXT_TO_LANGUAGE[ext] ?? 'plaintext' + const lowerName = filename.toLowerCase() + // Scoped dotenv names fall back to INI only when no specific extension matches. + return ( + EXT_TO_LANGUAGE[ext] ?? + (lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext') + ) } From ce2f75e17287c8014b08973088cf77447ac9648d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:41:46 -0400 Subject: [PATCH 024/387] refactor(mobile): the live input's composing range comes from a platform seam pair (#23037) #22958 made the page on Android report no composing range with a user-agent check inside the shared live-input hook. Host facts live in a `src/platform` pair, so the check moves to `live-input-composing-range.web.ts`; the native file passes the event's range through. Behaviour is unchanged. The hook test mocks the seam; the user-agent cases move to the seam's own test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../live-input-composing-range.test.ts | 44 +++++++++++++++++++ .../platform/live-input-composing-range.ts | 7 +++ .../live-input-composing-range.web.ts | 11 +++++ .../use-terminal-live-input-commit.test.ts | 13 ++++-- .../use-terminal-live-input-commit.ts | 13 +----- mobile/web-entry/web-overrides.json | 4 ++ 6 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 mobile/src/platform/live-input-composing-range.test.ts create mode 100644 mobile/src/platform/live-input-composing-range.ts create mode 100644 mobile/src/platform/live-input-composing-range.web.ts diff --git a/mobile/src/platform/live-input-composing-range.test.ts b/mobile/src/platform/live-input-composing-range.test.ts new file mode 100644 index 00000000000..f056f03e837 --- /dev/null +++ b/mobile/src/platform/live-input-composing-range.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { reportedLiveInputComposing } from './live-input-composing-range' +import { reportedLiveInputComposing as reportedPageLiveInputComposing } from './live-input-composing-range.web' + +const ANDROID_WEBVIEW = 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' +const IPHONE_WEBVIEW = 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15' + +describe('the composing range a live input reports', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('passes the native event through, whatever the user agent says', () => { + vi.stubGlobal('navigator', { userAgent: ANDROID_WEBVIEW }) + expect([true, false, undefined].map(reportedLiveInputComposing)).toEqual([ + true, + false, + undefined + ]) + }) + + it('reports no range in an Android WebView, as native Android does', () => { + vi.stubGlobal('navigator', { userAgent: ANDROID_WEBVIEW }) + expect([true, false, undefined].map(reportedPageLiveInputComposing)).toEqual([ + undefined, + undefined, + undefined + ]) + }) + + it("passes the DOM's range through in any other browser", () => { + vi.stubGlobal('navigator', { userAgent: IPHONE_WEBVIEW }) + expect([true, false, undefined].map(reportedPageLiveInputComposing)).toEqual([ + true, + false, + undefined + ]) + }) + + it('passes the range through where there is no navigator', () => { + vi.stubGlobal('navigator', undefined) + expect(reportedPageLiveInputComposing(true)).toBe(true) + }) +}) diff --git a/mobile/src/platform/live-input-composing-range.ts b/mobile/src/platform/live-input-composing-range.ts new file mode 100644 index 00000000000..99835eed7d6 --- /dev/null +++ b/mobile/src/platform/live-input-composing-range.ts @@ -0,0 +1,7 @@ +/** + * The marked-text range a live input's change event reports, as the host's text system means it. + * Native passes the event's own through: iOS reports its range, React Native Android reports none. + */ +export function reportedLiveInputComposing(isComposing: boolean | undefined): boolean | undefined { + return isComposing +} diff --git a/mobile/src/platform/live-input-composing-range.web.ts b/mobile/src/platform/live-input-composing-range.web.ts new file mode 100644 index 00000000000..61b46a3096e --- /dev/null +++ b/mobile/src/platform/live-input-composing-range.web.ts @@ -0,0 +1,11 @@ +/** + * Web sibling: the event is the DOM's, so its `isComposing` passes through except in an Android + * WebView, which reports none, as native Android does. + */ +export function reportedLiveInputComposing(isComposing: boolean | undefined): boolean | undefined { + // Why: Android keyboards compose every Latin word, so the DOM's range would hold each one unsent. + if (globalThis.navigator?.userAgent?.includes('Android')) { + return undefined + } + return isComposing +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index df693be45be..211454c87ad 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -6,6 +6,13 @@ import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS } from './terminal-live-preedit-mirror' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' +// The host's report is the seam's subject; here only whether it reports a range at all matters. +const composingRange = vi.hoisted(() => ({ hostReportsNone: false })) +vi.mock('../platform/live-input-composing-range', () => ({ + reportedLiveInputComposing: (isComposing: boolean | undefined) => + composingRange.hostReportsNone ? undefined : isComposing +})) + type TerminalLiveInputCommitHandlers = ReturnType> /** `isComposing` omitted models a platform that reports no marked-text range. */ @@ -121,7 +128,7 @@ function createTerminalLiveInputCommitHarness({ describe('terminal live input commit hook', () => { afterEach(() => { vi.useRealTimers() - vi.unstubAllGlobals() + composingRange.hostReportsNone = false }) it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => { @@ -171,7 +178,7 @@ describe('terminal live input commit hook', () => { it('Given an Android WebView composing each word When letters follow a slash Then each reaches the terminal as typed', async () => { // Given: the page's field event is the DOM's, and an Android keyboard composes Latin words - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + composingRange.hostReportsNone = true const { handlers, sent } = createTerminalLiveInputCommitHarness() // When @@ -190,7 +197,7 @@ describe('terminal live input commit hook', () => { it('Given an Android WebView composing Hangul When steps arrive Then the non-ASCII run still settles on the timer', async () => { // Given vi.useFakeTimers() - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + composingRange.hostReportsNone = true const { handlers, sent } = createTerminalLiveInputCommitHarness() // When diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 237a78ca12a..485a6a1e425 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' import type { TextInput } from 'react-native' +import { reportedLiveInputComposing } from '../platform/live-input-composing-range' import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' @@ -27,16 +28,6 @@ type TerminalLiveInputChangeEvent = { } } -/** Android keyboards compose every Latin word; report no range, as native Android does. */ -function reportedLiveInputComposing( - nativeEvent: TerminalLiveInputChangeEvent['nativeEvent'] -): boolean | undefined { - if (globalThis.navigator?.userAgent?.includes('Android')) { - return undefined - } - return nativeEvent.isComposing -} - type TerminalLiveInputCommitOptions = { readonly activeHandle: string | null readonly activeHandleRef: RefObject @@ -158,7 +149,7 @@ export function useTerminalLiveInputCommit({ void applyLiveInputMirror( activeHandle, normalizeTerminalTextInput(nativeEvent.text), - reportedLiveInputComposing(nativeEvent) + reportedLiveInputComposing(nativeEvent.isComposing) ) }, [ diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index 50a31ed61cb..afcc3c564d6 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -69,6 +69,10 @@ "file": "src/platform/keyboard-occlusion.web.ts", "reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document \u2014 the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing." }, + { + "file": "src/platform/live-input-composing-range.web.ts", + "reason": "The terminal's Live input reads the change event's isComposing as the text system's marked-text range, and on the page that event is the DOM's. An Android WebView marks the keyboard's composing region, which Samsung and other Latin keyboards keep over every word, so the preedit mirror held each word unsent until the next keystroke or Enter. Native Android reports no range at all; this sibling answers the same in an Android WebView and passes the DOM's range through in any other browser. The native file passes the event's own through." + }, { "file": "src/platform/clipboard.web.ts", "reason": "expo-clipboard resolves to navigator.clipboard on the web, which needs a secure context; the iOS shell serves the page from the custom scheme orca-mobile-web:/// while Android serves https, so that path would work on one platform and silently not on the other. This one asks the shell through the native.clipboard.write and native.clipboard.read verbs, where the pasteboard is the device's, and rejects when the route was not granted one so the caller's own catch puts that on screen. An image is the same pasteboard reached a different way: native.clipboard.read admits only text, and a 24 MiB base64 image cannot cross an 8 MiB reply cap, so readImage runs native.media.pick with source 'clipboard', reads the staged bytes back a chunk at a time and gives the handle back. contents answers per grant without probing, because the shell serves no 'is there text' verb and reading to find out would raise iOS's paste-consent prompt on every mount." From d1eb64ea415380ea04ae7e1aa80dd434b03f4455 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:42:41 -0700 Subject: [PATCH 025/387] fix(editor): use the bundled ABAP grammar (#22417) Co-authored-by: Jonghwa Hong --- src/renderer/src/lib/language-detect.test.ts | 19 +++++++++++++++++++ src/renderer/src/lib/language-detect.ts | 1 + 2 files changed, 20 insertions(+) diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index e00c46e8efa..d67d18f1db5 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -38,6 +38,25 @@ describe('detectLanguage', () => { expect(detectLanguage('C:\\rtl\\TOP.SV')).toBe('systemverilog') }) + it.each([ + 'report.abap', + 'src/zcor0260.prog.abap', + 'src/zcl_demo.clas.abap', + 'src/zif_demo.intf.abap', + 'C:\\repo\\src\\ZCL_DEMO.CLAS.ABAP', + '\\\\server\\share\\src\\ZREPORT.PROG.ABAP', + '/home/user/folder workspace/src/Report.AbAp' + ])('maps ABAP source %s to the Monaco built-in abap language id', (filePath) => { + expect(detectLanguage(filePath)).toBe('abap') + }) + + it.each(['src.abap/README', 'src\\abap.abap\\README', 'report.abap.bak', 'report.abapx'])( + 'keeps non-ABAP file %s on plaintext', + (filePath) => { + expect(detectLanguage(filePath)).toBe('plaintext') + } + ) + it('maps .proto files to the Monaco built-in proto language id, not the alias', () => { expect(detectLanguage('api/v1/service.proto')).toBe('proto') }) diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index 0668b9d8bac..b38ab72523b 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -104,6 +104,7 @@ const EXT_TO_LANGUAGE: Record = { '.nimble': 'nim', '.tf': 'hcl', '.hcl': 'hcl', + '.abap': 'abap', '.prisma': 'graphql', '.csv': 'csv', '.tsv': 'tsv' From 2ed6505a41371b664a107bb357c9aae3cc40c600 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 20:46:24 -0700 Subject: [PATCH 026/387] fix(native-chat): preserve current pane ownership through toggles and restore (#23049) * fix(native-chat): persist current pane ownership across lifecycle events * fix(native-chat): retain ownership when client chat rendering is disabled --------- Co-authored-by: m4air --- ...time-close-headless-mobile-terminal-tab.ts | 2 + ...time-persist-headless-session-tab-props.ts | 4 + .../methods/session-tab-mutation-methods.ts | 1 + .../terminal-chat-owner-persistence.test.ts | 53 ++++ .../TerminalPaneNativeChatPortal.test.tsx | 32 ++- .../remote-pane-layout-push.test.ts | 13 + .../terminal-pane/remote-pane-layout-push.ts | 1 + ...terminal-chat-ownership-lifecycle.test.tsx | 269 ++++++++++++++++++ .../use-terminal-pane-chat-state.ts | 10 +- .../use-terminal-pane-layout-persistence.ts | 22 +- .../use-terminal-pane-title-effects.ts | 13 +- .../runtime/web-runtime-terminal-actions.ts | 2 + .../session-tabs-schemas-params.ts | 1 + 13 files changed, 399 insertions(+), 24 deletions(-) create mode 100644 src/main/runtime/terminal-chat-owner-persistence.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index b326d00ec6c..1f5498043c6 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -172,6 +172,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record } ): Promise<{ updated: true }> { @@ -197,6 +198,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi tabId: hostTabId, root: acceptedLayout.root, expandedLeafId: acceptedLayout.expandedLeafId, + chatLeafId: acceptedLayout.chatLeafId ?? null, ...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {}) }) } diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index 10489c6c701..7c21c025654 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -112,6 +112,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record } ): TerminalLayoutSnapshot | undefined { @@ -131,6 +132,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi ...cloneTerminalLayoutSnapshot(existing), root: args.root ?? existing.root, expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } @@ -149,6 +151,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record } ): void { @@ -168,6 +171,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi ...tab.parentLayout, root: args.root ?? tab.parentLayout.root, expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId ?? undefined } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index d62f50be595..95369d5830f 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -99,6 +99,7 @@ export const SESSION_TAB_MUTATION_METHODS = [ tabId: params.tabId, root: params.root, expandedLeafId: params.expandedLeafId ?? null, + chatLeafId: params.chatLeafId, titlesByLeafId: params.titlesByLeafId }) } diff --git a/src/main/runtime/terminal-chat-owner-persistence.test.ts b/src/main/runtime/terminal-chat-owner-persistence.test.ts new file mode 100644 index 00000000000..a489285d27e --- /dev/null +++ b/src/main/runtime/terminal-chat-owner-persistence.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from './orca-runtime-test-fixtures.spec' +import { UpdatePaneLayout } from '../../shared/rpc-contract/session-tabs-schemas-params' +import type { RuntimeStore } from './runtime-store-contract' + +describe('remote terminal chat ownership', () => { + it('preserves, changes, and explicitly clears the owner across the RPC and restart boundary', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture implements RuntimeStore; its annotation erases the Vitest mock call signatures. + const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore) + const update = (owner: string | null | undefined) => { + const params = UpdatePaneLayout.parse({ + worktree: `id:${TEST_WORKTREE_ID}`, + tabId: 'host-tab', + root: getSession().terminalLayoutsByTabId['host-tab']!.root, + expandedLeafId: null, + ...(owner !== undefined ? { chatLeafId: owner } : {}) + }) + return runtime.updateMobileSessionPaneLayout(params.worktree, { + ...params, + expandedLeafId: params.expandedLeafId ?? null + }) + } + await update(HEADLESS_LEAF_ID) + expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBe(HEADLESS_LEAF_ID) + // An older client changing geometry must not clear the owner. + await update(undefined) + expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBe(HEADLESS_LEAF_ID) + runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) + runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID) + const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const surface = rehydrated.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab' + ) + expect(surface?.type === 'terminal' && surface.parentLayout?.chatLeafId).toBe(HEADLESS_LEAF_ID) + await update(null) + expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBeUndefined() + const cleared = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect( + cleared.tabs + .filter((tab) => tab.type === 'terminal') + .every((tab) => !tab.parentLayout?.chatLeafId) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx index 08155fb7c25..292aa0449e7 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx @@ -4,9 +4,14 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalPaneController } from './use-terminal-pane-controller' -const mocks = vi.hoisted(() => ({ - nativeChatViewProps: null as null | { isFocusedGroup: boolean } -})) +const mocks = vi.hoisted( + (): { + nativeChatViewProps: null | { + isFocusedGroup: boolean + contextMenuActions: { onClosePane: () => void } + } + } => ({ nativeChatViewProps: null }) +) vi.mock('@/store', () => ({ useAppStore: ( @@ -24,7 +29,10 @@ vi.mock('@/store', () => ({ })) vi.mock('../native-chat/NativeChatView', () => ({ - default: (props: { isFocusedGroup: boolean }) => { + default: (props: { + isFocusedGroup: boolean + contextMenuActions: { onClosePane: () => void } + }) => { mocks.nativeChatViewProps = props return } @@ -38,6 +46,22 @@ afterEach(() => { }) describe('TerminalPaneNativeChatPortal', () => { + it('targets the restored chat pane when its sibling is active', () => { + const portalContainer = document.createElement('div') + const controller = makeController(portalContainer, { activePaneIsChatLeaf: false }) + if (!controller.chatPane) { + throw new Error('fixture must have a chat pane') + } + controller.chatPane.id = 42 + controller.contextMenu.onClosePane = vi.fn() + render() + mocks.nativeChatViewProps?.contextMenuActions.onClosePane() + expect(controller.contextMenu.runForPane).toHaveBeenCalledWith( + 42, + controller.contextMenu.onClosePane + ) + }) + it('only gives the composer focus ownership to the active split leaf', () => { const portalContainer = document.createElement('div') document.body.appendChild(portalContainer) diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts index 737339f4705..4ede67a8990 100644 --- a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts @@ -65,6 +65,7 @@ describe('createRemotePaneLayoutPusher', () => { tabId: 'tab-1', root: layout.root, expandedLeafId: layout.expandedLeafId, + chatLeafId: null, titlesByLeafId: layout.titlesByLeafId }) }) @@ -79,6 +80,18 @@ describe('createRemotePaneLayoutPusher', () => { expect(updateWebRuntimePaneLayout.mock.calls[0][0]).not.toHaveProperty('titlesByLeafId') }) + it('publishes owner changes and an explicit clear without a geometry change', () => { + const pusher = createRemotePaneLayoutPusher() + for (const chatLeafId of ['leaf-a', 'leaf-b', undefined]) { + pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout({ chatLeafId }) }) + } + expect(updateWebRuntimePaneLayout.mock.calls.map(([args]) => args.chatLeafId)).toEqual([ + 'leaf-a', + 'leaf-b', + null + ]) + }) + it('pushes again for every host-visible change', () => { const pusher = createRemotePaneLayoutPusher() pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts index 963d4b78355..35ef17b949b 100644 --- a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts +++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts @@ -35,6 +35,7 @@ export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher { tabId, root: layout.root, expandedLeafId: layout.expandedLeafId, + chatLeafId: layout.chatLeafId ?? null, ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {}) }).then((updated) => { // Why: a disconnected or timed-out push carried no information, so the next persist must retry it. diff --git a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx new file mode 100644 index 00000000000..350c27667fa --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx @@ -0,0 +1,269 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { useEffect, useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { useTerminalPaneChatState } from './use-terminal-pane-chat-state' +import { useTerminalPaneLayoutPersistence } from './use-terminal-pane-layout-persistence' +import { resolveNativeChatLeafRoute } from '../native-chat/native-chat-leaf-routing' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' +import type { RemotePaneLayoutPusher } from './remote-pane-layout-push' + +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const mocks = vi.hoisted(() => { + const layouts: Record = {} + const tab = { id: 'tab', entityId: 'tab', contentType: 'terminal', viewMode: 'chat' } + const state = { + terminalLayoutsByTabId: layouts, + unifiedTabsByWorktree: { wt: [tab] }, + tabsByWorktree: {}, + settings: { experimentalNativeChat: true }, + pendingCodexPaneRestartIds: [], + runtimePaneTitlesByTabId: { tab: { 1: 'codex', 2: 'codex' } }, + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + setTabLayout: (_id: string, layout: TerminalLayoutSnapshot) => { + layouts.tab = layout + }, + setTabViewMode: (_id: string, mode: string) => { + state.unifiedTabsByWorktree = { wt: [{ ...tab, viewMode: mode }] } + }, + toggleTabViewMode: (id: string): void => { + state.setTabViewMode( + id, + state.unifiedTabsByWorktree.wt[0].viewMode === 'chat' ? 'terminal' : 'chat' + ) + } + } + return { state } +}) +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const { terminalLayoutEqual } = await import('@/lib/terminal-layout-equality') + mocks.state.setTabLayout = (id, layout) => { + if (terminalLayoutEqual(mocks.state.terminalLayoutsByTabId[id], layout)) { + return + } + mocks.state.terminalLayoutsByTabId = { ...mocks.state.terminalLayoutsByTabId, [id]: layout } + useAppStore.setState({ terminalLayoutsByTabId: mocks.state.terminalLayoutsByTabId }) + } + mocks.state.setTabViewMode = (_id, mode) => { + mocks.state.unifiedTabsByWorktree = { + wt: [{ ...mocks.state.unifiedTabsByWorktree.wt[0], viewMode: mode }] + } + useAppStore.setState({ unifiedTabsByWorktree: mocks.state.unifiedTabsByWorktree }) + } + const useAppStore = create(() => mocks.state) + return { useAppStore } +}) +vi.mock('@/runtime/web-runtime-session', () => ({ clearWebRuntimeTerminalBuffer: vi.fn() })) + +function makeFixture(paneIds = [1, 2], remotePusher?: RemotePaneLayoutPusher) { + const container = document.createElement('div') + const split = document.createElement('div') + split.className = 'pane-split' + container.append(split) + const panes = [LEFT, RIGHT].map((leafId, index) => { + const element = document.createElement('div') + element.className = 'pane' + element.dataset.paneId = String(paneIds[index]) + element.dataset.leafId = leafId + split.append(element) + return { id: paneIds[index], leafId, container: element } + }) + const manager = { + getPanes: () => panes, + getActivePane: () => panes[0], + getLeafIdMap: () => new Map(panes.map((pane) => [pane.id, pane.leafId])) + } + return { + managerRef: { current: manager }, + containerRef: { current: container }, + nativeChatTranscriptIsLocalReadable: true, + onAgentExitedRef: { current: vi.fn() }, + paneCount: 2, + tabId: 'tab', + worktreeId: 'wt', + tabWideAgentHintLeafId: null, + setTabWideAgentHintLeafId: vi.fn(), + clearedScrollbackLeafIdsRef: { current: new Set() }, + expandedPaneIdRef: { current: null }, + paneTitles: {}, + paneTitlesRef: { current: {} }, + paneTransportsRef: { current: new Map() }, + remotePaneLayoutPusherRef: { current: remotePusher ?? null }, + removedTitleLeafIdsRef: { current: new Set() }, + setPaneTitles: vi.fn() + } +} +function useFixture(fixture: ReturnType, initialOwner: string | null) { + const [chatLeafId, setChatLeafId] = useState(initialOwner) + const input = { ...fixture, chatLeafId, setChatLeafId } + const chat = useTerminalPaneChatState( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every field read by the chat hook. + input as unknown as Parameters[0] + ) + const layoutInput = { ...input, ...chat, setTabLayout: mocks.state.setTabLayout } + const layout = useTerminalPaneLayoutPersistence( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every field read by the persistence hook. + layoutInput as unknown as Parameters[0] + ) + const activeLeafId = fixture.managerRef.current.getActivePane()?.leafId ?? null + const mounted = fixture.managerRef.current.getPanes().some((pane) => pane.leafId === chatLeafId) + const { applyNativeChatLeafRoute, isChatViewMode, isChatEligibleForLeaf } = chat + useEffect(() => { + applyNativeChatLeafRoute( + resolveNativeChatLeafRoute({ + isChatViewMode, + chatLeafId, + activeLeafId, + chatLeafStillMounted: mounted, + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + }) + ) + }, [ + applyNativeChatLeafRoute, + isChatEligibleForLeaf, + isChatViewMode, + chatLeafId, + activeLeafId, + mounted + ]) + return { ...chat, ...layout, chatLeafId, setChatLeafId } +} + +beforeEach(() => { + mocks.state.settings.experimentalNativeChat = true + mocks.state.setTabViewMode('tab', 'chat') + mocks.state.setTabLayout('tab', { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + }, + activeLeafId: LEFT, + expandedLeafId: null, + chatLeafId: RIGHT + }) +}) +afterEach(cleanup) + +describe('terminal chat ownership lifecycle', () => { + it('batches a pane toggle into chat and back without selecting the active sibling', () => { + mocks.state.setTabViewMode('tab', 'terminal') + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, null)) + act(() => hook.result.current.toggleNativeChatForLeaf(RIGHT)) + expect(hook.result.current.chatLeafId).toBe(RIGHT) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') + expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBe(RIGHT) + act(() => hook.result.current.toggleNativeChatForLeaf(RIGHT)) + expect(hook.result.current.chatLeafId).toBeNull() + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') + expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBeUndefined() + }) + + it('preserves the saved owner when this client disables native chat', () => { + const push = vi.fn() + const fixture = makeFixture(undefined, { push }) + mocks.state.terminalLayoutsByTabId.tab.ptyIdsByLeafId = { + [LEFT]: 'remote:host:terminal', + [RIGHT]: 'remote:host:chat' + } + const hook = renderHook(() => useFixture(fixture, RIGHT)) + mocks.state.settings.experimentalNativeChat = false + hook.rerender() + hook.result.current.persistLayoutSnapshot() + expect(hook.result.current.effectiveChatViewMode).toBe(false) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') + expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBe(RIGHT) + expect(push).toHaveBeenLastCalledWith( + expect.objectContaining({ + layout: expect.objectContaining({ chatLeafId: RIGHT }) + }) + ) + }) + + it('clears ownership when the tab-bar turns chat off, then targets the active sibling', () => { + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, RIGHT)) + act(() => mocks.state.setTabViewMode('tab', 'terminal')) + hook.rerender() + expect(hook.result.current.chatLeafId).toBeNull() + expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBeUndefined() + act(() => mocks.state.setTabViewMode('tab', 'chat')) + hook.rerender() + expect(hook.result.current.chatLeafId).toBe(LEFT) + }) + + it('keeps the current owner when a mount-time callback captures a pane for detaching', () => { + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, RIGHT)) + const mountedPersist = hook.result.current.persistLayoutSnapshot + act(() => hook.result.current.toggleNativeChatForLeaf(LEFT)) + expect(hook.result.current.chatLeafId).toBe(LEFT) + mountedPersist() + const detached = detachTerminalLayoutLeaf(mocks.state.terminalLayoutsByTabId.tab, LEFT) + expect(detached?.detachedLayout.chatLeafId).toBe(LEFT) + expect(detached?.sourceLayout.chatLeafId).toBeUndefined() + }) + + it('does not resurrect an owner after chat is turned off and the split is resized', () => { + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, RIGHT)) + const mountedPersist = hook.result.current.persistLayoutSnapshot + act(() => hook.result.current.switchNativeChatToTerminal()) + mountedPersist() + expect(mocks.state.terminalLayoutsByTabId.tab.chatLeafId).toBeUndefined() + }) + + it('closing the chat leaf leaves an eligible terminal sibling in terminal mode', () => { + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, RIGHT)) + fixture.managerRef.current.getPanes().pop()?.container.remove() + hook.rerender() + expect(hook.result.current.chatLeafId).toBeNull() + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') + }) + + it('restores chat on the right with the left active and different numeric pane IDs', () => { + const initialFixture = makeFixture() + const original = renderHook(() => useFixture(initialFixture, RIGHT)) + original.result.current.persistLayoutSnapshot() + const session = parseWorkspaceSession( + JSON.parse( + JSON.stringify({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab', + tabsByWorktree: {}, + terminalLayoutsByTabId: mocks.state.terminalLayoutsByTabId + }) + ) + ) + expect(session.ok).toBe(true) + if (!session.ok) { + throw new Error('session did not parse') + } + original.unmount() + const saved = session.value.terminalLayoutsByTabId.tab! + mocks.state.setTabLayout('tab', saved) + expect(saved.activeLeafId).toBe(LEFT) + expect(saved.root).toMatchObject({ first: { leafId: LEFT }, second: { leafId: RIGHT } }) + const restored = makeFixture([20, 10]) + const hook = renderHook(() => useFixture(restored, saved.chatLeafId ?? null)) + const chatPane = restored.managerRef.current + .getPanes() + .find((pane) => pane.leafId === hook.result.current.chatLeafId) + expect(chatPane?.id).toBe(10) + // Closing the restored chat leaf must leave the left terminal untouched. + restored.managerRef.current.getPanes().pop()?.container.remove() + hook.rerender() + expect(restored.managerRef.current.getPanes().map((pane) => pane.leafId)).toEqual([LEFT]) + expect(hook.result.current.chatLeafId).toBeNull() + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 8874150df89..06c3b979f63 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -149,7 +149,13 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController ) const applyNativeChatLeafRoute = useCallback( (route: NativeChatLeafRoute): void => { - if (!isChatViewMode && chatLeafId && route.chatLeafId === null) { + const state = useAppStore.getState() + const currentMode = selectUnifiedTerminalTabFields( + state.unifiedTabsByWorktree, + worktreeId, + tabId + ).isChatViewMode + if (!isChatViewMode && currentMode && chatLeafId && route.chatLeafId === null) { // Keep the owner through the batched toggle that turns chat mode on. return } @@ -171,7 +177,7 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController } }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - [chatLeafId, isChatViewMode, setTabLayout, setTabViewMode, tabId, unifiedTabId] + [chatLeafId, isChatViewMode, setTabLayout, setTabViewMode, tabId, unifiedTabId, worktreeId] ) const handleConfirmedAgentExit = useCallback( (leafId: string): void => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index 014d7b34e28..d981232874d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { useAppStore } from '../../store' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { serializeTerminalLayout } from './layout-serialization' @@ -18,7 +18,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup clearedScrollbackLeafIdsRef, chatLeafId, containerRef, - effectiveChatViewMode, + isChatViewMode, expandedPaneIdRef, managerRef, paneCount, @@ -34,6 +34,10 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup terminalTab, worktreeId } = controller + const chatOwnerRef = useRef({ chatLeafId, isChatViewMode }) + useLayoutEffect(() => { + chatOwnerRef.current = { chatLeafId, isChatViewMode } + }, [chatLeafId, isChatViewMode]) const persistLayoutSnapshot = useCallback((): void => { const manager = managerRef.current const container = containerRef.current @@ -51,11 +55,9 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup const existing = useAppStore.getState().terminalLayoutsByTabId[tabId] const currentPanes = manager.getPanes() const currentLeafIds = new Set(currentPanes.map((pane) => pane.leafId)) - if ( - effectiveChatViewMode && - chatLeafId && - currentPanes.some((pane) => pane.leafId === chatLeafId) - ) { + // PaneManager retains this callback for its entire mount. + const { chatLeafId, isChatViewMode } = chatOwnerRef.current + if (isChatViewMode && chatLeafId && currentPanes.some((pane) => pane.leafId === chatLeafId)) { layout.chatLeafId = chatLeafId } const clearedScrollbackLeafIds = clearedScrollbackLeafIdsRef.current @@ -129,7 +131,11 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup clearedScrollbackLeafIds.delete(leafId) } // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [chatLeafId, effectiveChatViewMode, tabId, setTabLayout, worktreeId]) + }, [tabId, setTabLayout, worktreeId]) + + useEffect(() => { + persistLayoutSnapshot() + }, [chatLeafId, isChatViewMode, persistLayoutSnapshot]) const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts index dccbb079f30..9ac662e89cc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-effects.ts @@ -34,7 +34,7 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl containerRef, expandedPaneId, expandedPaneIdRef, - effectiveChatViewMode, + isChatViewMode, isolatedPaneKey, isVisible, managerRef, @@ -217,7 +217,7 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl paneTransports: paneTransportsRef.current, paneTitlesByPaneId: paneTitlesRef.current, existingLayout: existing, - chatLeafId: effectiveChatViewMode ? chatLeafId : null, + chatLeafId: isChatViewMode ? chatLeafId : null, // Why resolved: a pane that serializes empty mid-replay must fall back to whichever home // holds its last good copy, not only the shared one. priorBuffersByLeafId: resolveLeafScrollbackBuffers({ @@ -250,14 +250,7 @@ export function useTerminalPaneTitleEffects(controller: TerminalPaneCloseControl } } // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. - }, [ - chatLeafId, - effectiveChatViewMode, - tabId, - worktreeId, - setTabLayout, - setTabLocalOnlyScrollback - ]) + }, [chatLeafId, isChatViewMode, tabId, worktreeId, setTabLayout, setTabLocalOnlyScrollback]) useEffect(() => { if (renamingPaneId === null) { diff --git a/src/renderer/src/runtime/web-runtime-terminal-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-actions.ts index 5c4f7b60781..e12e9941492 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-actions.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-actions.ts @@ -197,6 +197,7 @@ export async function updateWebRuntimePaneLayout(args: { tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null + chatLeafId?: string | null titlesByLeafId?: Record }): Promise { const environmentId = @@ -216,6 +217,7 @@ export async function updateWebRuntimePaneLayout(args: { tabId: hostTabId, root: args.root, expandedLeafId: args.expandedLeafId, + ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) }, timeoutMs: 15_000 diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts index d04f5443477..7947e236891 100644 --- a/src/shared/rpc-contract/session-tabs-schemas-params.ts +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -117,6 +117,7 @@ export const UpdatePaneLayout = WorktreeTabSelector.extend({ .pipe(z.string().min(1, 'Missing tab id')), root: z.union([z.null(), TerminalPaneLayoutNodeSchema]), expandedLeafId: z.string().max(128).nullable().optional(), + chatLeafId: z.string().max(128).nullable().optional(), titlesByLeafId: z.record(z.string(), z.string()).optional() }) From 14087c8e3205215d929e0b60677a998e11dff776 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:15 -0700 Subject: [PATCH 027/387] Accept repeated leading BOMs in agent hooks (#22414) Adapted from the investigation and proposal by @bbingz. Co-authored-by: bbingz --- src/relay/agent-hook-integration.test.ts | 71 +++++++++++++++++++ ...-hook-listener-interactive-prompts.test.ts | 44 ++++++------ .../agent-hook-listener-transport.test.ts | 61 +++++++++++++++- .../agent-hook-listener/request-body.ts | 10 +-- 4 files changed, 158 insertions(+), 28 deletions(-) diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index b5a0f1066e4..7e5b21365e1 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -168,6 +168,77 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot expect(payload.agentType).toBe(agent) }) + it.each([0, 1, 2, 3])( + 'delivers Cursor form payloads with %i BOMs to the host-owned status store', + async (count) => { + const { port, token } = hookServer.getCoordinates() + for (const [hookEventName, state] of [ + ['beforeSubmitPrompt', 'working'], + ['stop', 'done'] + ]) { + const payload = Buffer.concat([ + ...Array.from({ length: count }, () => Buffer.from([0xef, 0xbb, 0xbf])), + Buffer.from( + JSON.stringify({ + hook_event_name: hookEventName, + prompt: 'Synthetic café 😀', + status: 'completed' + }) + ) + ]) + const response = await fetch(`http://127.0.0.1:${port}/hook/cursor`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': token + }, + body: new URLSearchParams({ + paneKey: `tab-7:${LEAF_7}`, + worktreeId: 'folder:synthetic-cursor', + payload: payload.toString('utf8') + }).toString() + }) + expect(response.status).toBe(204) + await expect + .poll(() => orcaServer.getStatusSnapshot()) + .toEqual([ + expect.objectContaining({ + paneKey: `tab-7:${LEAF_7}`, + worktreeId: 'folder:synthetic-cursor', + connectionId: 'conn-test', + state, + agentType: 'cursor', + prompt: 'Synthetic café 😀' + }) + ]) + } + } + ) + + it('acknowledges malformed Cursor form payloads without publishing status', async () => { + const { port, token } = hookServer.getCoordinates() + for (const payload of [ + '', + '\uFEFF\uFEFFnot json', + ' \uFEFF{}', + '{\uFEFF"hook_event_name":"stop"}', + '\uFEFF\uFEFF{"hook_event_name":"unknown"}' + ]) { + const response = await fetch(`http://127.0.0.1:${port}/hook/cursor`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': token + }, + body: new URLSearchParams({ paneKey: `tab-7:${LEAF_7}`, payload }).toString() + }) + expect(response.status).toBe(204) + } + await new Promise((resolve) => setImmediate(resolve)) + expect(hookServer.replayCachedPayloadsForPanes()).toBe(0) + expect(orcaServer.getStatusSnapshot()).toEqual([]) + }) + it('sheds an oversized assistant message through the production publication path', async () => { const events: { payload: { state: string; lastAssistantMessage?: string } }[] = [] orcaServer.setListener((event) => { diff --git a/src/shared/agent-hook-listener-interactive-prompts.test.ts b/src/shared/agent-hook-listener-interactive-prompts.test.ts index 214035ae575..ec8d7722c75 100644 --- a/src/shared/agent-hook-listener-interactive-prompts.test.ts +++ b/src/shared/agent-hook-listener-interactive-prompts.test.ts @@ -41,32 +41,34 @@ describe('shared agent-hook-listener', () => { expect(event!.payload.agentType).toBe('claude') }) - it('normalizes a BOM-prefixed Cursor hook payload to a working state', () => { - const event = normalizeHookPayload( - state, - 'cursor', - { - paneKey: PANE_KEY, - payload: '\uFEFF{"hook_event_name":"beforeSubmitPrompt","prompt":"Synthetic Cursor prompt"}' - }, - 'production' - ) + it.each([0, 1, 2, 3])( + 'normalizes %i leading Cursor BOMs to a working state (#21421)', + (count) => { + const event = normalizeHookPayload( + state, + 'cursor', + { + paneKey: PANE_KEY, + payload: `${'\uFEFF'.repeat(count)}{"hook_event_name":"beforeSubmitPrompt","prompt":"Synthetic Cursor prompt"}` + }, + 'production' + ) - expect(event?.payload).toMatchObject({ - agentType: 'cursor', - state: 'working', - prompt: 'Synthetic Cursor prompt' - }) - expect(event?.hookEventName).toBe('beforeSubmitPrompt') - }) + expect(event?.payload).toMatchObject({ + agentType: 'cursor', + state: 'working', + prompt: 'Synthetic Cursor prompt' + }) + expect(event?.hookEventName).toBe('beforeSubmitPrompt') + } + ) - // Why: pins the allowance to exactly one leading U+FEFF, so nobody widens it into a trim. - it('still rejects a hook payload that is malformed once the BOM is removed', () => { + // Only contiguous leading BOMs are transport framing; misplaced BOMs remain invalid JSON. + it('still rejects malformed JSON after leading BOMs are removed', () => { const bom = '\uFEFF' const body = '{"hook_event_name":"beforeSubmitPrompt"}' for (const payload of [ - `${bom}${bom}${body}`, - `${bom}not json`, + `${bom}${bom}not json`, ` ${bom}${body}`, `{"hook_event_name"${bom}:"beforeSubmitPrompt"}` ]) { diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index cf0a801ede1..50a4df2860b 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -11,6 +11,7 @@ import { } from './agent-hook-listener/endpoint-publication' import { HOOK_REQUEST_MAX_BYTES, + parseAgentHookJson, parseFormEncodedBody, readRequestBody } from './agent-hook-listener/request-body' @@ -161,12 +162,66 @@ describe('shared agent-hook-listener', () => { expectRequestParserListenersReleased(req) }) - it('strips exactly one outer JSON BOM', async () => { + it.each([0, 1, 2, 3])( + 'reads %i outer BOMs with every UTF-8 byte in a separate chunk', + async (count) => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + const bytes = Buffer.concat([ + ...Array.from({ length: count }, () => Buffer.from([0xef, 0xbb, 0xbf])), + Buffer.from('{"text":"café 日本語 😀"}') + ]) + for (const byte of bytes) { + req.emit('data', Buffer.from([byte])) + } + req.emit('end') + await expect(body).resolves.toEqual({ text: 'café 日本語 😀' }) + expectRequestParserListenersReleased(req) + } + ) + + it.each(['', '\uFEFF', '\uFEFF\uFEFF'])( + 'preserves JSON whitespace and string BOMs after prefix %j', + (prefix) => { + expect(parseAgentHookJson(`${prefix} \r\n\t{"text":"a\uFEFFb"} \t\r\n`)).toEqual({ + text: 'a\uFEFFb' + }) + } + ) + + it.each([ + '', + ' ', + '\uFEFF', + '\uFEFF\uFEFF', + '\uFEFF\uFEFFnot json', + ' \uFEFF{}', + '\uFEFF \uFEFF{}', + '{\uFEFF"ok":true}', + '{}\uFEFF', + '\uFEFF\uFEFF{"ok":}' + ])('rejects invalid JSON %j without trimming misplaced BOMs', (body) => { + expect(() => parseAgentHookJson(body)).toThrow() + }) + + it('keeps an empty HTTP body as an empty envelope', async () => { const req = createReadableRequest({ 'content-type': 'application/json' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing request harness supplies the headers and stream events used by the reader. const body = readRequestBody(req as unknown as IncomingMessage) - req.emit('data', Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"ok":true}')])) req.emit('end') - await expect(body).resolves.toEqual({ ok: true }) + await expect(body).resolves.toEqual({}) + expectRequestParserListenersReleased(req) + }) + + it('counts BOM bytes toward the cap across incremental chunks', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing request harness supplies the headers and stream events used by the reader. + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from([0xef, 0xbb, 0xbf, 0xef, 0xbb, 0xbf])) + req.emit('data', Buffer.from(JSON.stringify({ x: 'a'.repeat(HOOK_REQUEST_MAX_BYTES - 8) }))) + await expect(body).rejects.toThrow('payload too large') + expect(req.destroy).toHaveBeenCalledTimes(1) + expectRequestParserListenersReleased(req) }) it('rejects JSON beyond the nesting-depth limit', async () => { diff --git a/src/shared/agent-hook-listener/request-body.ts b/src/shared/agent-hook-listener/request-body.ts index b453c7c1f7e..88f01252783 100644 --- a/src/shared/agent-hook-listener/request-body.ts +++ b/src/shared/agent-hook-listener/request-body.ts @@ -12,10 +12,12 @@ const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { } as const export function parseAgentHookJson(content: string): unknown { - // Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF, - // so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other - // malformed payload rejected as before. - const normalizedContent = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content + // Cursor on Windows can pipe duplicated BOMs before the JSON (#21421). + let offset = 0 + while (offset < content.length && content.charCodeAt(offset) === 0xfeff) { + offset += 1 + } + const normalizedContent = offset > 0 ? content.slice(offset) : content assertJsonTextStructureWithinLimits(normalizedContent, AGENT_HOOK_JSON_STRUCTURE_LIMITS) return JSON.parse(normalizedContent) as unknown } From 7889a25b7f98ea24b8c7258d10e83dc0509ce21c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:18 -0700 Subject: [PATCH 028/387] fix(cli): preserve the WSL distro when adding managed accounts (#22418) Keep the caller distro across the Windows bridge, including drive-mounted working directories, and pass it through the existing account imports. Retain the contribution from PR #17093 and cover empty/space-containing bridge arguments, platform boundaries, and ambient environment conflicts. Co-authored-by: Joao Nicola --- src/cli/handlers/account-wsl-location.ts | 10 ++ src/cli/handlers/account.test.ts | 102 +++++++++++++++++- src/cli/handlers/account.ts | 9 +- .../cli/wsl-cli-powershell-boundary.test.ts | 85 +++++++++++++-- src/main/cli/wsl-cli-scripts.ts | 17 +++ 5 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 src/cli/handlers/account-wsl-location.ts diff --git a/src/cli/handlers/account-wsl-location.ts b/src/cli/handlers/account-wsl-location.ts new file mode 100644 index 00000000000..a38aa582672 --- /dev/null +++ b/src/cli/handlers/account-wsl-location.ts @@ -0,0 +1,10 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' + +// The bridge preserves the caller's distro even when /mnt/c makes cwd a Windows drive path. +export function getWslAccountTarget(cwd: string): { runtime: 'wsl'; wslDistro: string } | null { + const distro = + process.platform === 'win32' + ? process.env.ORCA_CLI_WSL_DISTRO?.trim() || parseWslUncPath(cwd)?.distro + : undefined + return distro ? { runtime: 'wsl', wslDistro: distro } : null +} diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index c35f2270e6c..9cecb6f12e4 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -97,14 +97,16 @@ describe('account CLI handlers', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! const originalElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE const originalPathAlias = process.env.Path + const originalWslDistroName = process.env.WSL_DISTRO_NAME + const originalBridgeDistro = process.env.ORCA_CLI_WSL_DISTRO const callMock = vi.fn() const client = { call: callMock } as unknown as RuntimeClient let logSpy: ReturnType - function context(agent: string, json = false): HandlerContext { + function context(agent: string, json = false, cwd = process.cwd()): HandlerContext { return { client, - cwd: process.cwd(), + cwd, flags: new Map([['agent', agent]]), json, rawArgs: [] @@ -136,6 +138,10 @@ describe('account CLI handlers', () => { ) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) process.env.ELECTRON_RUN_AS_NODE = '1' + // Why: a test run inside a real distro carries WSL_DISTRO_NAME, which would + // leak WSL attribution into every faked-win32 add below. + delete process.env.WSL_DISTRO_NAME + delete process.env.ORCA_CLI_WSL_DISTRO }) afterEach(() => { @@ -151,6 +157,16 @@ describe('account CLI handlers', () => { } else { process.env.Path = originalPathAlias } + if (originalBridgeDistro === undefined) { + delete process.env.ORCA_CLI_WSL_DISTRO + } else { + process.env.ORCA_CLI_WSL_DISTRO = originalBridgeDistro + } + if (originalWslDistroName === undefined) { + delete process.env.WSL_DISTRO_NAME + } else { + process.env.WSL_DISTRO_NAME = originalWslDistroName + } }) it('uses Codex device auth and keeps JSON stdout clean', async () => { @@ -308,6 +324,88 @@ describe('account CLI handlers', () => { expect(existsSync(configDir)).toBe(false) }) + it('attributes a Claude account added through the WSL bridge to the cwd distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await ACCOUNT_HANDLERS['account add']( + context('claude', false, String.raw`\\wsl.localhost\Ubuntu-22.04\home\user\project`) + ) + + const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR + expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { + configDir, + runtime: 'wsl', + wslDistro: 'Ubuntu-22.04' + }) + }) + + it('attributes a Codex account added through the WSL bridge to the cwd distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await ACCOUNT_HANDLERS['account add']( + context('codex', false, String.raw`\\wsl$\Ubuntu\home\user`) + ) + + const sourceHome = spawnMock.mock.calls[0]?.[2].env.CODEX_HOME + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', { + sourceHome, + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + }) + + it('uses the explicit bridge distro over an ambient environment or another distro cwd', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.WSL_DISTRO_NAME = 'stale-Windows-value' + process.env.ORCA_CLI_WSL_DISTRO = 'Debian' + + await ACCOUNT_HANDLERS['account add']( + context('claude', false, String.raw`\\wsl.localhost\Ubuntu-22.04\home\user`) + ) + + expect(callMock).toHaveBeenCalledWith( + 'accounts.addClaudeFromConfigDir', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Debian' }) + ) + }) + + it.each(['claude', 'codex'])( + 'attributes %s from a Windows mount to the bridge distro', + async (agent) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu Work' + await ACCOUNT_HANDLERS['account add'](context(agent, false, String.raw`C:\work with spaces`)) + expect(callMock).toHaveBeenCalledWith( + agent === 'claude' ? 'accounts.addClaudeFromConfigDir' : 'accounts.addCodexFromHome', + expect.objectContaining({ runtime: 'wsl', wslDistro: 'Ubuntu Work' }) + ) + } + ) + + it('ignores an ambient WSL distro in a native Windows account add', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.WSL_DISTRO_NAME = 'stale-Windows-value' + await ACCOUNT_HANDLERS['account add'](context('codex', false, String.raw`C:\work`)) + const sourceHome = spawnMock.mock.calls[0]?.[2].env.CODEX_HOME + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', { sourceHome }) + }) + + it.each(['linux', 'darwin'])( + 'keeps host attribution when the CLI runs on %s', + async (platform) => { + // Why: a Linux CLI talks to a Linux runtime whose accounts are host-lane; + // WSL_DISTRO_NAME there names the CLI's own environment, not a target lane. + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + process.env.WSL_DISTRO_NAME = 'Ubuntu-22.04' + process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu-22.04' + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + const sourceHome = spawnMock.mock.calls[0]?.[2].env.CODEX_HOME + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', { sourceHome }) + } + ) + it('waits for physical child close before removing interrupted login credentials', async () => { // Why: deleting first lets the still-live login recreate credentials afterward. Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 5a8bd4d3c6c..b2a06c6d2e2 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -34,6 +34,7 @@ import { type InteractiveLoginSession, withInteractiveLoginCleanup } from './interactive-login-interruption' +import { getWslAccountTarget } from './account-wsl-location' // Why: add returns just that provider's state; list returns the full snapshot. type AccountsListSnapshot = { @@ -194,7 +195,7 @@ async function cleanupClaudeLoginArtifacts( } /** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ -async function addClaudeAccount({ client, json }: HandlerContext): Promise { +async function addClaudeAccount({ client, cwd, json }: HandlerContext): Promise { const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) const session: InteractiveLoginSession = { child: null, @@ -225,6 +226,7 @@ async function addClaudeAccount({ client, json }: HandlerContext): Promise session.registering = true return client.call('accounts.addClaudeFromConfigDir', { configDir, + ...getWslAccountTarget(cwd), ...(process.platform === 'darwin' ? { previousLegacyCredentialsSha256: legacyCredentials @@ -239,7 +241,7 @@ async function addClaudeAccount({ client, json }: HandlerContext): Promise } /** Logs into a Codex account in a temp CODEX_HOME, then registers it with the local runtime. */ -async function addCodexAccount({ client, json }: HandlerContext): Promise { +async function addCodexAccount({ client, cwd, json }: HandlerContext): Promise { const codexHome = mkdtempSync(join(tmpdir(), 'orca-account-add-codex-')) const session: InteractiveLoginSession = { child: null, @@ -263,7 +265,8 @@ async function addCodexAccount({ client, json }: HandlerContext): Promise ) session.registering = true return client.call('accounts.addCodexFromHome', { - sourceHome: codexHome + sourceHome: codexHome, + ...getWslAccountTarget(cwd) }) } ) diff --git a/src/main/cli/wsl-cli-powershell-boundary.test.ts b/src/main/cli/wsl-cli-powershell-boundary.test.ts index ddfdabe9ad3..6d9798bf38a 100644 --- a/src/main/cli/wsl-cli-powershell-boundary.test.ts +++ b/src/main/cli/wsl-cli-powershell-boundary.test.ts @@ -1,9 +1,10 @@ import { spawnSync } from 'node:child_process' -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises' import { removeTree } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' import { buildWslBridgeScript, buildWslLauncher } from './wsl-cli-scripts' const FORWARDED_ARGS = [ @@ -32,6 +33,58 @@ const FORWARDED_ARGS = [ ] describe('WSL CLI PowerShell boundary', () => { + it.skipIf(process.platform === 'win32')( + 'forwards the distro as one argument and omits it when absent', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-launcher-argv-')) + try { + const launcherPath = join(root, 'launcher.sh') + const expectedCwd = await realpath(root) + await writeFile(launcherPath, buildWslLauncher('C:\\Orca\\orca.exe', '/bridge.ps1')) + await writeFile(join(root, 'wslpath'), '#!/bin/bash\nprintf "%s" "$2"\n', { + mode: 0o700 + }) + await writeFile(join(root, 'powershell.exe'), '#!/bin/bash\nprintf "%s\\0" "$@"\n', { + mode: 0o700 + }) + for (const distro of [undefined, '', 'Ubuntu Work']) { + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${root}:${process.env.PATH ?? ''}` + } + delete env.WSL_DISTRO_NAME + if (distro !== undefined) { + env.WSL_DISTRO_NAME = distro + } + const result = runProcessSync({ + program: '/bin/bash', + args: [launcherPath, 'account', 'add', '--agent', 'codex'], + env, + cwd: root + }) + expect(result.code).toBe(0) + expect(result.stdout.split('\0').slice(0, -1)).toEqual([ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + '/bridge.ps1', + 'C:\\Orca\\orca.exe', + '-WslCwd', + expectedCwd, + ...(distro ? ['-WslDistro', distro] : []), + 'account', + 'add', + '--agent', + 'codex' + ]) + } + } finally { + await removeTree(root) + } + } + ) + it('keeps forwarded argv outside PowerShell parsing', () => { const launcher = buildWslLauncher('C:\\Program Files\\Orca\\orca.exe') const bridge = buildWslBridgeScript() @@ -58,21 +111,33 @@ describe('WSL CLI PowerShell boundary', () => { await writeFile(bridgePath, buildWslBridgeScript(), 'utf8') await writeFile( targetPath, - 'process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.env.ORCA_CLI_CWD ?? null }))\n', + 'process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.env.ORCA_CLI_CWD ?? null, distro: process.env.ORCA_CLI_WSL_DISTRO ?? null }))\n', 'utf8' ) const invocations = [ + { + bridgeArgs: [ + process.execPath, + '-WslCwd', + wslCwd, + '-WslDistro', + 'Ubuntu Work', + targetPath, + ...FORWARDED_ARGS + ], + expected: { argv: FORWARDED_ARGS, cwd: wslCwd, distro: 'Ubuntu Work' } + }, { bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath, ...FORWARDED_ARGS], - expected: { argv: FORWARDED_ARGS, cwd: wslCwd } + expected: { argv: FORWARDED_ARGS, cwd: wslCwd, distro: null } }, { bridgeArgs: [process.execPath, '-WslCwd', wslCwd, targetPath], - expected: { argv: [], cwd: wslCwd } + expected: { argv: [], cwd: wslCwd, distro: null } }, { bridgeArgs: [process.execPath, targetPath, ...FORWARDED_ARGS], - expected: { argv: FORWARDED_ARGS, cwd: null } + expected: { argv: FORWARDED_ARGS, cwd: null, distro: null } } ] for (const { bridgeArgs, expected } of invocations) { @@ -87,7 +152,15 @@ describe('WSL CLI PowerShell boundary', () => { bridgePath, ...bridgeArgs ], - { encoding: 'utf8', env: { ...process.env, ORCA_CLI_CWD: 'stale' } } + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_CLI_CWD: 'stale', + ORCA_CLI_WSL_DISTRO: 'stale-distro', + WSL_DISTRO_NAME: 'wrong-distro' + } + } ) expect(result.error).toBeUndefined() diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 8875a81d18e..62482221d8d 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -28,6 +28,9 @@ ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { } ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD") +if [ -n "\${WSL_DISTRO_NAME:-}" ]; then + set -- -WslDistro "$WSL_DISTRO_NAME" "$@" +fi exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@" ` } @@ -71,6 +74,7 @@ try { } [string]$OrcaLauncher = $args[0] [string]$WslCwd = '' + [string]$WslDistro = '' [int]$ForwardArgStart = 1 if ($args.Count -ge 2 -and $args[1] -eq '-WslCwd') { if ($args.Count -lt 3) { @@ -79,6 +83,13 @@ try { $WslCwd = $args[2] $ForwardArgStart = 3 } + if ($ForwardArgStart -eq 3 -and $args.Count -ge 4 -and $args[3] -eq '-WslDistro') { + if ($args.Count -lt 5) { + throw 'Invalid Orca WSL CLI bridge invocation.' + } + $WslDistro = $args[4] + $ForwardArgStart = 5 + } [string[]]$ForwardArgs = @() if ($args.Count -gt $ForwardArgStart) { $ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)]) @@ -88,6 +99,12 @@ try { } else { $env:ORCA_CLI_CWD = $WslCwd } + # Do not let an inherited Windows environment choose the caller's account location. + if ([string]::IsNullOrEmpty($WslDistro)) { + Remove-Item Env:ORCA_CLI_WSL_DISTRO -ErrorAction SilentlyContinue + } else { + $env:ORCA_CLI_WSL_DISTRO = $WslDistro + } $LauncherDirectory = Split-Path -Parent $OrcaLauncher Push-Location -LiteralPath $LauncherDirectory # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. From 9d5591b791c1b20cb1d669f4cf269c3dafa6b7b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:21 -0700 Subject: [PATCH 029/387] fix(jira): bypass collection caches on explicit refresh (#22419) * fix: refresh Jira lists immediately Based on the report and proposal by @chaiyapod. Co-authored-by: chaiyapod * fix(jira): ignore superseded collection auth failures --------- Co-authored-by: chaiyapod --- .../use-task-page-jira-list-effects.test.ts | 83 +++++++++++ .../use-task-page-jira-list-effects.ts | 11 +- .../slices/jira-collection-read-actions.ts | 28 ++-- .../src/store/slices/jira-slice-contract.ts | 5 +- src/renderer/src/store/slices/jira.test.ts | 141 ++++++++++++++++++ 5 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/use-task-page-jira-list-effects.test.ts diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts new file mode 100644 index 00000000000..9a1f6e4973e --- /dev/null +++ b/src/renderer/src/components/use-task-page-jira-list-effects.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects' +import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' + +const listJiraIssues = vi.fn().mockResolvedValue([]) +const searchJiraIssues = vi.fn().mockResolvedValue([]) + +function createModel(): TaskPageLinearCollectionEffectsModel { + const model = { + settings: null, + setTaskResumeState: vi.fn(), + searchJiraIssues, + listJiraIssues, + jiraConnected: true, + selectedJiraSiteId: 'site-1', + taskSource: 'jira', + jiraTaskSourceContext: null, + jiraTaskSourceScopeKey: 'fixture', + jiraSearchPersistReadyRef: { current: true }, + taskResumeApplied: true, + selectedJiraIssueKey: null, + setSelectedJiraIssueKey: vi.fn(), + selectedJiraIssueFallback: null, + setSelectedJiraIssueFallback: vi.fn(), + setJiraIssues: vi.fn(), + setJiraLoading: vi.fn(), + setJiraError: vi.fn(), + setJiraErrorDetailsOpen: vi.fn(), + jiraSearchInput: '', + appliedJiraSearch: '', + setAppliedJiraSearch: vi.fn(), + activeJiraPreset: 'assigned', + jiraRefreshNonce: 0, + setJiraProjectStatusOrder: vi.fn(), + displayedJiraIssues: [] + } + // SAFETY: the hook only reads the Jira fields above; the preceding pipeline model is intentionally not constructed in this isolated wiring test. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the partial fixture intentionally models only fields read by this hook. + return model as unknown as TaskPageLinearCollectionEffectsModel +} + +describe('useTaskPageJiraListEffects refresh wiring', () => { + beforeEach(() => { + vi.clearAllMocks() + listJiraIssues.mockResolvedValue([]) + searchJiraIssues.mockResolvedValue([]) + }) + + it('passes force only for a refresh nonce change', async () => { + const model = createModel() + const { rerender } = renderHook(() => useTaskPageJiraListEffects(model)) + + await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(1)) + expect(listJiraIssues).toHaveBeenNthCalledWith(1, 'assigned', 50, { + sourceContext: null, + force: false + }) + + await act(async () => { + model.jiraRefreshNonce = 1 + rerender() + await Promise.resolve() + }) + await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(2)) + expect(listJiraIssues).toHaveBeenNthCalledWith(2, 'assigned', 50, { + sourceContext: null, + force: true + }) + + await act(async () => { + model.activeJiraPreset = 'reported' + rerender() + await Promise.resolve() + }) + await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(3)) + expect(listJiraIssues).toHaveBeenNthCalledWith(3, 'reported', 50, { + sourceContext: null, + force: false + }) + }) +}) diff --git a/src/renderer/src/components/use-task-page-jira-list-effects.ts b/src/renderer/src/components/use-task-page-jira-list-effects.ts index b39a145bd89..cd0f9a067bf 100644 --- a/src/renderer/src/components/use-task-page-jira-list-effects.ts +++ b/src/renderer/src/components/use-task-page-jira-list-effects.ts @@ -1,5 +1,5 @@ import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { getSingleJiraProjectScope, getTaskPageJiraStatusOrderScopeKey, @@ -57,6 +57,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect jiraQuery: appliedJiraSearch.trim() }) }, [appliedJiraSearch, setTaskResumeState, taskResumeApplied, jiraSearchPersistReadyRef]) + const previousJiraRefreshNonceRef = useRef(jiraRefreshNonce) useEffect(() => { if (!taskResumeApplied) { return @@ -67,6 +68,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect if (!jiraConnected) { return } + const force = previousJiraRefreshNonceRef.current !== jiraRefreshNonce + previousJiraRefreshNonceRef.current = jiraRefreshNonce let cancelled = false setJiraLoading(true) setJiraError(null) @@ -75,10 +78,12 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect const request = trimmed.length > 0 ? searchJiraIssues(trimmed, JIRA_ITEM_LIMIT, { - sourceContext: jiraTaskSourceContext + sourceContext: jiraTaskSourceContext, + force }) : listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, { - sourceContext: jiraTaskSourceContext + sourceContext: jiraTaskSourceContext, + force }) void request .then((issues) => { diff --git a/src/renderer/src/store/slices/jira-collection-read-actions.ts b/src/renderer/src/store/slices/jira-collection-read-actions.ts index c604b92a501..b79d358cea5 100644 --- a/src/renderer/src/store/slices/jira-collection-read-actions.ts +++ b/src/renderer/src/store/slices/jira-collection-read-actions.ts @@ -42,19 +42,15 @@ function handleJiraCollectionReadError( mutationGeneration: number, set: JiraSliceSet, get: JiraSliceGet, - options?: { abortable?: boolean } + options: { abortable?: boolean; currentRequest: boolean } ): JiraIssue[] { - if ( - isIntegrationCredentialDecryptionError(error) && - canWriteCollectionResult(scope, mutationGeneration, get) - ) { + const canWrite = + options.currentRequest && canWriteCollectionResult(scope, mutationGeneration, get) + if (isIntegrationCredentialDecryptionError(error) && canWrite) { if (!shouldRefreshJiraStatusAfterRead(siteId, get().jiraStatus, options)) { void get().checkJiraConnection() } - } else if ( - looksLikeJiraAuthError(error) && - canWriteCollectionResult(scope, mutationGeneration, get) - ) { + } else if (looksLikeJiraAuthError(error) && canWrite) { markJiraConnectionLost(set, scope) } if (isIntegrationCredentialDecryptionError(error) || looksLikeJiraAuthError(error)) { @@ -74,7 +70,7 @@ export function createJiraCollectionReadActions( options && 'siteId' in options ? options.siteId : getSelectedJiraSiteId(get().jiraStatus) const cacheKey = scopedJiraCacheKey(scope, `${siteId ?? 'default'}::${jql}::${limit}`) const cached = get().jiraSearchCache[cacheKey] - if (isFreshJiraCacheEntry(cached)) { + if (!options?.force && isFreshJiraCacheEntry(cached)) { return cached.data ?? [] } const inflight = inflightSearchRequests.get(cacheKey) @@ -82,6 +78,7 @@ export function createJiraCollectionReadActions( const requestMutationGeneration = currentJiraMutationGeneration() if ( !abortable && + !options?.force && inflight && inflight.contextKey === scope.contextKey && inflight.mutationGeneration === requestMutationGeneration @@ -119,7 +116,10 @@ export function createJiraCollectionReadActions( requestMutationGeneration, set, get, - { abortable } + { + abortable, + currentRequest: abortable || inflightSearchRequests.get(cacheKey) === entry + } ) }) .finally(() => { @@ -153,12 +153,13 @@ export function createJiraCollectionReadActions( `${siteId ?? 'default'}::list::${filter}::${limit}` ) const cached = get().jiraSearchCache[cacheKey] - if (isFreshJiraCacheEntry(cached)) { + if (!options?.force && isFreshJiraCacheEntry(cached)) { return cached.data ?? [] } const inflight = inflightListRequests.get(cacheKey) const requestMutationGeneration = currentJiraMutationGeneration() if ( + !options?.force && inflight && inflight.contextKey === scope.contextKey && inflight.mutationGeneration === requestMutationGeneration @@ -189,7 +190,8 @@ export function createJiraCollectionReadActions( siteId, requestMutationGeneration, set, - get + get, + { currentRequest: inflightListRequests.get(cacheKey) === entry } ) }) .finally(() => { diff --git a/src/renderer/src/store/slices/jira-slice-contract.ts b/src/renderer/src/store/slices/jira-slice-contract.ts index c33a3068c7a..0ffc9d04d36 100644 --- a/src/renderer/src/store/slices/jira-slice-contract.ts +++ b/src/renderer/src/store/slices/jira-slice-contract.ts @@ -16,7 +16,8 @@ export type JiraReadOptions = { siteId?: JiraSiteSelection | null } -export type JiraSearchOptions = JiraReadOptions & { signal?: AbortSignal } +export type JiraCollectionReadOptions = JiraReadOptions & { force?: boolean } +export type JiraSearchOptions = JiraCollectionReadOptions & { signal?: AbortSignal } export type JiraPatchOptions = { sourceContext?: TaskSourceContext | null } export type JiraIssueSummaryLookupOptions = { force?: boolean; signal?: AbortSignal } @@ -61,7 +62,7 @@ export type JiraSlice = { listJiraIssues: ( filter?: JiraIssueFilter, limit?: number, - options?: JiraReadOptions + options?: JiraCollectionReadOptions ) => Promise patchJiraIssue: (issueKey: string, patch: Partial, options?: JiraPatchOptions) => void } diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index 1e6d0fc7234..48f46e2e7e3 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -10,6 +10,7 @@ import { import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { createJiraSlice } from './jira' +import { beginJiraMutation } from './jira-read-coordination' const jiraStatus = vi.fn() const jiraConnect = vi.fn() @@ -212,6 +213,146 @@ describe('createJiraSlice runtime context', () => { expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined() }) + it('forces fresh list and search reads while keeping ordinary cache reads', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }, + jiraSearchCache: { + 'site-1::list::assigned::30': { data: [issue('ALP-OLD')], fetchedAt: Date.now() }, + 'site-1::project = ALP::30': { data: [issue('ALP-OLD')], fetchedAt: Date.now() } + } + }) + jiraListIssues.mockResolvedValueOnce([issue('ALP-LIST-FRESH')]) + jiraSearchIssues.mockResolvedValueOnce([issue('ALP-SEARCH-FRESH')]) + + await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([ + { key: 'ALP-OLD' } + ]) + await expect( + store.getState().listJiraIssues('assigned', 30, { force: true }) + ).resolves.toMatchObject([{ key: 'ALP-LIST-FRESH' }]) + await expect(store.getState().searchJiraIssues('project = ALP', 30)).resolves.toMatchObject([ + { key: 'ALP-OLD' } + ]) + await expect( + store.getState().searchJiraIssues('project = ALP', 30, { force: true }) + ).resolves.toMatchObject([{ key: 'ALP-SEARCH-FRESH' }]) + + expect(jiraListIssues).toHaveBeenCalledTimes(1) + expect(jiraSearchIssues).toHaveBeenCalledTimes(1) + expect(store.getState().jiraSearchCache['site-1::list::assigned::30']?.data).toMatchObject([ + { key: 'ALP-LIST-FRESH' } + ]) + expect(store.getState().jiraSearchCache['site-1::project = ALP::30']?.data).toMatchObject([ + { key: 'ALP-SEARCH-FRESH' } + ]) + }) + + it('reuses ordinary in-flight reads but force starts a fresh list and search request', async () => { + const store = createTestStore() + store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) + const listOld = deferred() + const listFresh = deferred() + const searchOld = deferred() + const searchFresh = deferred() + jiraListIssues.mockReturnValueOnce(listOld.promise).mockReturnValueOnce(listFresh.promise) + jiraSearchIssues.mockReturnValueOnce(searchOld.promise).mockReturnValueOnce(searchFresh.promise) + + const listRequest = store.getState().listJiraIssues('assigned', 30) + const listReuse = store.getState().listJiraIssues('assigned', 30) + const searchRequest = store.getState().searchJiraIssues('project = ALP', 30) + const searchReuse = store.getState().searchJiraIssues('project = ALP', 30) + await Promise.resolve() + expect(jiraListIssues).toHaveBeenCalledTimes(1) + expect(jiraSearchIssues).toHaveBeenCalledTimes(1) + + const listRefresh = store.getState().listJiraIssues('assigned', 30, { force: true }) + const searchRefresh = store.getState().searchJiraIssues('project = ALP', 30, { force: true }) + await Promise.resolve() + expect(jiraListIssues).toHaveBeenCalledTimes(2) + expect(jiraSearchIssues).toHaveBeenCalledTimes(2) + + listFresh.resolve([issue('ALP-LIST-FRESH')]) + searchFresh.resolve([issue('ALP-SEARCH-FRESH')]) + await expect(listRefresh).resolves.toMatchObject([{ key: 'ALP-LIST-FRESH' }]) + await expect(searchRefresh).resolves.toMatchObject([{ key: 'ALP-SEARCH-FRESH' }]) + listOld.resolve([issue('ALP-LIST-LATE')]) + searchOld.resolve([issue('ALP-SEARCH-LATE')]) + await expect(listRequest).resolves.toMatchObject([{ key: 'ALP-LIST-LATE' }]) + await expect(listReuse).resolves.toMatchObject([{ key: 'ALP-LIST-LATE' }]) + await expect(searchRequest).resolves.toMatchObject([{ key: 'ALP-SEARCH-LATE' }]) + await expect(searchReuse).resolves.toMatchObject([{ key: 'ALP-SEARCH-LATE' }]) + + expect(store.getState().jiraSearchCache['site-1::list::assigned::30']?.data).toMatchObject([ + { key: 'ALP-LIST-FRESH' } + ]) + expect(store.getState().jiraSearchCache['site-1::project = ALP::30']?.data).toMatchObject([ + { key: 'ALP-SEARCH-FRESH' } + ]) + }) + + it.each(['list', 'search'] as const)( + 'ignores an older %s auth failure after a forced refresh succeeds', + async (kind) => { + const store = createTestStore() + store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) + let rejectOld: (error: Error) => void = () => {} + const old = new Promise((_resolve, reject) => { + rejectOld = reject + }) + const service = kind === 'list' ? jiraListIssues : jiraSearchIssues + service.mockReturnValueOnce(old).mockResolvedValueOnce([issue('ALP-FRESH')]) + const read = (force = false) => + kind === 'list' + ? store.getState().listJiraIssues('assigned', 30, { force }) + : store.getState().searchJiraIssues('project = ALP', 30, { force }) + const previous = read() + await expect(read(true)).resolves.toMatchObject([{ key: 'ALP-FRESH' }]) + rejectOld(new Error('401 Unauthorized')) + await expect(previous).resolves.toEqual([]) + expect(store.getState().jiraStatus.connected).toBe(true) + expect(store.getState().jiraConnectionRevisions).toEqual({}) + } + ) + + it('rejects a forced late write after the Jira mutation generation changes', async () => { + const store = createTestStore() + store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) + const pending = deferred() + jiraListIssues.mockReturnValueOnce(pending.promise) + + const request = store.getState().listJiraIssues('assigned', 30, { force: true }) + await Promise.resolve() + beginJiraMutation() + pending.resolve([issue('ALP-STALE')]) + await expect(request).resolves.toMatchObject([{ key: 'ALP-STALE' }]) + expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined() + }) + + it('does not cache a late abortable search response', async () => { + const store = createTestStore() + store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) + const pending = deferred() + let signal: AbortSignal | undefined + jiraSearchIssues.mockImplementationOnce( + (_settings: unknown, _jql: string, _limit: number, _site: string, provided: AbortSignal) => { + signal = provided + return pending.promise + } + ) + const controller = new AbortController() + const request = store + .getState() + .searchJiraIssues('project = ALP', 30, { signal: controller.signal }) + await Promise.resolve() + const rejection = expect(request).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + pending.resolve([issue('ALP-LATE')]) + await rejection + expect(signal?.aborted).toBe(true) + expect(store.getState().jiraSearchCache['site-1::project = ALP::30']).toBeUndefined() + }) + it('keeps isolated status failures from mutating the focused Jira Settings state', async () => { const store = createTestStore() const focusedStatus = { From 7d4413b3d7c605a720bc8d418369c0fe43d71fa9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:24 -0700 Subject: [PATCH 030/387] fix(pdf): keep the search counter in sync with selected matches (#22420) * fix: update the PDF search counter Co-authored-by: BM Cho Co-authored-by: makoto-developer <72484465+makoto-developer@users.noreply.github.com> * test(pdf): respect explicitly headful launch mode * test(pdf): wait for rendered folder search highlights * test(pdf): wait for rendered text before initial search --------- Co-authored-by: BM Cho Co-authored-by: makoto-developer <72484465+makoto-developer@users.noreply.github.com> --- .../src/components/editor/PdfFind.test.tsx | 153 ++++++++++++++++ .../src/components/editor/PdfFind.tsx | 38 ++-- tests/e2e/helpers/pdf-find-fixture.ts | 35 ++++ tests/e2e/pdf-find-counter.spec.ts | 168 ++++++++++++++++++ 4 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/editor/PdfFind.test.tsx create mode 100644 tests/e2e/helpers/pdf-find-fixture.ts create mode 100644 tests/e2e/pdf-find-counter.spec.ts diff --git a/src/renderer/src/components/editor/PdfFind.test.tsx b/src/renderer/src/components/editor/PdfFind.test.tsx new file mode 100644 index 00000000000..a08f2640458 --- /dev/null +++ b/src/renderer/src/components/editor/PdfFind.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +// PDF.js's legacy entry supplies the runtime polyfills for Node-based tests. +import 'pdfjs-dist/legacy/build/pdf.mjs' +import { EventBus, FindState } from 'pdfjs-dist/web/pdf_viewer.mjs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import PdfFind from './PdfFind' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: { value0: number; value1: number }) => + fallback + .replace('{{value0}}', String(values?.value0)) + .replace('{{value1}}', String(values?.value1)) +})) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function setup() { + const bus = new EventBus() + const eventBusRef = { current: bus } + const onClose = vi.fn() + const view = render() + const query = (value: string): void => { + fireEvent.change(view.getByPlaceholderText('Find in page...'), { target: { value } }) + } + const count = (current: number, total: number): void => { + act(() => + bus.dispatch('updatefindmatchescount', { source: bus, matchesCount: { current, total } }) + ) + } + const control = (current: number, total: number, state = FindState.FOUND): void => { + act(() => + bus.dispatch('updatefindcontrolstate', { + source: bus, + state, + previous: false, + entireWord: false, + rawQuery: 'needle', + matchesCount: { current, total } + }) + ) + } + const open = (isOpen: boolean): void => { + view.rerender() + } + return { bus, view, query, count, control, open, onClose } +} + +describe('PDF find event synchronization', () => { + it('follows progressive totals and control-state navigation, wrap, and no results', () => { + const { view, query, count, control } = setup() + query('needle') + count(1, 2) + expect(view.getByText('1 of 2')).toBeTruthy() + count(1, 6) + expect(view.getByText('1 of 6')).toBeTruthy() + control(2, 6) + expect(view.getByText('2 of 6')).toBeTruthy() + control(6, 6, FindState.WRAPPED) + expect(view.getByText('6 of 6')).toBeTruthy() + query('absent') + control(6, 6, FindState.PENDING) + control(0, 0, FindState.NOT_FOUND) + expect(view.getByText('No matches')).toBeTruthy() + query('beacon') + count(1, 2) + expect(view.getByText('1 of 2')).toBeTruthy() + }) + + it('subscribes before a synchronous cached result on reopening', () => { + const { bus, view, query, count, open } = setup() + query('needle') + count(2, 6) + open(false) + bus.on('find', () => { + bus.dispatch('updatefindcontrolstate', { + source: bus, + state: FindState.PENDING, + rawQuery: 'needle', + matchesCount: { current: 2, total: 6 } + }) + }) + open(true) + expect(view.getByText('2 of 6')).toBeTruthy() + }) + + it('removes both listeners on close and unmount without accumulating on reopen', () => { + const { view, open, query, control } = setup() + const on = vi.spyOn(EventBus.prototype, 'on') + const off = vi.spyOn(EventBus.prototype, 'off') + open(false) + expect(off.mock.calls.map(([name]) => name)).toEqual([ + 'updatefindmatchescount', + 'updatefindcontrolstate' + ]) + off.mockClear() + for (let cycle = 0; cycle < 3; cycle += 1) { + open(true) + query('needle') + control(3, 6) + expect(view.getByText('3 of 6')).toBeTruthy() + if (cycle < 2) { + open(false) + } + } + view.unmount() + expect(on.mock.calls).toHaveLength(6) + expect(off.mock.calls).toEqual(on.mock.calls) + }) + + it('keeps empty queries clear even if a pending scan reports late', () => { + const { view, query, count, control } = setup() + query('needle') + count(2, 6) + query('') + count(3, 6) + control(3, 6) + expect(view.queryByText('3 of 6')).toBeNull() + query('absent') + expect(view.getByText('No matches')).toBeTruthy() + }) + + it('dispatches next and previous from buttons and Enter without changing the query', () => { + const { view, query, onClose } = setup() + const dispatch = vi.spyOn(EventBus.prototype, 'dispatch') + query('needle') + const input = view.getByPlaceholderText('Find in page...') + fireEvent.click(view.getByTitle('Next match')) + fireEvent.click(view.getByTitle('Previous match')) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }) + expect(dispatch.mock.calls.filter(([name]) => name === 'find')).toEqual( + ['', 'again', 'again', 'again', 'again'].map((type, index) => [ + 'find', + { + source: null, + type, + query: 'needle', + highlightAll: true, + caseSensitive: false, + entireWord: false, + findPrevious: index === 2 || index === 4 + } + ]) + ) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/editor/PdfFind.tsx b/src/renderer/src/components/editor/PdfFind.tsx index 16b42e7effb..a186123bcfa 100644 --- a/src/renderer/src/components/editor/PdfFind.tsx +++ b/src/renderer/src/components/editor/PdfFind.tsx @@ -60,6 +60,27 @@ export default function PdfFind({ input.select() }, []) + // Subscribe before dispatching: PDF.js can report cached counts synchronously. + useEffect(() => { + const eventBus = eventBusRef.current + if (!eventBus || !isOpen) { + return + } + const handleMatchesCount = (evt: { + matchesCount: { current: number; total: number } + }): void => { + setActiveMatch(evt.matchesCount.current) + setTotalMatches(evt.matchesCount.total) + } + eventBus.on('updatefindmatchescount', handleMatchesCount) + // Navigation and zero-result searches report counts on the control-state event. + eventBus.on('updatefindcontrolstate', handleMatchesCount) + return () => { + eventBus.off('updatefindmatchescount', handleMatchesCount) + eventBus.off('updatefindcontrolstate', handleMatchesCount) + } + }, [eventBusRef, isOpen]) + useEffect(() => { if (!isOpen) { return @@ -74,23 +95,6 @@ export default function PdfFind({ dispatchFind('') }, [requestQuery, isOpen, dispatchFind, eventBusRef]) - useEffect(() => { - const eventBus = eventBusRef.current - if (!eventBus || !isOpen) { - return - } - const handleMatchesCount = (evt: { - matchesCount: { current: number; total: number } - }): void => { - setActiveMatch(evt.matchesCount.current) - setTotalMatches(evt.matchesCount.total) - } - eventBus.on('updatefindmatchescount', handleMatchesCount) - return () => { - eventBus.off('updatefindmatchescount', handleMatchesCount) - } - }, [eventBusRef, isOpen]) - const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation() diff --git a/tests/e2e/helpers/pdf-find-fixture.ts b/tests/e2e/helpers/pdf-find-fixture.ts new file mode 100644 index 00000000000..fb7d90d6292 --- /dev/null +++ b/tests/e2e/helpers/pdf-find-fixture.ts @@ -0,0 +1,35 @@ +export function createPdfFindFixture(): Buffer { + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>' + ] + for (let page = 0; page < 3; page += 1) { + const contentId = 5 + page * 2 + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentId} 0 R >>` + ) + const lines = [ + `PDF search fixture - page ${page + 1}`, + `needle result ${page * 2 + 1}`, + `needle result ${page * 2 + 2}`, + page === 1 ? 'Plain text without the alternate query' : 'beacon alternate query' + ] + const stream = `BT /F1 20 Tf 60 700 Td 40 TL ${lines.map((line) => `(${line}) Tj T*`).join(' ')} ET\n` + objects.push(`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}endstream`) + } + let pdf = '%PDF-1.4\n' + const offsets = [0] + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(pdf)) + pdf += `${index + 1} 0 obj\n${object}\nendobj\n` + } + const xref = Buffer.byteLength(pdf) + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + pdf += offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`) + .join('') + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` + return Buffer.from(pdf) +} diff --git a/tests/e2e/pdf-find-counter.spec.ts b/tests/e2e/pdf-find-counter.spec.ts new file mode 100644 index 00000000000..7a8d4b13b04 --- /dev/null +++ b/tests/e2e/pdf-find-counter.spec.ts @@ -0,0 +1,168 @@ +import { writeFileSync, rmSync, mkdtempSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { createPdfFindFixture } from './helpers/pdf-find-fixture' +import { pressShortcut } from './helpers/shortcuts' + +test('PDF counter follows navigation, new queries, and reopening', async ({ + orcaPage, + electronApp, + seededRepoPath, + registerPostElectronShutdownCleanup +}, testInfo) => { + const filePath = path.join(seededRepoPath, 'pdf-find-fixture.pdf') + writeFileSync(filePath, createPdfFindFixture()) + registerPostElectronShutdownCleanup(async () => rmSync(filePath, { force: true })) + await orcaPage.evaluate((filePath) => { + const state = window.__store?.getState() + if (!state?.activeWorktreeId) { + throw new Error('Missing fixture worktree') + } + state.openFile({ + filePath, + relativePath: 'pdf-find-fixture.pdf', + worktreeId: state.activeWorktreeId, + language: 'plaintext', + mode: 'edit' + }) + }, filePath) + await expect(orcaPage.locator('.pdfViewer .page')).toHaveCount(3) + await expect(orcaPage.locator('.pdfViewer .page').first().locator('.textLayer')).toContainText( + 'needle result 1' + ) + await pressShortcut(orcaPage, 'f') + const input = orcaPage.getByPlaceholder('Find in page...') + const bar = input.locator('..') + const selected = orcaPage.locator('.pdfViewer .highlight.selected') + const observations: object[] = [] + const capture = async (name: string, counter: string, selectedText?: string): Promise => { + if (selectedText) { + await expect(selected).toHaveCount(1) + await expect(selected.locator('..')).toHaveText(selectedText) + } + await expect.soft(bar).toContainText(counter, { timeout: 1000 }) + observations.push({ + name, + expected: counter, + actual: await bar.innerText(), + selected: await selected.locator('..').allTextContents() + }) + await orcaPage.screenshot({ path: testInfo.outputPath(`${name}.png`) }) + } + await input.fill('needle') + await capture('initial', '1 of 6', 'needle result 1') + await bar.getByTitle('Next match', { exact: true }).click() + await capture('next', '2 of 6', 'needle result 2') + await input.press('Enter') + await capture('enter', '3 of 6', 'needle result 3') + await input.press('Shift+Enter') + await capture('shift-enter', '2 of 6', 'needle result 2') + await bar.getByTitle('Previous match', { exact: true }).click() + await capture('previous', '1 of 6', 'needle result 1') + await bar.getByTitle('Previous match', { exact: true }).click() + await capture('wrap-previous', '6 of 6', 'needle result 6') + await input.press('Enter') + await capture('wrap-next', '1 of 6', 'needle result 1') + await input.fill('beacon') + await capture('query-change', '1 of 2', 'beacon alternate query') + await input.fill('absentword') + await expect(selected).toHaveCount(0) + await capture('no-results', 'No matches') + await input.fill('needle') + await capture('query-restored', '1 of 6', 'needle result 1') + await input.fill('beacon') + await input.fill('absentword') + await input.fill('needle') + // The final query is unchanged; let PDF.js's 250ms debounce finish before stepping. + await orcaPage.waitForTimeout(350) + await capture('rapid-query', '1 of 6', 'needle result 1') + await bar.getByTitle('Next match', { exact: true }).click() + await capture('before-close', '2 of 6', 'needle result 2') + await input.press('Escape') + await expect(input).toHaveCount(0) + await expect(selected).toHaveCount(0) + await pressShortcut(orcaPage, 'f') + await capture('reopened', '1 of 6', 'needle result 1') + await input.press('Enter') + await capture('reopened-enter', '2 of 6', 'needle result 2') + const windows = await electronApp.evaluate(({ BrowserWindow, app }) => ({ + appPath: app.getAppPath(), + windows: BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + })) + expect(windows.windows.every((window) => !window.focused)).toBe(true) + const headful = + process.env.ORCA_E2E_FORCE_HEADFUL === '1' || testInfo.project.metadata.orcaHeadful === true + if (process.env.ORCA_BACKGROUND_LAUNCH === '1' || !headful) { + expect(windows.windows.every((window) => !window.visible)).toBe(true) + } + writeFileSync( + testInfo.outputPath('observations.json'), + JSON.stringify({ windows, observations }, null, 2) + ) +}) + +test.describe('PDF in a folder workspace', () => { + test.use({ seedTestRepo: false }) + + test('finds and navigates a PDF without a Git repository', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const folderPath = mkdtempSync(path.join(os.tmpdir(), 'orca-pdf-folder-')) + const filePath = path.join(folderPath, 'pdf-find-fixture.pdf') + writeFileSync(filePath, createPdfFindFixture()) + registerPostElectronShutdownCleanup(async () => + rmSync(folderPath, { recursive: true, force: true }) + ) + await orcaPage.evaluate( + async ({ folderPath, filePath }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Missing fixture store') + } + const group = await window.api.projectGroups.create({ + name: 'PDF fixture', + parentPath: folderPath, + createdFrom: 'folder-scan' + }) + await state.fetchProjectGroups() + const folder = await state.createFolderWorkspace({ + projectGroupId: group.id, + name: 'PDF documents', + folderPath + }) + if (!folder) { + throw new Error('Missing fixture folder') + } + const worktreeId = `folder:${folder.id}` + state.setActiveWorktree(worktreeId) + state.openFile({ + filePath, + relativePath: 'pdf-find-fixture.pdf', + worktreeId, + language: 'plaintext', + mode: 'edit' + }) + }, + { folderPath, filePath } + ) + await expect(orcaPage.locator('.pdfViewer .page')).toHaveCount(3) + await expect(orcaPage.locator('.pdfViewer .page').first().locator('.textLayer')).toContainText( + 'needle result 1' + ) + await pressShortcut(orcaPage, 'f') + const input = orcaPage.getByPlaceholder('Find in page...') + await input.fill('needle') + await expect(input.locator('..')).toContainText('1 of 6') + const selected = orcaPage.locator('.pdfViewer .highlight.selected').locator('..') + await expect(selected).toHaveText('needle result 1') + await input.press('Enter') + await expect(input.locator('..')).toContainText('2 of 6') + await expect(selected).toHaveText('needle result 2') + await orcaPage.screenshot({ path: testInfo.outputPath('folder-next.png') }) + }) +}) From 18d00ccfe6daa509ab74522d15cf29066a7ebade Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:27 -0700 Subject: [PATCH 031/387] fix(projects): refresh stale automatic GitHub icons during enrichment (#22421) * fix: repair a bounded stale project-icon case Based on the report and proposal by @mmarabel. Co-authored-by: mmarabel * fix(projects): preserve peer-owned remote metadata * fix(projects): keep enrichment within the owning host --------- Co-authored-by: mmarabel --- .../repo-git-remote-avatar-refresh.test.ts | 313 ++++++++++++++++++ ...epo-git-remote-identity-enrichment.test.ts | 29 +- .../repo-git-remote-identity-enrichment.ts | 94 ++++-- 3 files changed, 403 insertions(+), 33 deletions(-) create mode 100644 src/main/repo-git-remote-avatar-refresh.test.ts diff --git a/src/main/repo-git-remote-avatar-refresh.test.ts b/src/main/repo-git-remote-avatar-refresh.test.ts new file mode 100644 index 00000000000..ab5eb7ae114 --- /dev/null +++ b/src/main/repo-git-remote-avatar-refresh.test.ts @@ -0,0 +1,313 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host' +import { deriveGitRemoteIdentity } from '../shared/git-remote-identity' +import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' +import { githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' +import type { Repo } from '../shared/repo-types' +import { probeGitRemoteIdentity, type GitRemoteIdentityProbe } from './repo-git-remote-identity' +import { + enrichMissingRepoGitRemoteIdentities, + flushRepoGitRemoteIdentityEnrichmentForTests, + resetRepoGitRemoteIdentityEnrichmentForTests +} from './repo-git-remote-identity-enrichment' + +vi.mock('./repo-git-remote-identity', () => ({ probeGitRemoteIdentity: vi.fn() })) + +function identity(remote = 'https://github.com/org-b/app.git') { + const parsed = deriveGitRemoteIdentity(`origin\t${remote} (fetch)`) + if (!parsed) { + throw new Error('Fixture remote must parse') + } + return parsed +} + +function repo(overrides: Partial = {}): Repo { + return { + id: 'app', + path: '/workspace/app', + displayName: 'app', + kind: 'git', + badgeColor: '', + addedAt: 1, + upstream: null, + gitRemoteIdentity: identity(), + repoIcon: githubAvatarIcon({ owner: 'owner-a', repo: 'app' }), + ...overrides + } +} + +function storeFor(repos: Repo[]) { + const getRepo = (id: string, hostId?: ExecutionHostId) => + repos.find((row) => row.id === id && (!hostId || getRepoExecutionHostId(row) === hostId)) + const updateRepo = vi.fn((id: string, updates: Partial, hostId?: ExecutionHostId) => { + const current = getRepo(id, hostId) + if (!current) { + return null + } + Object.assign(current, updates) + return current + }) + return { getRepos: () => repos, getRepo, updateRepo } +} + +async function sweep(store: ReturnType, onChanged = vi.fn()) { + enrichMissingRepoGitRemoteIdentities(store, { onChanged }) + for (let i = 0; i < 8; i++) { + await flushRepoGitRemoteIdentityEnrichmentForTests() + } +} + +async function refresh(store: ReturnType, onChanged = vi.fn()) { + vi.useFakeTimers() + vi.setSystemTime(1_000) + await sweep(store, onChanged) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + vi.setSystemTime(301_001) + await sweep(store, onChanged) +} + +afterEach(() => { + resetRepoGitRemoteIdentityEnrichmentForTests() + vi.useRealTimers() + vi.clearAllMocks() +}) + +it.each(['already-current', 'changed'])( + 'repairs a stale avatar when the canonical key is %s', + async (mode) => { + const local = repo({ + gitRemoteIdentity: + mode === 'changed' ? identity('https://github.com/owner-a/app.git') : identity() + }) + const peer = repo({ + id: 'peer', + path: '/workspace/app', + connectionId: 'build', + repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) + }) + const store = storeFor([local, peer]) + const onChanged = vi.fn() + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity() + }) + + expect(projectHostSetupProjectionFromRepos(store.getRepos()).projects).toHaveLength(2) + await refresh(store, onChanged) + + expect(local.repoIcon).toEqual(githubAvatarIcon({ owner: 'org-b', repo: 'app' })) + expect(store.updateRepo).toHaveBeenCalledTimes(1) + expect(onChanged).toHaveBeenCalledTimes(1) + const projected = projectHostSetupProjectionFromRepos(store.getRepos()) + expect(projected.projects.map(({ id }) => id)).toEqual(['github:org-b/app']) + expect(projected.setups.map(({ hostId, path }) => ({ hostId, path }))).toEqual([ + { hostId: 'local', path: '/workspace/app' }, + { hostId: 'ssh:build', path: '/workspace/app' } + ]) + vi.setSystemTime(301_001 + 6 * 60 * 60 * 1000) + await sweep(store, onChanged) + expect(store.updateRepo).toHaveBeenCalledTimes(1) + } +) + +it.each([ + 'https://github.company.test:8443/org-b/app.git', + 'ssh://git@ssh.github.com:443/org-b/app.git' +])('preserves GitHub endpoint identity for %s', async (remote) => { + const row = repo({ gitRemoteIdentity: identity(remote) }) + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity(remote) + }) + await refresh(store) + expect(projectHostSetupProjectionFromRepos([row]).projects[0]?.id).toBe( + remote.includes('company') ? 'github:github.company.test:8443/org-b/app' : 'github:org-b/app' + ) +}) + +it.each([ + 'git@github-work:org/app.git', + 'ssh://git@ghe-work/org/app.git', + 'https://gitlab.com/team/sub/app.git', + 'https://forgejo.test/team/app.git', + 'https://gitea.test/team/app.git', + 'https://code.company.test/team/app.git' +])('leaves provider metadata intact for unresolved or other-provider remote %s', async (remote) => { + const row = repo({ gitRemoteIdentity: identity(remote) }) + const original = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity(remote) + }) + await refresh(store) + expect(row.repoIcon).toBe(original) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +const customIcons: (RepoIcon | null | undefined)[] = [ + { type: 'emoji', emoji: '🐙' }, + { type: 'lucide', name: 'Folder' }, + { type: 'image', source: 'upload', src: 'data:image/png;base64,fixture', label: 'custom' }, + { type: 'image', source: 'file', src: 'data:image/png;base64,fixture', label: 'custom' }, + { type: 'image', source: 'favicon', src: 'https://website.test/favicon.png', label: 'custom' }, + null, + undefined +] + +it.each(customIcons)('preserves custom or cleared icon %j', async (repoIcon) => { + const row = repo({ repoIcon }) + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(row.repoIcon).toBe(repoIcon) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +it('preserves explicit upstream even when the remote disagrees', async () => { + const row = repo({ upstream: { owner: 'parent', repo: 'app', host: 'github.parent.test' } }) + const original = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(row.repoIcon).toBe(original) + expect(projectHostSetupProjectionFromRepos([row]).projects[0]?.id).toBe( + 'github:github.parent.test/parent/app' + ) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +it.each(['unavailable', 'no-remote'] as const)( + 'preserves the last avatar when a refresh is %s', + async (status) => { + const row = repo() + const original = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status }) + await refresh(store) + expect(row.repoIcon).toBe(original) + expect(store.updateRepo).not.toHaveBeenCalled() + } +) + +it.each(['same', 'different', 'missing'] as const)( + 'never probes or writes peer-owned metadata when identity is %s', + async (kind) => { + const row = repo({ + executionHostId: 'runtime:peer', + connectionId: 'nested', + gitRemoteIdentity: + kind === 'missing' + ? undefined + : identity(kind === 'different' ? 'https://github.com/peer-only/app.git' : undefined) + }) + const originalIcon = row.repoIcon + const originalIdentity = row.gitRemoteIdentity + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ + status: 'resolved', + identity: identity() + }) + await (kind === 'missing' ? sweep(store) : refresh(store)) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + expect(row.repoIcon).toBe(originalIcon) + expect(row.gitRemoteIdentity).toBe(originalIdentity) + expect(store.updateRepo).not.toHaveBeenCalled() + } +) + +it('repairs only the matching owner when repo IDs and paths collide across hosts', async () => { + const local = repo({ repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) }) + const ssh = repo({ executionHostId: 'ssh:build' }) + const store = storeFor([local, ssh]) + vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) + await refresh(store) + expect(store.updateRepo).toHaveBeenCalledWith( + 'app', + { repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) }, + 'ssh:build' + ) + expect(projectHostSetupProjectionFromRepos([local, ssh]).projects).toHaveLength(1) +}) + +it.each(['ssh:build', 'runtime:peer'] as const)( + 'keeps local metadata writes scoped when a same-id %s row comes first', + async (hostId) => { + const foreignIdentity = identity('https://github.com/foreign/app.git') + const foreignIcon = githubAvatarIcon({ owner: 'foreign', repo: 'app' }) + const foreign = repo({ + executionHostId: hostId, + gitRemoteIdentity: foreignIdentity, + repoIcon: foreignIcon + }) + const local = repo({ gitRemoteIdentity: identity('https://github.com/old-local/app.git') }) + const store = storeFor([foreign, local]) + vi.mocked(probeGitRemoteIdentity).mockImplementation(async (_path, probeHostId) => ({ + status: 'resolved', + identity: probeHostId === 'local' ? identity() : foreignIdentity + })) + await refresh(store) + expect(foreign.gitRemoteIdentity).toBe(foreignIdentity) + expect(foreign.repoIcon).toBe(foreignIcon) + expect(local.gitRemoteIdentity).toEqual(identity()) + expect(local.repoIcon).toEqual(githubAvatarIcon({ owner: 'org-b', repo: 'app' })) + expect(store.updateRepo).toHaveBeenCalledExactlyOnceWith( + 'app', + { gitRemoteIdentity: identity(), repoIcon: local.repoIcon }, + 'local' + ) + } +) + +it('does not write after a pending local probe becomes peer-owned', async () => { + let answer: ((value: GitRemoteIdentityProbe) => void) | undefined + const row = repo({ gitRemoteIdentity: undefined }) + const originalIcon = row.repoIcon + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + enrichMissingRepoGitRemoteIdentities(store) + row.executionHostId = 'runtime:peer' + if (!answer) { + throw new Error('Expected pending probe') + } + answer({ status: 'resolved', identity: identity() }) + await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(row.gitRemoteIdentity).toBeUndefined() + expect(row.repoIcon).toBe(originalIcon) + expect(store.updateRepo).not.toHaveBeenCalled() +}) + +it('does not overwrite a custom icon selected while the probe is pending', async () => { + let answer: ((value: GitRemoteIdentityProbe) => void) | undefined + const row = repo({ gitRemoteIdentity: undefined }) + const store = storeFor([row]) + vi.mocked(probeGitRemoteIdentity).mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + enrichMissingRepoGitRemoteIdentities(store) + const selected: RepoIcon = { type: 'emoji', emoji: '🐙' } + row.repoIcon = selected + if (!answer) { + throw new Error('Expected pending probe') + } + answer({ status: 'resolved', identity: identity() }) + await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(row.repoIcon).toBe(selected) + expect(store.updateRepo).toHaveBeenCalledWith('app', { gitRemoteIdentity: identity() }, 'local') +}) + +it('never probes folder workspaces for an avatar repair', async () => { + const store = storeFor([repo({ kind: 'folder' })]) + await refresh(store) + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + expect(store.updateRepo).not.toHaveBeenCalled() +}) diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index a52d110aa02..8012145fdbc 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -129,23 +129,16 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { await flushRepoGitRemoteIdentityEnrichmentForTests() }) - it('never hands a runtime row nested SSH target to this client dispatch table', async () => { - // Why: `connectionId` on a `runtime:` row names a target inside that server's namespace, so - // dialing it here reaches a same-named box of ours. The row keeps the probe this process has - // always run for it; what it must never do is dial our same-named target. + it('never probes a runtime row through this client dispatch table', async () => { vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'unavailable' }) const store = makeStore( makeRepo({ connectionId: 'nested-1', executionHostId: 'runtime:env-a' }) ) - enrichMissingRepoGitRemoteIdentities(store) + await sweep(store) - expect(probeGitRemoteIdentity).toHaveBeenCalledWith( - '/workspace/sample-app', - 'local', - expect.objectContaining({ signal: expect.any(AbortSignal) }) - ) - await flushRepoGitRemoteIdentityEnrichmentForTests() + expect(probeGitRemoteIdentity).not.toHaveBeenCalled() + expect(store.updateRepo).not.toHaveBeenCalled() }) it('keeps same-path rows on two different SSH hosts from sharing one backoff', async () => { @@ -208,7 +201,7 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: null }) + expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: null }, 'local') expect(repo.gitRemoteIdentity).toBeNull() }) @@ -243,7 +236,11 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: remoteIdentity }) + expect(store.updateRepo).toHaveBeenCalledWith( + 'repo-1', + { gitRemoteIdentity: remoteIdentity }, + 'local' + ) }) it('does not re-probe a resolved identity before the refresh window elapses', async () => { @@ -296,7 +293,11 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged }) await drainEnrichmentSweep() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: movedIdentity }) + expect(store.updateRepo).toHaveBeenCalledWith( + 'repo-1', + { gitRemoteIdentity: movedIdentity }, + 'local' + ) expect(repo.gitRemoteIdentity).toEqual(movedIdentity) expect(onChanged).toHaveBeenCalledTimes(1) }) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index a97961fe59f..4c81174a79b 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -5,6 +5,9 @@ import { type ExecutionHostId } from '../shared/execution-host' import type { Repo } from '../shared/repo-types' +import { githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' +import { isUnresolvedSshHostAlias } from '../shared/git-remote-host-alias' +import { getProjectProviderIdentity } from '../shared/project-host-setup-projection' import { probeGitRemoteIdentity } from './repo-git-remote-identity' const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000 @@ -21,8 +24,12 @@ const MAX_IDENTITY_REFRESHES_PER_SWEEP = 4 type RepoIdentityStore = { getRepos(): Repo[] - getRepo?(id: string): Repo | undefined - updateRepo(id: string, updates: Pick, 'gitRemoteIdentity'>): Repo | null + getRepo?(id: string, hostId?: ExecutionHostId): Repo | undefined + updateRepo( + id: string, + updates: Pick, 'gitRemoteIdentity' | 'repoIcon'>, + hostId?: ExecutionHostId + ): Repo | null } type EnrichmentOptions = { @@ -51,18 +58,22 @@ function getRepoLocationKey(repo: Pick repo.id === id) +function getCurrentRepo(store: RepoIdentityStore, snapshot: Repo): Repo | undefined { + const hostId = getRepoExecutionHostId(snapshot) + const found = store.getRepo?.(snapshot.id, hostId) + return found && getRepoExecutionHostId(found) === hostId + ? found + : store + .getRepos() + .find((repo) => repo.id === snapshot.id && getRepoExecutionHostId(repo) === hostId) } function isSameProbedRepo(snapshot: Repo, current: Repo | undefined): current is Repo { @@ -87,22 +98,65 @@ function shouldWriteProbedIdentity(current: Repo, probed: Repo['gitRemoteIdentit return !!probed && probed.canonicalKey !== existing.canonicalKey } +function getAutomaticGitHubIconRefresh( + current: Repo, + probed: NonNullable +): RepoIcon | undefined { + if ( + (current.upstream?.owner && current.upstream.repo) || + current.repoIcon?.type !== 'image' || + current.repoIcon.source !== 'github' + ) { + return undefined + } + const identity = getProjectProviderIdentity({ + upstream: null, + repoIcon: undefined, + gitRemoteIdentity: probed + }) + if (!identity || (identity.host && isUnresolvedSshHostAlias(identity.host))) { + return undefined + } + const icon = githubAvatarIcon(identity) + return icon.type === 'image' && + current.repoIcon.src === icon.src && + current.repoIcon.label === icon.label + ? undefined + : icon +} + function writeIdentity( store: RepoIdentityStore, snapshot: Repo, gitRemoteIdentity: Repo['gitRemoteIdentity'] ): boolean { - const current = getCurrentRepo(store, snapshot.id) - if ( - !isSameProbedRepo(snapshot, current) || - !shouldWriteProbedIdentity(current, gitRemoteIdentity) - ) { + // A peer's repo metadata must never be repaired from a client-local probe. + const hostId = getRepoProbeHostId(snapshot) + if (!hostId) { return false } - return !!store.updateRepo(snapshot.id, { gitRemoteIdentity }) + const current = getCurrentRepo(store, snapshot) + if (!isSameProbedRepo(snapshot, current)) { + return false + } + const writeRemote = shouldWriteProbedIdentity(current, gitRemoteIdentity) + const icon = gitRemoteIdentity + ? getAutomaticGitHubIconRefresh(current, gitRemoteIdentity) + : undefined + const update = (updates: Pick, 'gitRemoteIdentity' | 'repoIcon'>): Repo | null => { + return store.updateRepo(snapshot.id, updates, hostId) + } + if (icon) { + return !!update({ ...(writeRemote ? { gitRemoteIdentity } : {}), repoIcon: icon }) + } + return writeRemote && !!update({ gitRemoteIdentity }) } async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise { + const hostId = getRepoProbeHostId(repo) + if (!hostId) { + return false + } const locationKey = getRepoLocationKey(repo) const retryAfter = probeRetryAfterByLocation.get(locationKey) ?? 0 if (retryAfter > Date.now()) { @@ -119,7 +173,7 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) : NO_IDENTITY_RETRY_TTL_MS const controller = new AbortController() const promise = (async () => { - const result = await probeGitRemoteIdentity(repo.path, getRepoProbeHostId(repo), { + const result = await probeGitRemoteIdentity(repo.path, hostId, { signal: controller.signal }) // Why the signal and not a catch: probeGitRemoteIdentity swallows the AbortError and RESOLVES @@ -187,7 +241,9 @@ function retireRemovedLocations(allRepos: Repo[]): void { function selectEnrichmentCandidates(store: RepoIdentityStore): Repo[] { const now = Date.now() - const repos = store.getRepos().filter((repo) => repo.kind !== 'folder') + const repos = store + .getRepos() + .filter((repo) => repo.kind !== 'folder' && getRepoProbeHostId(repo) !== null) // Why: the settled `null` marker stays a candidate on purpose — a repo that // gains a remote later must still resolve. Do not tighten this to // `=== undefined`; the retry TTL already bounds the cost and `writeIdentity` From f851d18e9162d9afdf6d7ac76ea740d037e00d82 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:31 -0700 Subject: [PATCH 032/387] Preserve Kimi config permissions (#22422) Adapted from the investigation and proposal by @Pr1p. Co-authored-by: Pr1p --- .../hook-service-mode-read-failure.test.ts | 70 +++++++++++++++++++ src/main/kimi/hook-service.test.ts | 59 +++++++++++++++- src/main/kimi/hook-service.ts | 8 ++- 3 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/main/kimi/hook-service-mode-read-failure.test.ts diff --git a/src/main/kimi/hook-service-mode-read-failure.test.ts b/src/main/kimi/hook-service-mode-read-failure.test.ts new file mode 100644 index 00000000000..4e82137e832 --- /dev/null +++ b/src/main/kimi/hook-service-mode-read-failure.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const modeReadFailure = vi.hoisted((): { path: string | null } => ({ path: null })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const statSync = (target: NodeFs.PathLike): NodeFs.Stats | NodeFs.BigIntStats => { + if (typeof target === 'string' && target === modeReadFailure.path) { + throw new Error('mode unavailable') + } + return actual.statSync(target) + } + const patched = { ...actual, statSync } + return { ...patched, default: patched } +}) + +const fs = await vi.importActual('node:fs') +const { KimiHookService } = await import('./hook-service') + +let home: string +let originalHome: string | undefined +let originalKimiHome: string | undefined +let originalUserProfile: string | undefined + +const configPath = (): string => join(home, '.kimi-code', 'config.toml') + +beforeEach(() => { + home = fs.mkdtempSync(join(tmpdir(), 'orca-kimi-hook-mode-read-')) + originalHome = process.env.HOME + originalKimiHome = process.env.KIMI_CODE_HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.KIMI_CODE_HOME = join(home, '.kimi-code') + process.env.USERPROFILE = home +}) + +afterEach(() => { + modeReadFailure.path = null + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = originalKimiHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + fs.rmSync(home, { recursive: true, force: true }) +}) + +describe('KimiHookService config mode preservation', () => { + it('does not replace an existing config when its mode cannot be read', () => { + fs.mkdirSync(join(home, '.kimi-code'), { recursive: true }) + const original = 'api_key = "fixture-only"\n' + fs.writeFileSync(configPath(), original) + modeReadFailure.path = configPath() + + expect(() => new KimiHookService().install()).toThrow('mode unavailable') + expect(fs.readFileSync(configPath(), 'utf-8')).toBe(original) + }) +}) diff --git a/src/main/kimi/hook-service.test.ts b/src/main/kimi/hook-service.test.ts index 24a77444e19..a88c68f3d04 100644 --- a/src/main/kimi/hook-service.test.ts +++ b/src/main/kimi/hook-service.test.ts @@ -1,4 +1,13 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -8,17 +17,20 @@ import { KIMI_HOOK_EVENTS } from './kimi-hook-config-toml' // Why: getSharedManagedScriptPath() writes the managed script under // homedir()/.orca, and getKimiHome() honors KIMI_CODE_HOME. Point both at a // temp dir so the local install/remove cycle never touches the real ~/.orca or -// ~/.kimi-code. os.homedir() resolves $HOME on POSIX (verified at write time). +// ~/.kimi-code. os.homedir() resolves HOME on POSIX and USERPROFILE on Windows. let home: string let originalHome: string | undefined let originalKimiHome: string | undefined +let originalUserProfile: string | undefined beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'orca-kimi-hook-')) originalHome = process.env.HOME originalKimiHome = process.env.KIMI_CODE_HOME + originalUserProfile = process.env.USERPROFILE process.env.HOME = home process.env.KIMI_CODE_HOME = join(home, '.kimi-code') + process.env.USERPROFILE = home }) afterEach(() => { @@ -32,11 +44,17 @@ afterEach(() => { } else { process.env.KIMI_CODE_HOME = originalKimiHome } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } rmSync(home, { recursive: true, force: true }) }) const configPath = (): string => join(home, '.kimi-code', 'config.toml') const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'kimi-hook.sh') +const supportsPosixFileModes = process.platform !== 'win32' describe('KimiHookService', () => { it('reports not_installed before install', () => { @@ -89,4 +107,41 @@ describe('KimiHookService', () => { const afterRemove = readFileSync(configPath(), 'utf-8') expect(afterRemove).toBe(userConfig) }) + + it.skipIf(!supportsPosixFileModes)( + 'preserves an existing config mode while installing and removing hooks', + () => { + mkdirSync(join(home, '.kimi-code'), { recursive: true }) + writeFileSync(configPath(), 'api_key = "sk-secret"\n') + const existingMode = 0o640 + chmodSync(configPath(), existingMode) + + const service = new KimiHookService() + const originalUmask = process.umask(0o077) + try { + service.install() + expect(statSync(configPath()).mode & 0o777).toBe(existingMode) + expect(statSync(`${configPath()}.bak`).mode & 0o777).toBe(existingMode) + expect( + readdirSync(join(home, '.kimi-code')).filter((name) => name.endsWith('.tmp')) + ).toEqual([]) + + service.remove() + expect(statSync(configPath()).mode & 0o777).toBe(existingMode) + } finally { + process.umask(originalUmask) + } + } + ) + + it.skipIf(!supportsPosixFileModes)('creates a new config with an owner-only mode', () => { + const originalUmask = process.umask(0o022) + try { + new KimiHookService().install() + + expect(statSync(configPath()).mode & 0o777).toBe(0o600) + } finally { + process.umask(originalUmask) + } + }) }) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 21fee64d276..1df8ff968cf 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -1,9 +1,11 @@ import { copyFileSync, + chmodSync, existsSync, mkdirSync, readFileSync, renameSync, + statSync, unlinkSync, writeFileSync } from 'node:fs' @@ -137,6 +139,8 @@ function readConfigToml(configPath: string): string | null { function writeConfigToml(configPath: string, text: string): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) + // Why: renameSync replaces the inode, so the temp mode becomes the config mode. + let mode = 0o600 if (existsSync(configPath)) { try { if (readFileSync(configPath, 'utf-8') === text) { @@ -145,10 +149,12 @@ function writeConfigToml(configPath: string, text: string): void { } catch { // Fall through to the atomic write path. } + mode = statSync(configPath).mode & 0o777 } const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) try { - writeFileSync(tmpPath, text, 'utf-8') + writeFileSync(tmpPath, text, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(tmpPath, mode) if (existsSync(configPath)) { copyFileSync(configPath, `${configPath}.bak`) } From 094fbef08c8908452e123f87651c1959eaf5a21a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:34 -0700 Subject: [PATCH 033/387] fix(toast): keep folder errors above standard modal backdrops (#22423) * fix: make folder errors visible above dialogs Co-authored-by: midego <61051030+midego1@users.noreply.github.com> * test(toast): explicitly isolate background app launches --------- Co-authored-by: midego <61051030+midego1@users.noreply.github.com> --- src/renderer/src/assets/main.css | 5 + .../add-project-error-toast-stacking.spec.ts | 180 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 tests/e2e/add-project-error-toast-stacking.spec.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index e0d1791606b..9a35520a0bb 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -412,6 +412,11 @@ z-index: 40 !important; } +/* Keep errors above standard modal backdrops at the existing popover tier. */ +body:has([data-slot='dialog-overlay'], [data-slot='sheet-overlay']) [data-sonner-toaster] { + z-index: 60 !important; +} + [data-sonner-toaster] [data-sonner-toast][data-styled='true'] { align-items: flex-start; flex-wrap: wrap; diff --git a/tests/e2e/add-project-error-toast-stacking.spec.ts b/tests/e2e/add-project-error-toast-stacking.spec.ts new file mode 100644 index 00000000000..e482404367a --- /dev/null +++ b/tests/e2e/add-project-error-toast-stacking.spec.ts @@ -0,0 +1,180 @@ +import path from 'node:path' +import type { ElectronApplication, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' + +test.use({ + seedTestRepo: false, + testRepoPath: '', + orcaAppExtraEnv: { ORCA_BACKGROUND_LAUNCH: '1' } +}) + +async function assertHiddenIsolation(app: ElectronApplication, testInfo: TestInfo, name: string) { + const isolation = await app.evaluate(({ app, BrowserWindow }) => ({ + home: app.getPath('home'), + expectedHome: process.env.ORCA_E2E_HOME_DIR, + appPath: app.getAppPath(), + background: process.env.ORCA_BACKGROUND_LAUNCH, + windows: BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + })) + expect(isolation.home).toBe(isolation.expectedHome) + expect(isolation.background).toBe('1') + expect(isolation.appPath).toContain(process.cwd()) + expect(isolation.windows.every((window) => !window.visible && !window.focused)).toBe(true) + await testInfo.attach(name, { body: JSON.stringify(isolation), contentType: 'application/json' }) + return isolation.home +} + +for (const theme of ['dark', 'light'] as const) { + test(`invalid host project path stays readable over its dialog (${theme})`, async ({ + electronApp, + orcaPage + }, testInfo) => { + const hostHome = await assertHiddenIsolation(electronApp, testInfo, 'host-isolation') + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedElectronClient(offer, testInfo, 'Disposable host', { + extraEnv: { ORCA_BACKGROUND_LAUNCH: '1' } + }) + try { + await assertHiddenIsolation(client.app, testInfo, 'client-isolation') + const page = client.page + await page.evaluate(async (theme) => { + await window.__store?.getState().updateSettings({ theme }) + }, theme) + await openSidebarProjectDialog(page) + const dialog = page.getByRole('dialog', { name: /Add a project/i }) + const hostPicker = dialog.getByRole('combobox') + await hostPicker.click() + await page.locator('[cmdk-item]').filter({ hasText: 'Disposable host' }).click() + await dialog.getByRole('button', { name: /Browse folder|Browse host/i }).click() + await page + .getByRole('dialog', { name: /Browse host filesystem/i }) + .getByRole('button', { name: 'Cancel', exact: true }) + .click() + const pathField = page.locator('#server-project-path') + const missingPath = path.join(hostHome, 'missing-project') + await pathField.fill(missingPath) + await page.getByRole('button', { name: 'Add Git Project' }).click() + const toast = page + .locator('[data-sonner-toast]') + .filter({ hasText: 'Cannot open folder on selected runtime' }) + .first() + await expect(toast).toBeVisible() + await expect(pathField).toHaveValue(missingPath) + await expect(page.getByRole('dialog', { name: /Open host project/i })).toBeVisible() + await page.screenshot({ + path: testInfo.outputPath(`toast-over-dialog-${theme}.png`), + animations: 'disabled' + }) + const layers = await toast.evaluate((element) => { + const overlay = document.querySelector('[data-slot="dialog-overlay"]') + const toaster = element.closest('[data-sonner-toaster]') + if (!overlay || !toaster) { + throw new Error('Missing modal/toast layers') + } + return { + toast: Number(getComputedStyle(toaster).zIndex), + overlay: Number(getComputedStyle(overlay).zIndex), + pointerEvents: getComputedStyle(element).pointerEvents + } + }) + await testInfo.attach('layers', { + body: JSON.stringify(layers), + contentType: 'application/json' + }) + expect(layers.toast, 'dialog overlay covers the project error').toBeGreaterThan( + layers.overlay + ) + expect(layers.pointerEvents).toBe('none') + await pathField.click() + // Radix still owns pointer/focus containment; a visible toast must not steal the form. + await expect( + toast.locator('[data-close-button]').click({ trial: true, timeout: 300 }) + ).rejects.toThrow() + await expect(pathField).toHaveValue(missingPath) + expect(await pathField.evaluate((input) => input === document.activeElement)).toBe(true) + + // A real action-toast renderer, fed a synthetic notification in this disposable client only. + await client.app.evaluate(({ ipcMain, BrowserWindow }) => { + ipcMain.removeHandler('mobile:consumePendingUnpairedDeviceAuthFailure') + ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', () => true) + BrowserWindow.getAllWindows()[0].webContents.send('mobile:unpairedDeviceAuthFailure') + }) + const actionToast = page + .locator('[data-sonner-toast]') + .filter({ hasText: 'A device tried to connect but is not paired' }) + const action = actionToast.getByRole('button', { name: 'Open Mobile Settings' }) + await expect(action).toBeVisible() + await expect(action.click({ trial: true, timeout: 300 })).rejects.toThrow() + await expect(pathField).toHaveValue(missingPath) + expect(await pathField.evaluate((input) => input === document.activeElement)).toBe(true) + + // Credential prompts deliberately use a higher tier; this visibility fix does not cross it. + await page.evaluate(() => + window.__store?.getState().enqueueSshCredentialRequest({ + requestId: 'toast-layer-fixture', + targetId: 'Simulated SSH prompt', + kind: 'password', + detail: 'Layering fixture only' + }) + ) + await expect(page.getByRole('dialog', { name: 'SSH Password' })).toBeVisible() + await page.screenshot({ + path: testInfo.outputPath(`higher-modal-residual-${theme}.png`), + animations: 'disabled' + }) + expect( + await page + .locator('[data-slot="dialog-overlay"]') + .evaluateAll((overlays) => + Math.max(...overlays.map((overlay) => Number(getComputedStyle(overlay).zIndex))) + ) + ).toBe(140) + await page.evaluate(() => + window.__store?.getState().removeSshCredentialRequest('toast-layer-fixture') + ) + await expect(pathField).toHaveValue(missingPath) + await page + .getByRole('dialog', { name: /Open host project/i }) + .getByRole('button', { name: 'Close', exact: true }) + .click() + await expect(page.locator('[data-slot="dialog-overlay"]')).toHaveCount(0) + const toaster = page.locator('[data-sonner-toaster]') + await expect(toaster).toHaveCSS('z-index', '40') + await toast.locator('[data-close-button]').click() + await expect(toast).toBeHidden() + + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ experimentalAgentDashboardPopout: true }) + }) + await page.evaluate(() => window.__store?.getState().setAgentDashboardDrawerOpen(true)) + await expect(page.locator('[data-agent-dashboard-sheet]')).toBeVisible() + await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0) + await expect(toaster).toHaveCSS('z-index', '40') + await page.screenshot({ + path: testInfo.outputPath(`nonmodal-drawer-${theme}.png`), + animations: 'disabled' + }) + await page.evaluate(() => window.__store?.getState().setAgentDashboardDrawerOpen(false)) + // Scroll-lock is also used by selects and menus and must not by itself lift notifications. + await page.evaluate(() => document.body.setAttribute('data-scroll-locked', '1')) + await expect(toaster).toHaveCSS('z-index', '40') + await page.evaluate(() => document.body.removeAttribute('data-scroll-locked')) + await action.click() + await expect(page.getByPlaceholder('Search settings')).toBeVisible() + await assertHiddenIsolation(client.app, testInfo, 'client-final-isolation') + await assertHiddenIsolation(electronApp, testInfo, 'host-final-isolation') + expect(await client.getDirectSshAttemptTargetIds()).toEqual([]) + expect(await orcaPage.evaluate(() => window.api.repos.list())).toEqual([]) + } finally { + await client.dispose() + } + }) +} From d929bd8f56f7e51af113619beeb38e8ed8c8a736 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:36 -0700 Subject: [PATCH 034/387] Keep large Windows file identities distinct (#22424) Adapted from the investigation and proposal by @Sungjae-Heo. Co-authored-by: Sungjae-Heo --- .../codex-session-file-discovery.ts | 5 +- .../codex-usage/scanner-file-identity.test.ts | 183 ++++++++++++++++++ 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/main/codex-usage/scanner-file-identity.test.ts diff --git a/src/main/codex-usage/codex-session-file-discovery.ts b/src/main/codex-usage/codex-session-file-discovery.ts index 946f9f1838c..bf2595fc340 100644 --- a/src/main/codex-usage/codex-session-file-discovery.ts +++ b/src/main/codex-usage/codex-session-file-discovery.ts @@ -138,8 +138,9 @@ async function getCodexSessionFileAliasKey(filePath: string): Promise { async function getPhysicalFileAliasKey(filePath: string): Promise { try { - const fileStat = await stat(filePath) - if (fileStat.ino !== 0) { + // Windows file IDs can exceed the precision of JavaScript numbers. + const fileStat = await stat(filePath, { bigint: true }) + if (fileStat.ino !== 0n) { return `${fileStat.dev}:${fileStat.ino}` } } catch {} diff --git a/src/main/codex-usage/scanner-file-identity.test.ts b/src/main/codex-usage/scanner-file-identity.test.ts new file mode 100644 index 00000000000..f0d85076457 --- /dev/null +++ b/src/main/codex-usage/scanner-file-identity.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import type { PathLike } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const fixture = vi.hoisted(() => ({ + root: '', + identities: new Map(), + statErrors: new Set(), + realpathErrors: new Set() +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + stat: async (filePath: PathLike, options?: { bigint?: boolean }) => { + const path = String(filePath) + if (fixture.statErrors.has(path)) { + throw new Error('Fixture stat unavailable') + } + const identity = fixture.identities.get(path) + if (options?.bigint) { + const stats = await actual.stat(filePath, { bigint: true }) + return identity ? Object.assign(stats, identity) : stats + } + const stats = await actual.stat(filePath) + return identity + ? Object.assign(stats, { dev: Number(identity.dev), ino: Number(identity.ino) }) + : stats + }, + realpath: async (filePath: PathLike) => { + if (fixture.realpathErrors.has(String(filePath))) { + throw new Error('Fixture realpath unavailable') + } + return actual.realpath(filePath) + } + } +}) + +vi.mock('../codex/codex-home-paths', () => ({ + getOrcaManagedCodexHomePath: () => join(fixture.root, 'runtime'), + getSystemCodexHomePath: () => join(fixture.root, 'system') +})) + +vi.mock('../codex/codex-account-home-discovery', () => ({ + getCodexAccountHomeSessionDirectories: () => [] +})) + +vi.mock('../codex/codex-session-bridge', () => ({ + getLegacyCopiedCodexSessionBridgeScanPreference: () => null +})) + +import { listCodexSessionFiles } from './codex-session-file-discovery' +import { scanCodexUsageFiles } from './scanner' + +function writeSession(name: string, tokens = 10): string { + const filePath = join(fixture.root, 'runtime', 'sessions', `${name}.jsonl`) + const records = [ + { type: 'session_meta', payload: { id: name, cwd: fixture.root } }, + { + timestamp: '2026-09-20T12:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + model: 'gpt-5-codex', + last_token_usage: { input_tokens: tokens, total_tokens: tokens } + } + } + } + ] + writeFileSync(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`) + return filePath +} + +beforeEach(() => { + fixture.root = mkdtempSync(join(tmpdir(), 'orca-codex-file-identity-')) + mkdirSync(join(fixture.root, 'runtime', 'sessions'), { recursive: true }) + fixture.identities.clear() + fixture.statErrors.clear() + fixture.realpathErrors.clear() +}) + +afterEach(() => { + rmSync(fixture.root, { recursive: true, force: true }) +}) + +describe('Codex session physical file identity', () => { + it('keeps adjacent large inode IDs distinct when numeric stats would round them together', async () => { + const first = writeSession('first') + const second = writeSession('second') + const firstIno = 2n ** 53n + const secondIno = firstIno + 1n + expect(Number(firstIno)).toBe(Number(secondIno)) + fixture.identities.set(first, { dev: 1n, ino: firstIno }) + fixture.identities.set(second, { dev: 1n, ino: secondIno }) + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) + + it('keeps the device part of the identity exact too', async () => { + const first = writeSession('first') + const second = writeSession('second') + fixture.identities.set(first, { dev: 2n ** 53n, ino: 7n }) + fixture.identities.set(second, { dev: 2n ** 53n + 1n, ino: 7n }) + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) + + it('counts both distinct sessions and preserves numeric timestamps, sizes and cache reuse', async () => { + const first = writeSession('first', 10) + const second = writeSession('second', 20) + fixture.identities.set(first, { dev: 1n, ino: 2n ** 53n }) + fixture.identities.set(second, { dev: 1n, ino: 2n ** 53n + 1n }) + + const result = await scanCodexUsageFiles([], []) + expect(result.dailyAggregates.reduce((total, row) => total + row.totalTokens, 0)).toBe(30) + expect(result.sessions).toHaveLength(2) + const actual = await vi.importActual('node:fs/promises') + for (const file of result.processedFiles) { + const stats = await actual.stat(file.path) + expect(file.mtimeMs).toBe(stats.mtimeMs) + expect(file.size).toBe(stats.size) + } + const cached = await scanCodexUsageFiles([], result.processedFiles) + expect(cached.dailyAggregates).toEqual(result.dailyAggregates) + expect(cached.processedFiles[0]).toBe(result.processedFiles[0]) + expect(cached.processedFiles[1]).toBe(result.processedFiles[1]) + }) + + it('deduplicates real hardlink aliases across session homes', async () => { + const original = writeSession('original') + mkdirSync(join(fixture.root, 'system', 'sessions'), { recursive: true }) + const alias = join(fixture.root, 'system', 'sessions', 'alias.jsonl') + linkSync(original, alias) + + expect(await listCodexSessionFiles()).toEqual([original, alias].sort().slice(0, 1)) + const result = await scanCodexUsageFiles([], []) + expect(result.dailyAggregates.reduce((total, row) => total + row.totalTokens, 0)).toBe(10) + }) + + it('does not collapse distinct zero-inode files', async () => { + const first = writeSession('first') + const second = writeSession('second') + fixture.identities.set(first, { dev: 1n, ino: 0n }) + fixture.identities.set(second, { dev: 1n, ino: 0n }) + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) + + it.each(['zero inode', 'stat failure'])('deduplicates canonical paths after %s', async (mode) => { + const original = writeSession('original') + symlinkSync( + join(fixture.root, 'runtime'), + join(fixture.root, 'system'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + const alias = join(fixture.root, 'system', 'sessions', 'original.jsonl') + for (const path of [original, alias]) { + if (mode === 'zero inode') { + fixture.identities.set(path, { dev: 1n, ino: 0n }) + } else { + fixture.statErrors.add(path) + } + } + + expect(await listCodexSessionFiles()).toEqual([original, alias].sort().slice(0, 1)) + }) + + it('preserves path spelling when both stat and realpath are unavailable', async () => { + const first = writeSession('MiXeD-first') + const second = writeSession('MiXeD-second') + for (const path of [first, second]) { + fixture.statErrors.add(path) + fixture.realpathErrors.add(path) + } + + expect(await listCodexSessionFiles()).toEqual([first, second]) + }) +}) From 15baf8666005f44de5679753c9f3ed61e78266ec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:50:39 -0700 Subject: [PATCH 035/387] fix(agents): honor environment prefixes in generation commands (#22427) * fix(agents): preserve environment prefixes in generation commands Adapted from the proposal by @carlosbaraza. Co-authored-by: carlosbaraza * test(agents): respect Windows environment key normalization --------- Co-authored-by: carlosbaraza --- src/main/codex-cli/codex-home-process-lock.ts | 7 +- .../ssh-git-noninteractive-provider.ts | 1 + .../ssh-git-provider-commit-message.test.ts | 4 +- .../command-environment-process.test.ts | 160 ++++++++++++++++++ .../command-environment-wsl.test.ts | 49 ++++++ .../commit-message-model-discovery-policy.ts | 7 +- .../commit-message-model-discovery.ts | 22 ++- .../source-control-agent-launch.ts | 26 ++- .../source-control-local-generation.ts | 18 +- .../source-control-local-process.ts | 1 + .../source-control-text-generation-types.ts | 2 + src/relay/agent-exec-handler.test.ts | 57 ++++++- src/relay/agent-exec-handler.ts | 16 +- src/shared/agent-command-plan.ts | 29 ++++ src/shared/command-environment.test.ts | 90 ++++++++++ src/shared/command-environment.ts | 38 +++++ src/shared/commit-message-plan.ts | 31 +--- src/shared/commit-message-prompt.ts | 19 ++- 18 files changed, 517 insertions(+), 60 deletions(-) create mode 100644 src/main/text-generation/command-environment-process.test.ts create mode 100644 src/main/text-generation/command-environment-wsl.test.ts create mode 100644 src/shared/agent-command-plan.ts create mode 100644 src/shared/command-environment.test.ts create mode 100644 src/shared/command-environment.ts diff --git a/src/main/codex-cli/codex-home-process-lock.ts b/src/main/codex-cli/codex-home-process-lock.ts index ef91a5705d9..63b64deaf8e 100644 --- a/src/main/codex-cli/codex-home-process-lock.ts +++ b/src/main/codex-cli/codex-home-process-lock.ts @@ -17,12 +17,15 @@ export function resolveCodexHomeProcessLockKey(codexHomePath?: string | null): s export function resolveCodexHomeProcessLockKeyForSpawnEnv( env: NodeJS.ProcessEnv | undefined, - wslDistro?: string | null + wslDistro?: string | null, + commandEnv?: Record ): string { if (wslDistro) { // buildWslLauncherEnv forwards only explicit values that differ from the // host process; all other cases use the distro user's default home. - const codexHome = env?.CODEX_HOME !== process.env.CODEX_HOME ? (env?.CODEX_HOME ?? null) : null + const codexHome = + commandEnv?.CODEX_HOME ?? + (env?.CODEX_HOME !== process.env.CODEX_HOME ? (env?.CODEX_HOME ?? null) : null) // Why: WSL spawns carry a Linux CODEX_HOME; key it through the same UNC // normalization the probe's \\wsl$ home path uses so both lanes collide. // Without an explicit home the distro default is unknowable from the host; diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts index ee60b254134..0550b1bba39 100644 --- a/src/main/providers/ssh-git-noninteractive-provider.ts +++ b/src/main/providers/ssh-git-noninteractive-provider.ts @@ -27,6 +27,7 @@ export class SshGitNoninteractiveProvider extends SshGitReadProvider { args: plan.args, cwd, stdin: plan.stdinPayload, + ...(plan.env ? { env: plan.env } : {}), timeoutMs, operation }, diff --git a/src/main/providers/ssh-git-provider-commit-message.test.ts b/src/main/providers/ssh-git-provider-commit-message.test.ts index 41a5003946d..be024b643c8 100644 --- a/src/main/providers/ssh-git-provider-commit-message.test.ts +++ b/src/main/providers/ssh-git-provider-commit-message.test.ts @@ -163,7 +163,8 @@ describe('SshGitProvider', () => { binary: 'codex', args: ['exec', 'PROMPT'], stdinPayload: null, - label: 'Codex' + label: 'Codex', + env: { FLAG: 'literal $HOME' } }, '/home/user/repo', 60_000 @@ -176,6 +177,7 @@ describe('SshGitProvider', () => { args: ['exec', 'PROMPT'], cwd: '/home/user/repo', stdin: null, + env: { FLAG: 'literal $HOME' }, timeoutMs: 60_000, operation: 'commit-message' }, diff --git a/src/main/text-generation/command-environment-process.test.ts b/src/main/text-generation/command-environment-process.test.ts new file mode 100644 index 00000000000..bdc04daca3f --- /dev/null +++ b/src/main/text-generation/command-environment-process.test.ts @@ -0,0 +1,160 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { + withCodexHomeProcessLock, + resolveCodexHomeProcessLockKey +} from '../codex-cli/codex-home-process-lock' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { generateCommitMessage } from './source-control-text-generation-requests' +import { discoverModelsLocal } from './commit-message-model-discovery' +import { + discoverCommitMessageModelsLocal, + generateBranchNameFromContext, + generateCommitMessageFromContext, + generatePullRequestFieldsFromContext +} from './commit-message-text-generation' + +const folder = mkdtempSync(join(tmpdir(), 'orca-command-env-')) +const script = join(folder, 'agent.cjs') +const literal = 'spaces $HOME;$(echo unsafe) `echo unsafe` * = value' +writeFileSync( + script, + ` +let prompt = '' +process.stdin.on('data', chunk => prompt += chunk) +process.stdin.on('end', () => { + if (process.env.ORCA_COMMAND_VALUE !== ${JSON.stringify(literal)}) { + console.error('command environment missing or expanded') + process.exitCode = 3 + return + } + if (process.argv.includes('debug')) console.log(JSON.stringify({models:[{slug:'gpt-5.5',display_name:'GPT-5.5'}]})) + else if (process.argv.includes('models')) console.log('anthropic/claude-sonnet-4') + else if (prompt.includes('branch name')) console.log('honor-command-environment') + else if (prompt.includes('JSON')) console.log(JSON.stringify({base:'main',title:'Honor command environment',body:'Passed environment.',draft:true})) + else console.log('Honor command environment') +}) +` +) +afterAll(() => rmSync(folder, { recursive: true, force: true })) + +// Forward slashes remain valid Windows paths and avoid POSIX command-template escaping. +const override = `ORCA_COMMAND_VALUE='${literal}' "${process.execPath.replaceAll('\\', '/')}" "${script.replaceAll('\\', '/')}"` +const target = { + kind: 'local', + cwd: folder, + env: { ...process.env, ORCA_COMMAND_VALUE: 'base' } +} as const +const params = { agentId: 'custom', model: '', customAgentCommand: override } as const + +describe('environment-prefixed commands with real child processes', () => { + it.each(['generation', 'discovery'] as const)( + 'queues Codex %s on the overridden home before spawning', + async (operation) => { + const home = join(folder, operation).replaceAll('\\', '/') + let release!: () => void + const held = withCodexHomeProcessLock( + resolveCodexHomeProcessLockKey(home), + () => + new Promise((resolve) => { + release = resolve + }) + ) + await Promise.resolve() + const spawnAgent = vi.fn(spawnSourceControlAgent) + const command = `CODEX_HOME="${home}" ${override}` + const pending = + operation === 'generation' + ? generateCommitMessage({ + context: { branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+test' }, + params: { agentId: 'codex', model: 'gpt-5.5', agentCommandOverride: command }, + target, + spawnAgent + }) + : discoverModelsLocal({ + agentId: 'codex', + env: target.env, + agentCommandOverride: command, + options: { cwd: folder }, + backslash: process.platform === 'win32' ? 'literal' : 'escape', + spawnAgent + }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(spawnAgent).not.toHaveBeenCalled() + } finally { + release() + await held + } + await expect(pending).resolves.toMatchObject( + operation === 'discovery' ? { success: true, catalogOrigin: 'probe' } : { success: true } + ) + expect(spawnAgent).toHaveBeenCalledWith( + expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: home }) }) + ) + } + ) + + it('generates commits from a custom command in a plain folder', async () => { + await expect( + generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+test' }, + params, + target + ) + ).resolves.toMatchObject({ success: true, message: 'Honor command environment' }) + }) + + it('generates commits from a preset override', async () => { + await expect( + generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M README.md', stagedPatch: '+test' }, + { agentId: 'claude', model: 'sonnet', agentCommandOverride: override }, + target + ) + ).resolves.toMatchObject({ success: true, message: 'Honor command environment' }) + }) + + it('generates branch names', async () => { + await expect( + generateBranchNameFromContext({ firstPrompt: 'Honor command environment' }, params, target) + ).resolves.toMatchObject({ success: true, slug: 'honor-command-environment' }) + }) + + it.each(['github', 'gitlab'] as const)('generates %s review fields', async (provider) => { + await expect( + generatePullRequestFieldsFromContext( + { + branch: 'test', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: true, + commitSummary: 'Change', + changeSummary: 'M README.md', + patch: '+test', + provider + }, + params, + target + ) + ).resolves.toMatchObject({ + success: true, + fields: { + title: 'Honor command environment', + body: 'Passed environment.', + base: 'main', + draft: true + } + }) + }) + + it('discovers models through the same override environment', async () => { + await expect( + discoverCommitMessageModelsLocal('opencode', target.env, override, { cwd: folder }) + ).resolves.toMatchObject({ success: true, models: [{ id: 'anthropic/claude-sonnet-4' }] }) + }) +}) diff --git a/src/main/text-generation/command-environment-wsl.test.ts b/src/main/text-generation/command-environment-wsl.test.ts new file mode 100644 index 00000000000..e7408a2e059 --- /dev/null +++ b/src/main/text-generation/command-environment-wsl.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { wslAwareSpawn } from '../git/runner' +import { spawnSourceControlAgent } from './source-control-agent-launch' +import { withPlatform } from './commit-message-text-generation-test-harness' +import { resolveCodexHomeProcessLockKeyForSpawnEnv } from '../codex-cli/codex-home-process-lock' + +vi.mock('../git/runner', () => ({ wslAwareSpawn: vi.fn() })) +afterEach(() => vi.unstubAllEnvs()) + +describe('command environment on WSL', () => { + it('applies literal assignments in the guest after the login shell, without changing launcher PATH', () => { + withPlatform('win32', () => { + spawnSourceControlAgent({ + binary: 'claude', + args: ['-p'], + cwd: '\\\\wsl$\\Ubuntu\\repo', + wslDistro: 'Ubuntu', + env: undefined, + commandEnv: { PATH: '/guest/bin', VALUE: '$HOME;$(echo unsafe)' }, + stdinMode: 'pipe', + useCwdForNative: true + }) + }) + expect(wslAwareSpawn).toHaveBeenCalledWith( + '/usr/bin/env', + ['PATH=/guest/bin', 'VALUE=$HOME;$(echo unsafe)', 'claude', '-p'], + expect.objectContaining({ wslDistro: 'Ubuntu', windowsHide: true, useWslLoginShell: true }) + ) + const options = vi.mocked(wslAwareSpawn).mock.calls[0][2] + expect(options.env?.PATH).not.toBe('/guest/bin') + expect(options.env?.VALUE).toBeUndefined() + }) + + it('locks an explicitly assigned guest Codex home even when it matches the host value', () => { + vi.stubEnv('CODEX_HOME', '/same/home') + const explicit = resolveCodexHomeProcessLockKeyForSpawnEnv( + { CODEX_HOME: '/same/home' }, + 'Ubuntu', + { CODEX_HOME: '/same/home' } + ) + const managed = resolveCodexHomeProcessLockKeyForSpawnEnv( + { CODEX_HOME: '/other/home' }, + 'Ubuntu' + ) + expect(explicit).toContain('/same/home') + expect(explicit).not.toContain('.orca-default-codex-home') + expect(explicit).not.toBe(managed) + }) +}) diff --git a/src/main/text-generation/commit-message-model-discovery-policy.ts b/src/main/text-generation/commit-message-model-discovery-policy.ts index 88b8e1680e1..fa2b8869a37 100644 --- a/src/main/text-generation/commit-message-model-discovery-policy.ts +++ b/src/main/text-generation/commit-message-model-discovery-policy.ts @@ -61,8 +61,8 @@ export function finalizeModelDiscoveryOutput( // should keep its configured provider even when discovery lists concrete models. const defaultModelId = spec.defaultModelId === 'default' || models.some((model) => model.id === spec.defaultModelId) - ? spec.defaultModelId - : models[0].id + ? spec.defaultModelId + : models[0].id return staticModelDiscoveryResult(spec, models, defaultModelId, 'probe') } @@ -85,7 +85,8 @@ export function planModelDiscovery( binary: command.binary, args: [...command.prefixArgs, ...modelDiscovery.args], stdinPayload: modelDiscovery.stdinPayload ?? null, - label: spec.label + label: spec.label, + ...(command.env ? { env: command.env } : {}) } } } diff --git a/src/main/text-generation/commit-message-model-discovery.ts b/src/main/text-generation/commit-message-model-discovery.ts index ec6be0f12bb..80dbbb205a3 100644 --- a/src/main/text-generation/commit-message-model-discovery.ts +++ b/src/main/text-generation/commit-message-model-discovery.ts @@ -1,3 +1,4 @@ +import { mergeCommandEnvironment } from '../../shared/command-environment' import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import { getAgentModelProbeSpec } from '../../shared/agent-model-probe-spec' @@ -46,6 +47,16 @@ export async function discoverModelsLocal(input: { return staticModelDiscoveryResult(spec) } + const planned = planModelDiscovery(spec, input.agentCommandOverride, input.backslash) + if (!planned.ok) { + return { success: false, error: planned.error } + } + const env = mergeCommandEnvironment( + input.env, + planned.plan.env, + input.options.wslDistro ? 'linux' : process.platform + ) + const startDiscovery = (): LocalProcessExecution => { let markProcessClosed!: () => void const processClosed = new Promise((resolve) => { @@ -53,18 +64,13 @@ export async function discoverModelsLocal(input: { }) const result = new Promise((resolve) => { let child: SpawnedSourceControlAgentProcess - const planned = planModelDiscovery(spec, input.agentCommandOverride, input.backslash) - if (!planned.ok) { - markProcessClosed() - resolve({ success: false, error: planned.error }) - return - } try { child = input.spawnAgent({ binary: planned.plan.binary, args: planned.plan.args, cwd: input.options.cwd, - env: input.env, + env: input.options.wslDistro ? input.env : env, + commandEnv: planned.plan.env, wslDistro: input.options.wslDistro, stdinMode: planned.plan.stdinPayload === null ? 'ignore' : 'pipe', useCwdForNative: false @@ -172,7 +178,7 @@ export async function discoverModelsLocal(input: { } return input.agentId === 'codex' ? runCodexProcessWithHomeLock( - resolveCodexHomeProcessLockKeyForSpawnEnv(input.env, input.options.wslDistro), + resolveCodexHomeProcessLockKeyForSpawnEnv(env, input.options.wslDistro, planned.plan.env), startDiscovery ) : startDiscovery().result diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 8587497d318..709c0481390 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -39,15 +39,23 @@ function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { const spawnEnv = input.env ?? process.env if (process.platform === 'win32' && input.wslDistro) { - // Same contract as spawnProcess: stdout/stderr are piped; stdin matches stdinMode. - return wslAwareSpawn(input.binary, input.args, { - cwd: input.cwd, - env: buildWslLauncherEnv(input.env), - stdio: [input.stdinMode, 'pipe', 'pipe'], - windowsHide: true, - wslDistro: input.wslDistro, - useWslLoginShell: true - }) as SpawnedSourceControlAgentProcess + // Apply assignments in the guest after its login shell, not to the Windows launcher. + const assignments = Object.entries(input.commandEnv ?? {}).map( + ([key, value]) => `${key}=${value}` + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: WSL spawn pipes both output streams and supplies the configured stdin stream. + return wslAwareSpawn( + assignments.length ? '/usr/bin/env' : input.binary, + assignments.length ? [...assignments, input.binary, ...input.args] : input.args, + { + cwd: input.cwd, + env: buildWslLauncherEnv(input.env), + stdio: [input.stdinMode, 'pipe', 'pipe'], + windowsHide: true, + wslDistro: input.wslDistro, + useWslLoginShell: true + } + ) as SpawnedSourceControlAgentProcess } const resolvedBinary = process.platform === 'win32' diff --git a/src/main/text-generation/source-control-local-generation.ts b/src/main/text-generation/source-control-local-generation.ts index f388fd1b03a..d918b1ef487 100644 --- a/src/main/text-generation/source-control-local-generation.ts +++ b/src/main/text-generation/source-control-local-generation.ts @@ -1,3 +1,4 @@ +import { mergeCommandEnvironment } from '../../shared/command-environment' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import { resolveCodexHomeProcessLockKeyForSpawnEnv, @@ -25,13 +26,21 @@ export function runLocalPlanForAgent(input: { operation: TextGenerationOperation spawnAgent: SpawnSourceControlAgent }): Promise { + const target = { + ...input.target, + env: mergeCommandEnvironment( + input.target.env, + input.plan.env, + input.target.wslDistro ? 'linux' : process.platform + ) + } const start = ( holdHomeLockUntilExit = false ): LocalProcessExecution => runLocalSourceControlPlan({ plan: input.plan, cwd: input.target.cwd, - env: input.target.env, + env: input.target.wslDistro ? input.target.env : target.env, emptyResultName: input.emptyResultName, operation: input.operation, wslDistro: input.target.wslDistro, @@ -41,13 +50,14 @@ export function runLocalPlanForAgent(input: { if (input.agentId !== 'codex') { return start().result } - return runCodexLocalPlanUnderHomeLock(start, input.target, input.operation) + return runCodexLocalPlanUnderHomeLock(start, target, input.operation, input.plan.env) } function runCodexLocalPlanUnderHomeLock( start: (holdHomeLockUntilExit: boolean) => LocalProcessExecution, target: LocalGenerationTarget, - operation: TextGenerationOperation + operation: TextGenerationOperation, + commandEnv?: Record ): Promise { const laneKey = localGenerationLaneKey(operation, target.cwd) let canceledWhileQueued = false @@ -69,7 +79,7 @@ function runCodexLocalPlanUnderHomeLock( } setLocalGenerationCancelToken(laneKey, queuedCancel) void withCodexHomeProcessLock( - resolveCodexHomeProcessLockKeyForSpawnEnv(target.env, target.wslDistro), + resolveCodexHomeProcessLockKeyForSpawnEnv(target.env, target.wslDistro, commandEnv), async () => { if (canceledWhileQueued) { publishResult({ success: false, error: 'Generation canceled.', canceled: true }) diff --git a/src/main/text-generation/source-control-local-process.ts b/src/main/text-generation/source-control-local-process.ts index 3f046494f0f..da1a2f9a139 100644 --- a/src/main/text-generation/source-control-local-process.ts +++ b/src/main/text-generation/source-control-local-process.ts @@ -66,6 +66,7 @@ export function runLocalSourceControlPlan(input: { args: plan.args, cwd, env: input.env, + commandEnv: plan.env, wslDistro: input.wslDistro, stdinMode: 'pipe', useCwdForNative: true diff --git a/src/main/text-generation/source-control-text-generation-types.ts b/src/main/text-generation/source-control-text-generation-types.ts index 2a8f2c9b8c6..a7b74c35d3a 100644 --- a/src/main/text-generation/source-control-text-generation-types.ts +++ b/src/main/text-generation/source-control-text-generation-types.ts @@ -88,6 +88,8 @@ export type SpawnSourceControlAgent = (input: { cwd?: string env?: NodeJS.ProcessEnv wslDistro?: string + // WSL applies these in the guest; native callers already merge them into env. + commandEnv?: Record stdinMode: 'ignore' | 'pipe' useCwdForNative: boolean }) => SpawnedSourceControlAgentProcess diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index 42a79469d42..87a2ef0403b 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -36,6 +36,7 @@ describe('AgentExecHandler', () => { }) afterEach(() => { + vi.unstubAllEnvs() for (const [key, value] of Object.entries(ambientGuardEnv)) { if (value !== undefined) { process.env[key] = value @@ -84,6 +85,7 @@ describe('AgentExecHandler', () => { }) it('merges caller-supplied provider environment into the spawned command environment', async () => { + vi.stubEnv('ORCA_EXEC_INHERITED', 'inherited-value') const child = createFakeChild() spawnMock.mockReturnValue(child as never) const handlers = createHandlers() @@ -112,7 +114,7 @@ describe('AgentExecHandler', () => { expect(spawnMock).toHaveBeenCalledWith('codex', ['exec'], { cwd: '/repo', env: expect.objectContaining({ - ...process.env, + ORCA_EXEC_INHERITED: 'inherited-value', CODEX_HOME: '/managed/codex-home', PATH: '/managed/bin' }), @@ -121,6 +123,59 @@ describe('AgentExecHandler', () => { }) }) + it('honors Windows override casing while replacing indexed Git configuration', async () => { + const child = createFakeChild() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every child event and stream used by the handler. + spawnMock.mockReturnValue(child as never) + const originalPlatform = process.platform + const originalPath = process.env.PATH + process.env.PATH = 'inherited-path' + process.env.GIT_CONFIG_COUNT = '2' + process.env.GIT_CONFIG_KEY_1 = 'stale.key' + process.env.GIT_CONFIG_VALUE_1 = 'stale-value' + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const handlers = createHandlers() + const pending = handlers.get('agent.execNonInteractive')!( + { + binary: 'agent.exe', + args: [], + env: { + Path: 'override-path', + token: 'override-token', + git_config_count: '1', + git_config_key_0: 'http.proxy', + git_config_value_0: 'proxy-value' + } + }, + requestContext() + ) + child.emit('close', 0) + await expect(pending).resolves.toMatchObject({ exitCode: 0 }) + const env = spawnMock.mock.calls[0][2]?.env + expect(env).toMatchObject({ + PATH: 'override-path', + TOKEN: 'override-token', + GIT_CONFIG_KEY_0: 'http.proxy', + GIT_CONFIG_VALUE_0: 'proxy-value' + }) + expect(env).not.toHaveProperty('Path') + expect(env).not.toHaveProperty('git_config_count') + expect(Object.values(env ?? {})).not.toContain('stale.key') + expect(Object.values(env ?? {})).not.toContain('stale-value') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + delete process.env.GIT_CONFIG_COUNT + delete process.env.GIT_CONFIG_KEY_1 + delete process.env.GIT_CONFIG_VALUE_1 + } + }) + it('consumes an unattended marker and applies the full Git guard on the relay host', async () => { const child = createFakeChild() spawnMock.mockReturnValue(child as never) diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index a82791dcf45..152e8bd72fb 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -1,3 +1,4 @@ +import { mergeCommandEnvironment } from '../shared/command-environment' import { spawn, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' @@ -148,10 +149,17 @@ export class AgentExecHandler { params.env && typeof params.env === 'object' && !Array.isArray(params.env) ? (params.env as Record) : null - const spawnEnv = mergeGitConfigEnvProtocol(process.env, extraEnv ?? undefined) as Record< - string, - string - > + const baseEnv = mergeCommandEnvironment( + process.env, + extraEnv ? {} : undefined, + process.platform + ) + const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform) + const spawnEnv = Object.fromEntries( + Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? process.env, overrides)).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) // Why: this RPC has no interactive terminal, regardless of which wrapper // launches the agent or hook command. applyTerminalGitCredentialPromptGuard(spawnEnv, { diff --git a/src/shared/agent-command-plan.ts b/src/shared/agent-command-plan.ts new file mode 100644 index 00000000000..20190d0451b --- /dev/null +++ b/src/shared/agent-command-plan.ts @@ -0,0 +1,29 @@ +import { extractLeadingEnvAssignments } from './command-environment' +import { + tokenizeCustomCommandTemplate, + type CommandTemplateBackslash +} from './commit-message-prompt' + +export function planAgentBinary( + defaultBinary: string, + commandOverride: string | undefined, + backslash: CommandTemplateBackslash = 'escape' +): + | { ok: true; binary: string; prefixArgs: string[]; env?: Record } + | { ok: false; error: string } { + const command = commandOverride?.trim() + if (!command) { + return { ok: true, binary: defaultBinary, prefixArgs: [] } + } + + const tokenized = tokenizeCustomCommandTemplate(command, backslash) + if (!tokenized.ok) { + return { ok: false, error: `Agent command override is invalid: ${tokenized.error}` } + } + const { env, rest } = extractLeadingEnvAssignments(tokenized.tokens) + const [binary, ...prefixArgs] = rest + if (!binary) { + return { ok: false, error: 'Agent command override must start with a binary name.' } + } + return { ok: true, binary, prefixArgs, ...(env ? { env } : {}) } +} diff --git a/src/shared/command-environment.test.ts b/src/shared/command-environment.test.ts new file mode 100644 index 00000000000..91241a36c0f --- /dev/null +++ b/src/shared/command-environment.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { extractLeadingEnvAssignments, mergeCommandEnvironment } from './command-environment' +import { planAgentBinary, planCommitMessageGeneration } from './commit-message-plan' +import { planCustomCommand } from './commit-message-prompt' + +describe('command environment assignments', () => { + it('parses only leading valid names and retains literal values', () => { + expect(extractLeadingEnvAssignments(['A=x=y', '_EMPTY=', 'agent', 'B=later'])).toEqual({ + env: { A: 'x=y', _EMPTY: '' }, + rest: ['agent', 'B=later'] + }) + for (const name of ['1BAD=x', 'BAD-NAME=x', '=empty']) { + expect(extractLeadingEnvAssignments([name, 'agent'])).toEqual({ rest: [name, 'agent'] }) + } + }) + + it('uses the real binary for presets and custom commands without expanding expressions', () => { + expect(planAgentBinary('claude', 'FLAG=1 npx claude')).toEqual({ + ok: true, + binary: 'npx', + prefixArgs: ['claude'], + env: { FLAG: '1' } + }) + expect( + planCustomCommand('VALUE="a b=$HOME;$(echo unsafe)" agent KEY=arg {prompt}', 'hello') + ).toEqual({ + ok: true, + binary: 'agent', + args: ['KEY=arg', 'hello'], + stdinPayload: null, + env: { VALUE: 'a b=$HOME;$(echo unsafe)' } + }) + expect( + planCommitMessageGeneration( + { agentId: 'claude', model: 'sonnet', agentCommandOverride: 'FLAG=1 claude' }, + 'hello' + ) + ).toMatchObject({ ok: true, plan: { binary: 'claude', env: { FLAG: '1' } } }) + }) + + it('rejects assignment-only commands and preserves no-prefix plans', () => { + expect(planAgentBinary('claude', 'ONLY=env')).toMatchObject({ ok: false }) + expect(planCustomCommand('ONLY=env', 'prompt')).toMatchObject({ ok: false }) + expect(planCustomCommand('agent', 'prompt')).toEqual({ + ok: true, + binary: 'agent', + args: [], + stdinPayload: 'prompt' + }) + }) + + it('keeps Windows paths and empty or prototype-named values intact', () => { + const result = planCustomCommand( + 'DIR="C:\\my dir" __proto__=data "C:\\bin\\agent.exe"', + 'prompt', + 'literal' + ) + expect(result).toMatchObject({ + ok: true, + binary: 'C:\\bin\\agent.exe', + env: { DIR: 'C:\\my dir' } + }) + if (result.ok) { + expect(Object.getOwnPropertyDescriptor(result.env, '__proto__')?.value).toBe('data') + } + }) + + it('lets the last assignment override every Windows spelling without mutating the base', () => { + const base = { PATH: 'old', Path: 'also old', TOKEN: 'old', KEEP: 'keep', ComSpec: 'cmd.exe' } + const { env } = extractLeadingEnvAssignments([ + 'Path=first', + 'PATH=second', + 'Path=last', + 'token=', + 'agent' + ]) + expect(mergeCommandEnvironment(base, env, 'win32')).toEqual({ + PATH: 'last', + TOKEN: '', + KEEP: 'keep', + COMSPEC: 'cmd.exe' + }) + expect(base.PATH).toBe('old') + expect(mergeCommandEnvironment({ PATH: 'old' }, { Path: 'new' }, 'linux')).toEqual({ + PATH: 'old', + Path: 'new' + }) + expect(mergeCommandEnvironment(undefined, undefined, 'darwin')).toBeUndefined() + }) +}) diff --git a/src/shared/command-environment.ts b/src/shared/command-environment.ts new file mode 100644 index 00000000000..6a1f6d337a2 --- /dev/null +++ b/src/shared/command-environment.ts @@ -0,0 +1,38 @@ +/** Leading assignments are data, never shell expressions. */ +export function extractLeadingEnvAssignments(tokens: string[]): { + env?: Record + rest: string[] +} { + const assignments = new Map() + let index = 0 + for (const token of tokens) { + if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + break + } + const equals = token.indexOf('=') + const name = token.slice(0, equals) + // Preserve last-assignment order when Windows folds differently cased names. + assignments.delete(name) + assignments.set(name, token.slice(equals + 1)) + index++ + } + return { + ...(assignments.size ? { env: Object.fromEntries(assignments) } : {}), + rest: tokens.slice(index) + } +} + +export function mergeCommandEnvironment( + base: NodeJS.ProcessEnv | undefined, + overrides: Record | undefined, + platform: NodeJS.Platform +): NodeJS.ProcessEnv | undefined { + if (!overrides) { + return base + } + const entries = [...Object.entries(base ?? process.env), ...Object.entries(overrides)] + // Windows keys share one spelling so guards cannot retain differently cased aliases. + return Object.fromEntries( + platform === 'win32' ? entries.map(([key, value]) => [key.toUpperCase(), value]) : entries + ) +} diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts index 83760f00ae1..1d0bf1e02de 100644 --- a/src/shared/commit-message-plan.ts +++ b/src/shared/commit-message-plan.ts @@ -1,3 +1,5 @@ +import { planAgentBinary } from './agent-command-plan' +export { planAgentBinary } from './agent-command-plan' import type { CommandTemplateBackslash } from './commit-message-prompt' import { getCommitMessageAgentSpec, @@ -33,33 +35,14 @@ export type CommitMessagePlan = { stdinPayload: string | null /** Human-readable label used in error prefixes (e.g. "Claude failed: ..."). */ label: string + /** Leading command assignments, applied on the execution host. */ + env?: Record } export type CommitMessagePlanResult = | { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } -export function planAgentBinary( - defaultBinary: string, - commandOverride: string | undefined, - backslash: CommandTemplateBackslash = 'escape' -): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } { - const command = commandOverride?.trim() - if (!command) { - return { ok: true, binary: defaultBinary, prefixArgs: [] } - } - - const tokenized = tokenizeCustomCommandTemplate(command, backslash) - if (!tokenized.ok) { - return { ok: false, error: `Agent command override is invalid: ${tokenized.error}` } - } - const [binary, ...prefixArgs] = tokenized.tokens - if (!binary) { - return { ok: false, error: 'Agent command override must start with a binary name.' } - } - return { ok: true, binary, prefixArgs } -} - function planAdditionalAgentArgs( agentArgs: string | null | undefined, backslash: CommandTemplateBackslash = 'escape' @@ -267,7 +250,8 @@ export function planCommitMessageGeneration( stdinPayload: planned.stdinPayload, // Why: a custom command has no friendly name, so the binary doubles // as the label in error prefixes ("ollama failed: ..."). - label: planned.binary + label: planned.binary, + ...(planned.env ? { env: planned.env } : {}) } } } @@ -329,7 +313,8 @@ export function planCommitMessageGeneration( binary: command.binary, args: [...merged.prefixArgs, ...args], stdinPayload: spec.promptDelivery === 'stdin' ? prompt : null, - label: spec.label + label: spec.label, + ...(command.env ? { env: command.env } : {}) } } } diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index 7fb9da76d66..ecf4cb39069 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -1,3 +1,4 @@ +import { extractLeadingEnvAssignments } from './command-environment' // Why: keeping the base prompt and assembly here (in shared) lets both the // renderer (preview/tests) and main (actual generation) reach the exact same // string without duplicating the wording. @@ -265,7 +266,13 @@ export function tokenizeCustomCommandTemplate( } export type CustomCommandPlan = - | { ok: true; binary: string; args: string[]; stdinPayload: string | null } + | { + ok: true + binary: string + args: string[] + stdinPayload: string | null + env?: Record + } | { ok: false; error: string } /** @@ -289,7 +296,8 @@ export function planCustomCommand( if (tokenized.tokens.length === 0) { return { ok: false, error: 'Custom command is empty.' } } - const [binary, ...rest] = tokenized.tokens + const { env, rest: commandTokens } = extractLeadingEnvAssignments(tokenized.tokens) + const [binary, ...rest] = commandTokens if (!binary) { return { ok: false, error: 'Custom command must start with a binary name.' } } @@ -298,14 +306,15 @@ export function planCustomCommand( token.includes(CUSTOM_PROMPT_PLACEHOLDER) ? token.split(CUSTOM_PROMPT_PLACEHOLDER).join(prompt) : token - const usesPlaceholder = tokenized.tokens.some((t) => t.includes(CUSTOM_PROMPT_PLACEHOLDER)) + const usesPlaceholder = commandTokens.some((t) => t.includes(CUSTOM_PROMPT_PLACEHOLDER)) if (usesPlaceholder) { return { ok: true, binary: substitute(binary), args: rest.map(substitute), - stdinPayload: null + stdinPayload: null, + ...(env ? { env } : {}) } } - return { ok: true, binary, args: rest, stdinPayload: prompt } + return { ok: true, binary, args: rest, stdinPayload: prompt, ...(env ? { env } : {}) } } From 27ca6a229a45f6ea331787cd243b783a10455ddd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:56:00 -0400 Subject: [PATCH 036/387] test(mobile): repin RPC goldens to main after #22956's squash (#23046) #22956 re-recorded with baseline = its own branch commit c5c1477ad4d, which the squash merge left unreachable from main, so the RPC recording pin guard fails on main and every open PR. Repin to main's tip and re-record: only baseline and lockfileSha256 (main's lockfile moved in #22961/#22964) change; every recorded body is identical. --- .../goldens/aivault-history-scan-fulfilled.json | 4 ++-- .../goldens/aivault-history-scan-unsupported.json | 4 ++-- .../goldens/aivault-history-scan-worktrees-late.json | 4 ++-- .../rpc-foundation/goldens/aivault-history-screen-listed.json | 4 ++-- .../goldens/aivault-history-screen-worktrees.json | 4 ++-- .../goldens/aivault-resume-launch-create-refused.json | 4 ++-- .../goldens/aivault-resume-launch-invalid-tab.json | 4 ++-- .../rpc-foundation/goldens/aivault-resume-launch-locked.json | 4 ++-- mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json | 4 ++-- .../goldens/aivault-resume-prepare-refused.json | 4 ++-- .../rpc-foundation/goldens/aivault-resume-prepare-repin.json | 4 ++-- .../goldens/aivault-resume-prepare-skipped.json | 4 ++-- .../goldens/aivault-resume-prepare-unavailable.json | 4 ++-- mobile/rpc-foundation/goldens/b1.json | 4 ++-- mobile/rpc-foundation/goldens/b2.json | 4 ++-- mobile/rpc-foundation/goldens/b3.json | 4 ++-- mobile/rpc-foundation/goldens/browser-dialog-accepted.json | 4 ++-- mobile/rpc-foundation/goldens/browser-dialog-dismissed.json | 4 ++-- mobile/rpc-foundation/goldens/browser-keyboard-input.json | 4 ++-- .../goldens/browser-pointer-click-accepted.json | 4 ++-- .../goldens/browser-pointer-click-fallback.json | 4 ++-- mobile/rpc-foundation/goldens/browser-wheel-scrolled.json | 4 ++-- .../goldens/clipboard-image-attachment-anonymous.json | 4 ++-- .../clipboard-image-attachment-blocked-before-send.json | 4 ++-- .../goldens/clipboard-image-attachment-cancelled.json | 4 ++-- .../goldens/clipboard-image-attachment-pasted.json | 4 ++-- .../goldens/clipboard-image-attachment-upload-refused.json | 4 ++-- .../clipboard-image-upload-aborts-on-chunk-failure.json | 4 ++-- .../goldens/clipboard-image-upload-chunked.json | 4 ++-- .../goldens/clipboard-image-upload-single-frame-fallback.json | 4 ++-- .../goldens/clipboard-image-upload-start-refused.json | 4 ++-- .../rpc-foundation/goldens/codex-reset-credit-consumed.json | 4 ++-- mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json | 4 ++-- .../rpc-foundation/goldens/components-codex-capability.json | 4 ++-- mobile/rpc-foundation/goldens/components-setup-ask.json | 4 ++-- mobile/rpc-foundation/goldens/components-target-local.json | 4 ++-- mobile/rpc-foundation/goldens/components-target-ssh.json | 4 ++-- mobile/rpc-foundation/goldens/diff-review-branch-compare.json | 4 ++-- .../rpc-foundation/goldens/diff-review-branch-file-diff.json | 4 ++-- .../goldens/diff-review-notes-refused-before-compare.json | 4 ++-- .../rpc-foundation/goldens/diff-review-refused-file-diff.json | 4 ++-- mobile/rpc-foundation/goldens/diff-review-snapshot.json | 4 ++-- .../goldens/diff-review-status-unavailable.json | 4 ++-- .../goldens/diff-review-worktree-file-diff.json | 4 ++-- mobile/rpc-foundation/goldens/file-tap-open-refused.json | 4 ++-- .../rpc-foundation/goldens/file-tap-opens-worktree-file.json | 4 ++-- .../goldens/file-tap-previews-absolute-artifact.json | 4 ++-- mobile/rpc-foundation/goldens/file-tap-resolve-miss.json | 4 ++-- mobile/rpc-foundation/goldens/file-tap-resolve-refused.json | 4 ++-- .../goldens/files-explorer-legacy-fallback.json | 4 ++-- mobile/rpc-foundation/goldens/files-explorer-readdir.json | 4 ++-- mobile/rpc-foundation/goldens/files-ownership-local.json | 4 ++-- mobile/rpc-foundation/goldens/files-ownership-ssh.json | 4 ++-- .../rpc-foundation/goldens/files-preview-artifact-direct.json | 4 ++-- .../goldens/files-preview-artifact-image-read.json | 4 ++-- .../rpc-foundation/goldens/files-preview-artifact-image.json | 4 ++-- .../rpc-foundation/goldens/files-preview-grant-refresh.json | 4 ++-- .../goldens/files-preview-worktree-image-read.json | 4 ++-- .../rpc-foundation/goldens/files-preview-worktree-image.json | 4 ++-- .../goldens/files-preview-worktree-text-read.json | 4 ++-- mobile/rpc-foundation/goldens/files-preview-worktree.json | 4 ++-- mobile/rpc-foundation/goldens/files-save-blind.json | 4 ++-- mobile/rpc-foundation/goldens/files-save-verified.json | 4 ++-- mobile/rpc-foundation/goldens/files-tab-doc-shapes.json | 4 ++-- mobile/rpc-foundation/goldens/home-host-accounts.json | 4 ++-- mobile/rpc-foundation/goldens/home-host-stats.json | 4 ++-- mobile/rpc-foundation/goldens/host-view-settings-sync.json | 4 ++-- .../goldens/host-worktree-actions-pin-open-delete.json | 4 ++-- .../rpc-foundation/goldens/host-worktree-delete-refused.json | 4 ++-- .../rpc-foundation/goldens/host-worktree-refresh-stream.json | 4 ++-- .../goldens/interruptions-inventory-lifecycle.json | 4 ++-- .../interruptions-settings-bot-overrides-fulfilled.json | 4 ++-- mobile/rpc-foundation/goldens/inventory-lifecycle.json | 4 ++-- mobile/rpc-foundation/goldens/inventory-repeat-query.json | 4 ++-- mobile/rpc-foundation/goldens/lifecycle-b3.json | 4 ++-- .../rpc-foundation/goldens/lifecycle-inventory-lifecycle.json | 4 ++-- .../goldens/lifecycle-settings-bot-overrides-fulfilled.json | 4 ++-- .../goldens/lifecycle-settings-task-hydration-fulfilled.json | 4 ++-- .../lifecycle-settings-workspace-context-fulfilled.json | 4 ++-- mobile/rpc-foundation/goldens/linear-select-workspace.json | 4 ++-- mobile/rpc-foundation/goldens/live-worktree-name-stream.json | 4 ++-- ...-agentsession.structured-create-agentsession.create-1.json | 4 ++-- ...ession.structured-create-agentsession.createsupport-1.json | 4 ++-- ...ession.structured-launch-agentsession.createsupport-1.json | 4 ++-- .../matrix-aivault.history-aivault.listsessions-1.json | 4 ++-- .../matrix-aivault.history-screen-platform-status.json | 4 ++-- .../goldens/matrix-aivault.history-screen-status.get-2.json | 4 ++-- .../goldens/matrix-aivault.history-screen-worktree.ps-1.json | 4 ++-- .../goldens/matrix-aivault.history-status.get-1.json | 4 ++-- ...x-aivault.resume-launch-session.tabs.createterminal-1.json | 4 ++-- .../goldens/matrix-aivault.resume-launch-terminal.send-1.json | 4 ++-- ...ult.resume-preparation-aivault.preparesessionresume-1.json | 4 ++-- .../goldens/matrix-browser.dialog-browser.dialogaccept-1.json | 4 ++-- .../matrix-browser.keyboard-browser.keyboardinserttext-1.json | 4 ++-- .../goldens/matrix-browser.keyboard-browser.keypress-1.json | 4 ++-- .../matrix-browser.pointer-click-browser.mouseclick-1.json | 4 ++-- .../matrix-browser.pointer-click-browser.mousedown-1.json | 4 ++-- .../matrix-browser.pointer-click-browser.mousemove-1.json | 4 ++-- .../matrix-browser.pointer-click-browser.mouseup-1.json | 4 ++-- .../goldens/matrix-browser.wheel-browser.mousemove-1.json | 4 ++-- .../goldens/matrix-browser.wheel-browser.mousewheel-1.json | 4 ++-- ...ipboard.image-attachment-clipboard.startimageupload-1.json | 4 ++-- ...lipboard.image-upload-clipboard.saveimageastempfile-1.json | 4 ++-- ...x-clipboard.image-upload-clipboard.startimageupload-1.json | 4 ++-- ...matrix-components.codex-reset-capability-status.get-1.json | 4 ++-- ...codex-reset-credit-accounts.consumecodexresetcredit-1.json | 4 ++-- ...nents.execution-target-local-preflight.detectagents-1.json | 4 ++-- ...nents.execution-target-preflight.detectremoteagents-1.json | 4 ++-- .../matrix-components.execution-target-ssh.connect-1.json | 4 ++-- .../matrix-components.execution-target-ssh.getstate-1.json | 4 ++-- ...rix-components.new-workspace-repositories-repo.list-1.json | 4 ++-- .../goldens/matrix-components.setup-script-repo.hooks-1.json | 4 ++-- .../goldens/matrix-files.explorer-screen-files.list-1.json | 4 ++-- .../goldens/matrix-files.explorer-screen-files.readdir-1.json | 4 ++-- .../matrix-files.mutation-ownership-ssh.getstate-1.json | 4 ++-- .../goldens/matrix-files.mutation-ownership-status.get-1.json | 4 ++-- .../matrix-files.mutation-ownership-worktree.show-1.json | 4 ++-- ...ew-artifact-image-files.readterminalartifactpreview-1.json | 4 ++-- ...atrix-files.preview-load-files.readterminalartifact-1.json | 4 ++-- ...atrix-files.preview-load-files.readterminalartifact-2.json | 4 ++-- ...matrix-files.preview-load-files.resolveterminalpath-1.json | 4 ++-- ...atrix-files.preview-save-files.readterminalartifact-1.json | 4 ++-- ...trix-files.preview-save-files.writeterminalartifact-1.json | 4 ++-- ...trix-files.preview-worktree-image-files.readpreview-1.json | 4 ++-- .../matrix-files.preview-worktree-text-files.read-1.json | 4 ++-- .../goldens/matrix-files.tab-doc-files.read-1.json | 4 ++-- .../goldens/matrix-files.tab-doc-files.readpreview-1.json | 4 ++-- .../goldens/matrix-files.tab-doc-git.diff-1.json | 4 ++-- .../goldens/matrix-files.terminal-path-tap-files.open-1.json | 4 ++-- ...x-files.terminal-path-tap-files.resolveterminalpath-1.json | 4 ++-- .../matrix-git.base-ref-chain-repo.baserefdefault-1.json | 4 ++-- .../goldens/matrix-git.base-ref-chain-repo.list-1.json | 4 ++-- .../goldens/matrix-git.base-ref-chain-worktree.show-1.json | 4 ++-- .../matrix-git.branch-diff-preview-git.branchdiff-1.json | 4 ++-- .../goldens/matrix-git.changes-load-git.branchcompare-1.json | 4 ++-- .../goldens/matrix-git.changes-load-git.status-1.json | 4 ++-- .../goldens/matrix-git.changes-load-repo.list-1.json | 4 ++-- .../goldens/matrix-git.changes-load-worktree.show-1.json | 4 ++-- ...rix-git.commit-message-ai-git.generatecommitmessage-1.json | 4 ++-- .../matrix-git.history-commit-files-git.commitcompare-1.json | 4 ++-- .../matrix-git.history-commit-files-git.history-1.json | 4 ++-- .../goldens/matrix-git.history-read-git.history-1.json | 4 ++-- .../goldens/matrix-git.remote-prerequisite-git.push-1.json | 4 ++-- .../goldens/matrix-git.review-preparation-git.status-1.json | 4 ++-- ...x-github.pr-comment-mutation-github.addissuecomment-1.json | 4 ++-- ....pr-comment-mutation-github.addprreviewcommentreply-1.json | 4 ++-- ...nt-mutation-github.project.deleteissuecommentbyslug-1.json | 4 ++-- ...nt-mutation-github.project.updateissuecommentbyslug-1.json | 4 ++-- ...thub.pr-comment-mutation-github.resolvereviewthread-1.json | 4 ++-- .../goldens/matrix-github.pr-mutation-github.mergepr-1.json | 4 ++-- .../matrix-github.pr-mutation-github.removeprreviewers-1.json | 4 ++-- ...matrix-github.pr-mutation-github.requestprreviewers-1.json | 4 ++-- .../matrix-github.pr-mutation-github.rerunprchecks-1.json | 4 ++-- .../matrix-github.pr-mutation-github.setprautomerge-1.json | 4 ++-- .../matrix-github.pr-mutation-github.updateprstate-1.json | 4 ++-- .../matrix-github.pr-read-github.listassignableusers-1.json | 4 ++-- .../matrix-github.pr-read-github.prcheckdetails-1.json | 4 ++-- .../goldens/matrix-github.pr-read-github.prchecks-1.json | 4 ++-- .../goldens/matrix-github.pr-read-github.prforbranch-1.json | 4 ++-- .../goldens/matrix-github.pr-read-github.reposlug-1.json | 4 ++-- .../matrix-github.pr-read-github.workitemdetails-1.json | 4 ++-- .../matrix-github.pr-read-hostedreview.forbranch-1.json | 4 ++-- ...atrix-github.pr-title-mutation-github.updateprtitle-1.json | 4 ++-- .../goldens/matrix-home.host-accounts-accounts.list-1.json | 4 ++-- .../goldens/matrix-home.host-stats-stats.summary-1.json | 4 ++-- ...t-worktree-refresh-runtime.clientevents.subscribe-1-1.json | 4 ++-- ...t-worktree-refresh-runtime.clientevents.subscribe-1-2.json | 4 ++-- ...t-worktree-refresh-runtime.clientevents.subscribe-1-3.json | 4 ++-- ...t-worktree-refresh-runtime.clientevents.subscribe-2-1.json | 4 ++-- .../goldens/matrix-host.view-settings-ui.get-1.json | 4 ++-- .../goldens/matrix-host.view-settings-ui.set-1.json | 4 ++-- .../matrix-host.worktree-actions-worktree.activate-1.json | 4 ++-- .../goldens/matrix-host.worktree-actions-worktree.rm-1.json | 4 ++-- .../goldens/matrix-host.worktree-actions-worktree.set-1.json | 4 ++-- .../goldens/matrix-hostedreview.create-chain-git.push-1.json | 4 ++-- ...atrix-hostedreview.create-chain-hostedreview.create-1.json | 4 ++-- .../matrix-hostedreview.create-chain-worktree.set-1.json | 4 ++-- .../matrix-hostedreview.create-intent-git.bulkstage-1.json | 4 ++-- .../matrix-hostedreview.create-intent-git.commit-1.json | 4 ++-- ...ostedreview.create-intent-git.generatecommitmessage-1.json | 4 ++-- .../goldens/matrix-hostedreview.create-intent-git.push-1.json | 4 ++-- .../matrix-hostedreview.create-intent-git.status-1.json | 4 ++-- .../matrix-hostedreview.create-intent-git.status-2.json | 4 ++-- .../matrix-hostedreview.create-intent-git.status-3.json | 4 ++-- .../matrix-hostedreview.create-intent-git.status-4.json | 4 ++-- ...trix-hostedreview.create-intent-hostedreview.create-1.json | 4 ++-- ...w.create-intent-hostedreview.getcreationeligibility-1.json | 4 ++-- ...w.create-intent-hostedreview.getcreationeligibility-2.json | 4 ++-- .../matrix-hostedreview.create-intent-worktree.set-1.json | 4 ++-- ...iew.eligibility-hostedreview.getcreationeligibility-1.json | 4 ++-- .../goldens/matrix-legacy-inventory-files.searchpaths-1.json | 4 ++-- .../goldens/matrix-legacy-inventory-files.searchpaths-2.json | 4 ++-- .../goldens/matrix-legacy-inventory-fresh-inventory.json | 4 ++-- .../goldens/matrix-legacy-inventory-old-inventory.json | 4 ++-- .../matrix-linear-detail-barrier-linear.getissue-1.json | 4 ++-- .../matrix-linear-detail-barrier-linear.issuecomments-1.json | 4 ++-- ...near.select-workspace-picker-linear.selectworkspace-1.json | 4 ++-- ...live-worktree-name-runtime.clientevents.subscribe-1-1.json | 4 ++-- ...live-worktree-name-runtime.clientevents.subscribe-1-2.json | 4 ++-- ...live-worktree-name-runtime.clientevents.subscribe-2-1.json | 4 ++-- .../goldens/matrix-live-worktree-name-worktree.show-1.json | 4 ++-- .../goldens/matrix-live-worktree-name-worktree.show-2.json | 4 ++-- .../goldens/matrix-live-worktree-name-worktree.show-3.json | 4 ++-- .../goldens/matrix-mobileweb.bundle-fetch-app-js.json | 4 ++-- .../goldens/matrix-mobileweb.bundle-fetch-index-head.json | 4 ++-- .../goldens/matrix-mobileweb.bundle-fetch-index-tail.json | 4 ++-- ...ix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json | 4 ++-- ...mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json | 4 ++-- .../matrix-nativechat.image-paste-terminal.send-1.json | 4 ++-- .../matrix-nativechat.image-paste-terminal.send-2.json | 4 ++-- ...-nativechat.image-upload-clipboard.startimageupload-1.json | 4 ++-- ...option-pick-settings.mutatenativechatsessionoptions-1.json | 4 ++-- ...erminal-write-orchestration.workerterminaluserinput-1.json | 4 ++-- .../matrix-nativechat.terminal-write-terminal.send-1.json | 4 ++-- ...cations.desktop-stream-notifications.getmissedsince-1.json | 4 ++-- ...ifications.desktop-stream-notifications.subscribe-1-1.json | 4 ++-- ...ifications.desktop-stream-notifications.subscribe-1-2.json | 4 ++-- ...ifications.desktop-stream-notifications.unsubscribe-1.json | 4 ++-- ...ications.display-test-screen-notifications.testpush-1.json | 4 ++-- ...cations.push-dismissal-notifications.getmissedsince-1.json | 4 ++-- ...ations.push-registration-notifications.registerpush-1.json | 4 ++-- ...ions.push-registration-notifications.unregisterpush-1.json | 4 ++-- .../goldens/matrix-pairing.pre-profile-direct-status.json | 4 ++-- .../matrix-pairing.pre-profile-pairing.getendpoints-1.json | 4 ++-- .../matrix-pairing.pre-profile-pairing.provisionrelay-1.json | 4 ++-- .../goldens/matrix-pairing.pre-profile-relay-status.json | 4 ++-- ...ect-explicit-false-github.project.updateissuebyslug-1.json | 4 ++-- ...trix-relay.credential-rotation-pairing.getendpoints-1.json | 4 ++-- ...trix-relay.credential-rotation-pairing.getendpoints-2.json | 4 ++-- ...ix-relay.credential-rotation-pairing.provisionrelay-1.json | 4 ++-- .../matrix-relay.direct-upgrade-pairing.getendpoints-1.json | 4 ++-- .../matrix-relay.direct-upgrade-pairing.getendpoints-2.json | 4 ++-- .../matrix-relay.direct-upgrade-pairing.provisionrelay-1.json | 4 ++-- .../matrix-relay.pairing-recovery-pairing.getendpoints-1.json | 4 ++-- ...matrix-session.browser-tab-create-browser.tabcreate-1.json | 4 ++-- .../matrix-session.content-create-files.createfile-1.json | 4 ++-- .../goldens/matrix-session.content-create-files.open-1.json | 4 ++-- .../goldens/matrix-session.content-create-status.get-1.json | 4 ++-- .../matrix-session.content-create-worktree.show-1.json | 4 ++-- ...session.create-terminal-session.tabs.createterminal-1.json | 4 ++-- .../matrix-session.create-terminal-terminal.send-1.json | 4 ++-- .../goldens/matrix-session.diff-notes-worktree.show-1.json | 4 ++-- .../matrix-session.diff-review-actions-worktree.set-1.json | 4 ++-- .../goldens/matrix-session.diff-review-base-ref-show.json | 4 ++-- .../matrix-session.diff-review-git.branchcompare-1.json | 4 ++-- .../goldens/matrix-session.diff-review-git.status-1.json | 4 ++-- .../goldens/matrix-session.diff-review-repo.list-1.json | 4 ++-- .../goldens/matrix-session.diff-review-review-show.json | 4 ++-- .../matrix-session.markdown-disk-fallback-files.read-1.json | 4 ++-- ...rix-session.markdown-disk-fallback-markdown.readtab-1.json | 4 ++-- .../matrix-session.markdown-save-markdown.savetab-1.json | 4 ++-- ...rix-session.native-chat-page-nativechat.readsession-1.json | 4 ++-- ...rix-session.native-chat-page-nativechat.subscribe-1-1.json | 4 ++-- ...rix-session.native-chat-page-nativechat.subscribe-2-1.json | 4 ++-- .../matrix-session.native-chat-readability-repo.list-1.json | 4 ++-- ...ive-chat-stop-orchestration.workerterminaluserinput-1.json | 4 ++-- .../matrix-session.native-chat-stop-terminal.send-1.json | 4 ++-- .../matrix-session.native-chat-stop-terminal.send-2.json | 4 ++-- .../matrix-session.pr-branch-context-git.branchcompare-1.json | 4 ++-- .../matrix-session.pr-branch-context-git.status-1.json | 4 ++-- .../goldens/matrix-session.pr-branch-context-repo.list-1.json | 4 ++-- .../matrix-session.pr-branch-context-worktree.show-1.json | 4 ++-- .../goldens/matrix-session.pr-sidebar-github.prchecks-1.json | 4 ++-- .../matrix-session.pr-sidebar-github.prforbranch-1.json | 4 ++-- .../matrix-session.pr-sidebar-hostedreview.forbranch-1.json | 4 ++-- .../goldens/matrix-session.pr-sidebar-worktree.show-1.json | 4 ++-- ...atrix-session.pr-triage-session.tabs.createterminal-1.json | 4 ++-- .../goldens/matrix-session.pr-triage-terminal.send-1.json | 4 ++-- .../matrix-session.review-branch-diff-git.branchdiff-1.json | 4 ++-- .../goldens/matrix-session.review-file-diff-git.diff-1.json | 4 ++-- .../goldens/matrix-session.review-file-diff-git.diff-2.json | 4 ++-- .../goldens/matrix-session.review-file-diff-git.diff-3.json | 4 ++-- .../matrix-session.review-git-mutations-git.discard-1.json | 4 ++-- .../matrix-session.review-git-mutations-git.stage-1.json | 4 ++-- .../matrix-session.review-git-mutations-git.stage-2.json | 4 ++-- .../matrix-session.review-send-sheet-session.tabs.list-1.json | 4 ++-- .../goldens/matrix-session.startup-worktree.activate-1.json | 4 ++-- .../goldens/matrix-session.startup-worktree.activate-2.json | 4 ++-- ...matrix-session.tab-activation-session.tabs.activate-1.json | 4 ++-- .../matrix-session.tab-activation-terminal.focus-1.json | 4 ++-- ...matrix-session.tab-close-session-session.tabs.close-1.json | 4 ++-- .../goldens/matrix-session.tab-close-terminal.close-1.json | 4 ++-- .../matrix-session.tab-documents-markdown.readtab-1.json | 4 ++-- .../goldens/matrix-session.tab-rename-terminal.rename-1.json | 4 ++-- .../matrix-session.tab-reveal-session.tabs.activate-1.json | 4 ++-- .../matrix-session.tab-reveal-session.tabs.list-1.json | 4 ++-- ...matrix-session.tabs-stream-health-session.tabs.list-1.json | 4 ++-- ...ssion.terminal-display-mode-terminal.setdisplaymode-1.json | 4 ++-- ...gesture-input-orchestration.workerterminaluserinput-1.json | 4 ++-- ...session.terminal-gesture-input-terminal.clearbuffer-1.json | 4 ++-- ...matrix-session.terminal-gesture-input-terminal.send-1.json | 4 ++-- ...al-input-send-orchestration.workerterminaluserinput-1.json | 4 ++-- .../matrix-session.terminal-input-send-terminal.send-1.json | 4 ++-- .../matrix-session.terminal-inventory-terminal.list-1.json | 4 ++-- ...erminal-paste-orchestration.workerterminaluserinput-1.json | 4 ++-- .../goldens/matrix-session.terminal-paste-settings.get-1.json | 4 ++-- .../matrix-session.terminal-paste-terminal.send-1.json | 4 ++-- .../matrix-session.worktree-connection-repo.list-1.json | 4 ++-- .../matrix-session.worktree-connection-settings.get-1.json | 4 ++-- ...ix-settings-agent-read-preflight.detectremoteagents-1.json | 4 ++-- .../goldens/matrix-settings-agent-read-repo.list-1.json | 4 ++-- .../goldens/matrix-settings-agent-read-settings.get-1.json | 4 ++-- .../matrix-settings-best-effort-settings.update-1.json | 4 ++-- .../goldens/matrix-settings.bot-overrides-settings.get-1.json | 4 ++-- .../matrix-settings.home-providers-linear.status-1.json | 4 ++-- .../matrix-settings.home-providers-preflight.check-1.json | 4 ++-- .../matrix-settings.home-providers-settings.get-1.json | 4 ++-- ...ettings.new-tab-local-agents-preflight.detectagents-1.json | 4 ++-- .../matrix-settings.new-tab-local-agents-repo.list-1.json | 4 ++-- .../matrix-settings.new-tab-local-agents-settings.get-1.json | 4 ++-- ...gs.quick-commands-settings.getterminalquickcommands-1.json | 4 ++-- ...quick-commands-settings.updateterminalquickcommands-1.json | 4 ++-- .../matrix-settings.repo-metadata-host.platform-1.json | 4 ++-- .../goldens/matrix-settings.repo-metadata-repo.list-1.json | 4 ++-- .../goldens/matrix-settings.repo-metadata-settings.get-1.json | 4 ++-- ...trix-settings.repo-metadata-ssh.listtargetsummaries-1.json | 4 ++-- ...atrix-settings.resume-metadata-folderworkspace.list-1.json | 4 ++-- .../matrix-settings.resume-metadata-projectgroup.list-1.json | 4 ++-- .../goldens/matrix-settings.resume-metadata-repo.list-1.json | 4 ++-- .../matrix-settings.resume-metadata-settings.get-1.json | 4 ++-- .../matrix-settings.resume-metadata-worktree.ps-1.json | 4 ++-- .../matrix-settings.task-hydration-linear.status-1.json | 4 ++-- .../matrix-settings.task-hydration-preflight.check-1.json | 4 ++-- .../matrix-settings.task-hydration-settings.get-1.json | 4 ++-- .../goldens/matrix-settings.task-hydration-status.get-1.json | 4 ++-- .../goldens/matrix-settings.task-hydration-ui.get-1.json | 4 ++-- .../matrix-settings.task-workspace-create-settings.get-1.json | 4 ++-- ...trix-settings.task-workspace-create-worktree.create-1.json | 4 ++-- .../matrix-settings.task-workspace-settings.get-1.json | 4 ++-- .../matrix-settings.workspace-context-linear.status-1.json | 4 ++-- .../matrix-settings.workspace-context-preflight.check-1.json | 4 ++-- .../matrix-settings.workspace-context-settings.get-1.json | 4 ++-- .../goldens/matrix-settings.workspace-context-ui.get-1.json | 4 ++-- .../matrix-settings.workspace-submit-settings.get-1.json | 4 ++-- ...atrix-speech.dictation-chunk-speech.dictation.chunk-1.json | 4 ++-- ...ix-speech.dictation-session-speech.dictation.finish-1.json | 4 ++-- ...rix-speech.dictation-session-speech.dictation.start-1.json | 4 ++-- ...trix-speech.dictation-start-speech.dictation.cancel-1.json | 4 ++-- ...atrix-speech.dictation-start-speech.dictation.start-1.json | 4 ++-- .../matrix-speech.setup-sheet-speech.dictation.setup-1.json | 4 ++-- .../matrix-speech.setup-sheet-speech.models.delete-1.json | 4 ++-- .../matrix-speech.setup-sheet-speech.models.download-1.json | 4 ++-- .../matrix-speech.setup-sheet-speech.models.list-1.json | 4 ++-- ...x-tasks.item-checks-files-github.addprreviewcomment-1.json | 4 ++-- ...atrix-tasks.item-checks-files-github.prfilecontents-1.json | 4 ++-- ...matrix-tasks.item-checks-files-github.rerunprchecks-1.json | 4 ++-- ...-tasks.item-checks-files-github.resolvereviewthread-1.json | 4 ++-- ...trix-tasks.item-checks-files-github.setprfileviewed-1.json | 4 ++-- ...ix-tasks.item-comment-github-github.addissuecomment-1.json | 4 ++-- ...ix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json | 4 ++-- ...ix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json | 4 ++-- ...rix-tasks.item-detail-github-github.workitemdetails-1.json | 4 ++-- ...rix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json | 4 ++-- .../matrix-tasks.item-detail-linear-linear.getissue-1.json | 4 ++-- ...atrix-tasks.item-detail-linear-linear.issuecomments-1.json | 4 ++-- ...sks.item-detail-metadata-github.listassignableusers-1.json | 4 ++-- ...matrix-tasks.item-detail-metadata-github.listlabels-1.json | 4 ++-- .../matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json | 4 ++-- .../matrix-tasks.item-metadata-github-github.updatepr-1.json | 4 ++-- ...atrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json | 4 ++-- ...atrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json | 4 ++-- ...atrix-tasks.item-reply-merge-github.addissuecomment-1.json | 4 ++-- ...sks.item-reply-merge-github.addprreviewcommentreply-1.json | 4 ++-- .../matrix-tasks.item-reply-merge-github.mergepr-1.json | 4 ++-- .../matrix-tasks.item-reply-merge-linear.updateissue-1.json | 4 ++-- .../matrix-tasks.item-review-github-github.prchecks-1.json | 4 ++-- ...-tasks.item-review-github-github.requestprreviewers-1.json | 4 ++-- .../matrix-tasks.item-status-gitlab-github.updateissue-1.json | 4 ++-- .../matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json | 4 ++-- ...ix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json | 4 ++-- .../goldens/matrix-tasks.linear-connect-linear.connect-1.json | 4 ++-- .../matrix-tasks.linear-item-linear.addissuecomment-1.json | 4 ++-- .../matrix-tasks.linear-item-linear.createissue-1.json | 4 ++-- .../goldens/matrix-tasks.linear-item-linear.getissue-1.json | 4 ++-- .../matrix-tasks.linear-team-context-linear.listteams-1.json | 4 ++-- .../matrix-tasks.linear-team-context-linear.teamstates-1.json | 4 ++-- .../goldens/matrix-tasks.paste-lookup-github.reposlug-1.json | 4 ++-- .../goldens/matrix-tasks.paste-lookup-github.workitem-1.json | 4 ++-- ...atrix-tasks.paste-lookup-github.workitembyownerrepo-1.json | 4 ++-- .../matrix-tasks.paste-lookup-gitlab.workitembypath-1.json | 4 ++-- ...ks.project-board-load-github.project.listaccessible-1.json | 4 ++-- ...x-tasks.project-board-load-github.project.listviews-1.json | 4 ++-- ...x-tasks.project-board-load-github.project.listviews-2.json | 4 ++-- ...-tasks.project-board-load-github.project.resolveref-1.json | 4 ++-- ...x-tasks.project-board-load-github.project.viewtable-1.json | 4 ++-- .../matrix-tasks.project-repo-slugs-github.reposlug-1.json | 4 ++-- ...comments-issue-github.project.addissuecommentbyslug-1.json | 4 ++-- ...row-comments-issue-github.project.updateissuebyslug-1.json | 4 ++-- ...ments-issue-github.project.updateissuecommentbyslug-1.json | 4 ++-- ...-comments-pr-github.project.updatepullrequestbyslug-1.json | 4 ++-- ...ect-row-detail-github.project.workitemdetailsbyslug-1.json | 4 ++-- ...ks.project-row-fields-github.project.clearitemfield-1.json | 4 ++-- ...ect-row-fields-github.project.updateissuetypebyslug-1.json | 4 ++-- ...s.project-row-fields-github.project.updateitemfield-1.json | 4 ++-- ...s.project-row-files-merge-github.addprreviewcomment-1.json | 4 ++-- ...matrix-tasks.project-row-files-merge-github.mergepr-1.json | 4 ++-- ...tasks.project-row-files-merge-github.prfilecontents-1.json | 4 ++-- ...ix-tasks.project-row-files-merge-github.updateissue-1.json | 4 ++-- ...-tasks.project-row-files-merge-github.updateprstate-1.json | 4 ++-- ...adata-load-github.project.listassignableusersbyslug-1.json | 4 ++-- ...w-metadata-load-github.project.listissuetypesbyslug-1.json | 4 ++-- ...t-row-metadata-load-github.project.listlabelsbyslug-1.json | 4 ++-- ...rix-tasks.project-row-review-checks-github.prchecks-1.json | 4 ++-- ...project-row-review-checks-github.requestprreviewers-1.json | 4 ++-- ...asks.project-row-review-checks-github.rerunprchecks-1.json | 4 ++-- ...ks.project-row-review-checks-github.setprfileviewed-1.json | 4 ++-- ...ix-tasks.project-row-threads-github.addissuecomment-1.json | 4 ++-- ....project-row-threads-github.addprreviewcommentreply-1.json | 4 ++-- ...row-threads-github.project.deleteissuecommentbyslug-1.json | 4 ++-- ...asks.project-row-threads-github.resolvereviewthread-1.json | 4 ++-- .../matrix-tasks.provider-load-github.countworkitems-1.json | 4 ++-- .../matrix-tasks.provider-load-github.listworkitems-1.json | 4 ++-- .../matrix-tasks.provider-load-linear.listteams-1.json | 4 ++-- .../goldens/matrix-tasks.provider-load-linear.status-1.json | 4 ++-- .../goldens/matrix-tasks.provider-load-settings.update-1.json | 4 ++-- .../goldens/matrix-tasks.route-repo-list-repo.list-1.json | 4 ++-- ...trix-tasks.smart-source-search-github.listworkitems-1.json | 4 ++-- ...trix-tasks.smart-source-search-gitlab.listworkitems-1.json | 4 ++-- .../matrix-tasks.smart-source-search-linear.listissues-1.json | 4 ++-- ...atrix-tasks.smart-source-search-linear.searchissues-1.json | 4 ++-- .../matrix-tasks.smart-source-search-repo.searchrefs-1.json | 4 ++-- .../matrix-tasks.task-create-github-github.createissue-1.json | 4 ++-- .../matrix-tasks.task-create-github-repo.update-1.json | 4 ++-- .../matrix-tasks.task-create-gitlab-gitlab.createissue-1.json | 4 ++-- .../matrix-tasks.task-create-linear-linear.createissue-1.json | 4 ++-- ...x-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json | 4 ++-- .../matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json | 4 ++-- .../matrix-tasks.task-list-linear-linear.listissues-1.json | 4 ++-- .../matrix-tasks.task-list-linear-linear.searchissues-1.json | 4 ++-- .../matrix-tasks.workspace-source-repo.searchrefs-1.json | 4 ++-- .../matrix-tasks.workspace-source-repo.sparsepresets-1.json | 4 ++-- ...matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json | 4 ++-- .../goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json | 4 ++-- ...ix-tasks.workspace-ssh-local-preflight.detectagents-1.json | 4 ++-- ...ix-tasks.workspace-ssh-preflight.detectremoteagents-1.json | 4 ++-- .../goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json | 4 ++-- .../goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json | 4 ++-- .../goldens/matrix-terminal.query-reply-terminal.send-1.json | 4 ++-- ...nal.raw-input-orchestration.workerterminaluserinput-1.json | 4 ++-- .../goldens/matrix-terminal.raw-input-terminal.send-1.json | 4 ++-- ...keover-report-orchestration.workerterminaluserinput-1.json | 4 ++-- ...keover-report-orchestration.workerterminaluserinput-2.json | 4 ++-- ...rix-terminal.viewport-refit-terminal.updateviewport-1.json | 4 ++-- .../matrix-transport.capability-probe-status.get-1.json | 4 ++-- .../matrix-transport.host-status-gates-status.get-1.json | 4 ++-- .../goldens/matrix-transport.pairing-race-direct-status.json | 4 ++-- .../goldens/matrix-transport.pairing-race-relay-status.json | 4 ++-- .../matrix-worktree.agent-launch-create-agent.launch-1.json | 4 ++-- .../matrix-worktree.catalog-snapshot-worktree.ps-1.json | 4 ++-- .../matrix-worktree.create-retry-worktree.create-1.json | 4 ++-- .../goldens/matrix-worktree.home-catalog-worktree.ps-1.json | 4 ++-- .../matrix-worktree.hosted-base-worktree.resolvemrbase-1.json | 4 ++-- .../matrix-worktree.hosted-base-worktree.resolveprbase-1.json | 4 ++-- ...ix-worktree.retired-names-worktree.listretirednames-1.json | 4 ++-- .../goldens/matrix-worktree.review-link-worktree.set-1.json | 4 ++-- .../matrix-worktree.runtime-capabilities-status.get-1.json | 4 ++-- .../goldens/matrix-worktree.setup-hook-trust-ui.set-1.json | 4 ++-- .../goldens/mobile-web-bundle-build-changed.json | 4 ++-- .../rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json | 4 ++-- .../goldens/mobile-web-bundle-manifest-read.json | 4 ++-- .../rpc-foundation/goldens/mobile-web-bundle-unavailable.json | 4 ++-- .../goldens/native-chat-image-paste-single.json | 4 ++-- .../goldens/native-chat-image-paste-stops-on-rejection.json | 4 ++-- .../goldens/native-chat-image-paste-trailing-image.json | 4 ++-- .../goldens/native-chat-image-paste-two-images.json | 4 ++-- .../goldens/native-chat-image-upload-cancelled.json | 4 ++-- .../goldens/native-chat-image-upload-second-fails.json | 4 ++-- .../goldens/native-chat-image-upload-single.json | 4 ++-- .../goldens/native-chat-image-upload-start-refused.json | 4 ++-- .../rpc-foundation/goldens/native-chat-image-upload-two.json | 4 ++-- mobile/rpc-foundation/goldens/native-chat-page-earlier.json | 4 ++-- .../goldens/native-chat-readability-local-repo.json | 4 ++-- .../goldens/native-chat-readability-refused.json | 4 ++-- .../goldens/native-chat-readability-remote-repo.json | 4 ++-- .../goldens/native-chat-session-option-pick-empty.json | 4 ++-- .../goldens/native-chat-session-option-pick-refused.json | 4 ++-- .../goldens/native-chat-session-option-pick-written.json | 4 ++-- mobile/rpc-foundation/goldens/native-chat-stop-accepted.json | 4 ++-- .../goldens/native-chat-stop-both-rejected.json | 4 ++-- .../goldens/native-chat-stop-delivery-unknown.json | 4 ++-- mobile/rpc-foundation/goldens/native-chat-write-accepted.json | 4 ++-- .../rpc-foundation/goldens/native-chat-write-clear-line.json | 4 ++-- .../goldens/native-chat-write-delivery-unknown.json | 4 ++-- mobile/rpc-foundation/goldens/native-chat-write-rejected.json | 4 ++-- .../goldens/native-chat-write-typed-command.json | 4 ++-- mobile/rpc-foundation/goldens/new-tab-local-agents.json | 4 ++-- .../goldens/new-workspace-repositories-fulfilled.json | 4 ++-- .../goldens/notifications-desktop-stream-closed.json | 4 ++-- .../goldens/notifications-desktop-stream-replayed.json | 4 ++-- .../rpc-foundation/goldens/notifications-desktop-stream.json | 4 ++-- .../goldens/notifications-display-test-accepted.json | 4 ++-- .../goldens/notifications-display-test-not-registered.json | 4 ++-- .../goldens/notifications-display-test-rate-limited.json | 4 ++-- .../goldens/notifications-display-test-unknown-reason.json | 4 ++-- .../goldens/notifications-push-gateway-rejected.json | 4 ++-- .../rpc-foundation/goldens/notifications-push-registered.json | 4 ++-- .../pairing-pre-profile-direct-wins-and-provisions.json | 4 ++-- ...g-pre-profile-provision-unsupported-saves-direct-host.json | 4 ++-- .../rpc-foundation/goldens/pairing-pre-profile-times-out.json | 4 ++-- mobile/rpc-foundation/goldens/pr-branch-identity.json | 4 ++-- mobile/rpc-foundation/goldens/pr-branch-repo-context.json | 4 ++-- mobile/rpc-foundation/goldens/pr-comment-mutation.json | 4 ++-- .../goldens/pr-comment-resolve-unconfirmed.json | 4 ++-- .../rpc-foundation/goldens/pr-mutation-in-band-failure.json | 4 ++-- mobile/rpc-foundation/goldens/pr-mutation-status.json | 4 ++-- mobile/rpc-foundation/goldens/pr-read-fork-routing.json | 4 ++-- mobile/rpc-foundation/goldens/pr-read-surface.json | 4 ++-- mobile/rpc-foundation/goldens/pr-read-upstream-error.json | 4 ++-- mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json | 4 ++-- mobile/rpc-foundation/goldens/pr-sidebar-load.json | 4 ++-- mobile/rpc-foundation/goldens/pr-title-mutation.json | 4 ++-- mobile/rpc-foundation/goldens/pr-title-unconfirmed.json | 4 ++-- mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json | 4 ++-- mobile/rpc-foundation/goldens/pr-triage-launch.json | 4 ++-- mobile/rpc-foundation/goldens/pr-triage-send-locked.json | 4 ++-- mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json | 4 ++-- .../goldens/probe-new-tab-null-sibling-refused.json | 4 ++-- .../goldens/probe-new-tab-refused-sibling-rejects.json | 4 ++-- .../goldens/probe-new-tab-rejects-sibling-refused.json | 4 ++-- .../goldens/push-dismissal-tray-reconciled.json | 4 ++-- .../rpc-foundation/goldens/quick-commands-load-refused.json | 4 ++-- .../goldens/quick-commands-loaded-and-saved.json | 4 ++-- .../goldens/quick-commands-save-refused-rolls-back.json | 4 ++-- .../rpc-foundation/goldens/relay-direct-upgrade-commits.json | 4 ++-- .../relay-direct-upgrade-unsupported-host-declines.json | 4 ++-- .../goldens/relay-pairing-recovery-invite-authorizes.json | 4 ++-- .../goldens/relay-pairing-recovery-resume-committed.json | 4 ++-- .../goldens/relay-rotation-installs-and-commits.json | 4 ++-- .../goldens/relay-rotation-resumes-committed-pending.json | 4 ++-- mobile/rpc-foundation/goldens/review-branch-diff-shapes.json | 4 ++-- .../goldens/review-create-terminal-refused.json | 4 ++-- mobile/rpc-foundation/goldens/review-file-diff-shapes.json | 4 ++-- mobile/rpc-foundation/goldens/review-git-mutations-run.json | 4 ++-- .../rpc-foundation/goldens/review-mark-reviewed-persists.json | 4 ++-- .../goldens/review-mark-reviewed-rolls-back.json | 4 ++-- mobile/rpc-foundation/goldens/review-open-in-session.json | 4 ++-- .../goldens/review-send-notes-heals-stale-input.json | 4 ++-- .../goldens/review-send-sheet-lists-terminals.json | 4 ++-- mobile/rpc-foundation/goldens/review-stage-file.json | 4 ++-- mobile/rpc-foundation/goldens/review-stage-refused.json | 4 ++-- mobile/rpc-foundation/goldens/sc-base-ref-default.json | 4 ++-- mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json | 4 ++-- mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json | 4 ++-- mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json | 4 ++-- mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json | 4 ++-- mobile/rpc-foundation/goldens/sc-changes-loaded.json | 4 ++-- .../goldens/sc-commit-message-cancel-rejected.json | 4 ++-- mobile/rpc-foundation/goldens/sc-commit-message-canceled.json | 4 ++-- .../rpc-foundation/goldens/sc-commit-message-generated.json | 4 ++-- mobile/rpc-foundation/goldens/sc-create-existing-review.json | 4 ++-- .../goldens/sc-create-intent-stage-commit-push-create.json | 4 ++-- .../goldens/sc-create-intent-unlisted-provider.json | 4 ++-- .../goldens/sc-create-link-failure-is-non-fatal.json | 4 ++-- .../rpc-foundation/goldens/sc-create-pushes-then-creates.json | 4 ++-- .../goldens/sc-create-refused-empty-message.json | 4 ++-- .../goldens/sc-create-rejected-empty-message.json | 4 ++-- mobile/rpc-foundation/goldens/sc-eligibility-fetched.json | 4 ++-- mobile/rpc-foundation/goldens/sc-history-commit-files.json | 4 ++-- mobile/rpc-foundation/goldens/sc-history-loaded.json | 4 ++-- mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json | 4 ++-- mobile/rpc-foundation/goldens/sc-pr-link-read.json | 4 ++-- mobile/rpc-foundation/goldens/sc-pr-link-set.json | 4 ++-- .../goldens/sc-prefill-unavailable-on-refusal.json | 4 ++-- .../goldens/sc-prefill-unavailable-on-rejection.json | 4 ++-- .../goldens/sc-prerequisite-force-with-lease.json | 4 ++-- mobile/rpc-foundation/goldens/sc-prerequisite-publish.json | 4 ++-- mobile/rpc-foundation/goldens/sc-prerequisite-push.json | 4 ++-- mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json | 4 ++-- mobile/rpc-foundation/goldens/sc-reveal-first-poll.json | 4 ++-- mobile/rpc-foundation/goldens/sc-reveal-timeout.json | 4 ++-- .../goldens/sc-review-commit-inner-failure.json | 4 ++-- .../goldens/sc-review-commit-refused-empty-message.json | 4 ++-- mobile/rpc-foundation/goldens/sc-review-commit-rejected.json | 4 ++-- mobile/rpc-foundation/goldens/sc-review-commit.json | 4 ++-- .../goldens/sc-review-status-entries-not-array.json | 4 ++-- .../rpc-foundation/goldens/sc-review-status-normalized.json | 4 ++-- mobile/rpc-foundation/goldens/schedules-b3.json | 4 ++-- .../goldens/schedules-settings-home-providers-fulfilled.json | 4 ++-- .../goldens/schedules-settings-new-tab-ssh.json | 4 ++-- .../goldens/schedules-settings-repo-metadata-fulfilled.json | 4 ++-- .../goldens/schedules-settings-resume-metadata-fulfilled.json | 4 ++-- .../goldens/schedules-settings-task-hydration-fulfilled.json | 4 ++-- .../schedules-settings-workspace-context-fulfilled.json | 4 ++-- .../rpc-foundation/goldens/session-browser-tab-created.json | 4 ++-- .../goldens/session-create-browser-refused.json | 4 ++-- mobile/rpc-foundation/goldens/session-create-browser-tab.json | 4 ++-- .../goldens/session-create-markdown-name-collision.json | 4 ++-- .../rpc-foundation/goldens/session-create-markdown-note.json | 4 ++-- ...ion-create-terminal-ignores-a-second-create-in-flight.json | 4 ++-- ...ssion-create-terminal-launches-an-agent-quick-command.json | 4 ++-- .../goldens/session-create-terminal-refused.json | 4 ++-- .../goldens/session-create-terminal-replaces-active.json | 4 ++-- .../goldens/session-create-terminal-runs-a-quick-command.json | 4 ++-- .../goldens/session-create-terminal-with-prompt.json | 4 ++-- .../goldens/session-create-terminal-without-active-tab.json | 4 ++-- .../goldens/session-create-terminal-without-handle.json | 4 ++-- .../goldens/session-diff-notes-load-refused.json | 4 ++-- mobile/rpc-foundation/goldens/session-diff-notes-loaded.json | 4 ++-- mobile/rpc-foundation/goldens/session-file-tab-read.json | 4 ++-- mobile/rpc-foundation/goldens/session-markdown-disk-read.json | 4 ++-- .../rpc-foundation/goldens/session-markdown-disk-served.json | 4 ++-- .../goldens/session-markdown-save-conflict.json | 4 ++-- mobile/rpc-foundation/goldens/session-markdown-saved.json | 4 ++-- .../goldens/session-markdown-tab-disk-fallback.json | 4 ++-- mobile/rpc-foundation/goldens/session-markdown-tab-read.json | 4 ++-- .../rpc-foundation/goldens/session-markdown-tab-refused.json | 4 ++-- .../goldens/session-startup-both-activation-sites.json | 4 ++-- .../session-startup-floating-route-skips-activation.json | 4 ++-- .../session-startup-keeps-terminals-visible-on-reconnect.json | 4 ++-- ...ession-startup-refused-tab-load-still-loads-terminals.json | 4 ++-- .../goldens/session-tab-activation-focus-and-activate.json | 4 ++-- .../goldens/session-tab-activation-refused.json | 4 ++-- .../goldens/session-tab-activation-transport-error.json | 4 ++-- .../goldens/session-tab-close-refused-keeps-tab.json | 4 ++-- .../rpc-foundation/goldens/session-tab-close-session-tab.json | 4 ++-- mobile/rpc-foundation/goldens/session-tab-close-terminal.json | 4 ++-- mobile/rpc-foundation/goldens/session-tab-closed.json | 4 ++-- mobile/rpc-foundation/goldens/session-tab-rename.json | 4 ++-- mobile/rpc-foundation/goldens/session-tab-renamed.json | 4 ++-- .../rpc-foundation/goldens/session-tabs-health-errored.json | 4 ++-- .../goldens/session-tabs-health-reconciled.json | 4 ++-- .../rpc-foundation/goldens/session-tabs-health-refused.json | 4 ++-- .../session-tabs-health-stale-application-revision.json | 4 ++-- .../session-terminal-display-mode-auto-take-floor.json | 4 ++-- ...ssion-terminal-display-mode-auto-without-device-token.json | 4 ++-- .../session-terminal-display-mode-auto-without-viewport.json | 4 ++-- .../session-terminal-display-mode-drops-second-toggle.json | 4 ++-- .../goldens/session-terminal-display-mode-to-desktop.json | 4 ++-- .../goldens/session-terminal-list-dedupes-handles.json | 4 ++-- .../goldens/session-terminal-list-empty-guarded.json | 4 ++-- .../rpc-foundation/goldens/session-terminal-list-merged.json | 4 ++-- .../rpc-foundation/goldens/session-terminal-list-refused.json | 4 ++-- .../goldens/settings-bot-overrides-fulfilled.json | 4 ++-- .../goldens/settings-bot-overrides-refresh-refused.json | 4 ++-- .../goldens/settings-bot-overrides-refused.json | 4 ++-- .../goldens/settings-bot-overrides-transport-error.json | 4 ++-- mobile/rpc-foundation/goldens/settings-home-coalesced.json | 4 ++-- .../goldens/settings-home-providers-fulfilled.json | 4 ++-- .../goldens/settings-home-providers-refuse-after-data.json | 4 ++-- .../goldens/settings-home-providers-refused.json | 4 ++-- .../goldens/settings-home-providers-transport-error.json | 4 ++-- mobile/rpc-foundation/goldens/settings-new-tab-refused.json | 4 ++-- mobile/rpc-foundation/goldens/settings-new-tab-ssh.json | 4 ++-- .../goldens/settings-new-tab-transport-error.json | 4 ++-- mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json | 4 ++-- .../goldens/settings-repo-metadata-fulfilled.json | 4 ++-- .../rpc-foundation/goldens/settings-repo-metadata-icons.json | 4 ++-- .../goldens/settings-repo-metadata-refuse-after-data.json | 4 ++-- .../goldens/settings-repo-metadata-refused.json | 4 ++-- .../goldens/settings-repo-metadata-single-host.json | 4 ++-- .../goldens/settings-repo-metadata-transport-error.json | 4 ++-- .../goldens/settings-resume-metadata-fulfilled.json | 4 ++-- .../goldens/settings-resume-metadata-refuse-after-data.json | 4 ++-- .../goldens/settings-resume-metadata-refused.json | 4 ++-- .../goldens/settings-resume-metadata-transport-error.json | 4 ++-- .../goldens/settings-task-hydration-fulfilled.json | 4 ++-- .../goldens/settings-task-hydration-refuse-after-data.json | 4 ++-- .../goldens/settings-task-hydration-refused.json | 4 ++-- .../goldens/settings-task-hydration-transport-error.json | 4 ++-- .../goldens/settings-task-workspace-create-linear.json | 4 ++-- .../settings-task-workspace-create-pr-start-point.json | 4 ++-- .../goldens/settings-task-workspace-fulfilled.json | 4 ++-- .../goldens/settings-task-workspace-refused.json | 4 ++-- .../goldens/settings-task-workspace-transport-error.json | 4 ++-- mobile/rpc-foundation/goldens/settings-task-write.json | 4 ++-- .../goldens/settings-workspace-context-fulfilled.json | 4 ++-- .../goldens/settings-workspace-context-refuse-after-data.json | 4 ++-- .../goldens/settings-workspace-context-refused.json | 4 ++-- .../goldens/settings-workspace-context-transport-error.json | 4 ++-- .../goldens/settings-workspace-submit-fulfilled.json | 4 ++-- .../goldens/settings-workspace-submit-refused.json | 4 ++-- .../goldens/settings-workspace-submit-transport-error.json | 4 ++-- .../goldens/speech-audio-chunk-acknowledged.json | 4 ++-- .../goldens/speech-desktop-start-fulfilled.json | 4 ++-- .../goldens/speech-desktop-start-recording-failed.json | 4 ++-- .../goldens/speech-desktop-start-superseded.json | 4 ++-- .../goldens/speech-dictation-session-cancelled.json | 4 ++-- .../goldens/speech-dictation-session-transcript.json | 4 ++-- .../goldens/speech-setup-sheet-denied-to-mobile.json | 4 ++-- .../rpc-foundation/goldens/speech-setup-sheet-fulfilled.json | 4 ++-- .../goldens/speech-setup-sheet-legacy-desktop.json | 4 ++-- .../goldens/speech-setup-sheet-model-vocabulary.json | 4 ++-- .../goldens/structured-agent-session-created.json | 4 ++-- mobile/rpc-foundation/goldens/structured-launch-created.json | 4 ++-- .../goldens/structured-launch-definitive-refusal.json | 4 ++-- .../goldens/structured-launch-replays-dropped-create.json | 4 ++-- .../goldens/structured-launch-support-refused.json | 4 ++-- .../rpc-foundation/goldens/structured-launch-unsupported.json | 4 ++-- mobile/rpc-foundation/goldens/tasks-route-repo-list.json | 4 ++-- .../goldens/terminal-gesture-flush-and-clear.json | 4 ++-- .../rpc-foundation/goldens/terminal-input-send-accepted.json | 4 ++-- .../rpc-foundation/goldens/terminal-input-send-refused.json | 4 ++-- .../rpc-foundation/goldens/terminal-live-input-accepted.json | 4 ++-- mobile/rpc-foundation/goldens/terminal-paste-accepted.json | 4 ++-- mobile/rpc-foundation/goldens/terminal-paste-refused.json | 4 ++-- .../rpc-foundation/goldens/terminal-query-reply-accepted.json | 4 ++-- .../goldens/terminal-query-reply-unsubscribed.json | 4 ++-- mobile/rpc-foundation/goldens/terminal-raw-input-refused.json | 4 ++-- .../rpc-foundation/goldens/terminal-raw-input-reported.json | 4 ++-- .../goldens/terminal-takeover-report-accepted.json | 4 ++-- .../goldens/terminal-takeover-report-retried.json | 4 ++-- .../goldens/terminal-viewport-refit-applied.json | 4 ++-- .../goldens/terminal-viewport-refit-legacy-desktop.json | 4 ++-- .../goldens/terminal-worktree-connection-resolved.json | 4 ++-- mobile/rpc-foundation/goldens/tk-create-github.json | 4 ++-- mobile/rpc-foundation/goldens/tk-create-gitlab.json | 4 ++-- mobile/rpc-foundation/goldens/tk-create-linear.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-checks-files.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-comment-github.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json | 4 ++-- .../goldens/tk-item-detail-github-reactions.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-detail-github.json | 4 ++-- .../goldens/tk-item-detail-gitlab-reactions.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-detail-linear.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-detail-metadata.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-metadata-github.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-reply-merge.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-review-github.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json | 4 ++-- mobile/rpc-foundation/goldens/tk-item-status-gitlab.json | 4 ++-- mobile/rpc-foundation/goldens/tk-linear-connect.json | 4 ++-- mobile/rpc-foundation/goldens/tk-linear-item.json | 4 ++-- mobile/rpc-foundation/goldens/tk-linear-team-context.json | 4 ++-- mobile/rpc-foundation/goldens/tk-list-gitlab-items.json | 4 ++-- mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json | 4 ++-- mobile/rpc-foundation/goldens/tk-list-linear.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-board-load.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-repo-slugs.json | 4 ++-- .../rpc-foundation/goldens/tk-project-row-comments-issue.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-row-detail.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-row-fields.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-row-files-merge.json | 4 ++-- .../rpc-foundation/goldens/tk-project-row-metadata-load.json | 4 ++-- .../rpc-foundation/goldens/tk-project-row-review-checks.json | 4 ++-- mobile/rpc-foundation/goldens/tk-project-row-threads.json | 4 ++-- mobile/rpc-foundation/goldens/tk-provider-load.json | 4 ++-- .../transport-capability-probe-cutover-reasks-fast.json | 4 ++-- ...ansport-capability-probe-non-string-capabilities-drop.json | 4 ++-- .../goldens/transport-capability-probe-publishes.json | 4 ++-- .../goldens/transport-capability-probe-refused-backs-off.json | 4 ++-- .../transport-host-status-gates-drop-keeps-capabilities.json | 4 ++-- .../goldens/transport-host-status-gates-ready.json | 4 ++-- .../goldens/transport-host-status-gates-refused-degrades.json | 4 ++-- .../goldens/transport-pairing-race-both-refused.json | 4 ++-- .../transport-pairing-race-direct-completes-first.json | 4 ++-- .../goldens/transport-pairing-race-relay-completes-first.json | 4 ++-- ...transport-pairing-race-relay-wins-when-direct-refused.json | 4 ++-- mobile/rpc-foundation/goldens/tw-capabilities-advertised.json | 4 ++-- .../goldens/tw-capabilities-cutover-retried.json | 4 ++-- .../goldens/tw-capabilities-legacy-idempotency.json | 4 ++-- .../goldens/tw-create-retry-agent-launched.json | 4 ++-- .../goldens/tw-create-retry-ambiguous-after-drop.json | 4 ++-- .../goldens/tw-create-retry-ambiguous-while-connected.json | 4 ++-- .../tw-create-retry-ambiguous-without-idempotency.json | 4 ++-- mobile/rpc-foundation/goldens/tw-create-retry-created.json | 4 ++-- .../goldens/tw-create-retry-name-collision.json | 4 ++-- .../goldens/tw-create-retry-unretryable-refusal.json | 4 ++-- .../rpc-foundation/goldens/tw-create-retry-warning-kept.json | 4 ++-- mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json | 4 ++-- mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json | 4 ++-- mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json | 4 ++-- .../rpc-foundation/goldens/tw-paste-lookup-slug-refused.json | 4 ++-- .../goldens/tw-paste-lookup-slug-unsupported.json | 4 ++-- mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json | 4 ++-- .../rpc-foundation/goldens/tw-setup-hook-trust-approved.json | 4 ++-- .../rpc-foundation/goldens/tw-smart-search-all-providers.json | 4 ++-- .../goldens/tw-smart-search-gitlab-provider-error.json | 4 ++-- .../rpc-foundation/goldens/tw-smart-search-linear-listed.json | 4 ++-- .../goldens/tw-task-preferences-resume-write.json | 4 ++-- .../goldens/tw-workspace-source-presets-refused.json | 4 ++-- .../rpc-foundation/goldens/tw-workspace-source-presets.json | 4 ++-- .../goldens/tw-workspace-sparse-missing-preset.json | 4 ++-- mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json | 4 ++-- .../goldens/tw-workspace-ssh-connect-refused.json | 4 ++-- mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json | 4 ++-- .../rpc-foundation/goldens/tw-workspace-ssh-local-agents.json | 4 ++-- mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json | 4 ++-- .../goldens/worktree-catalog-snapshot-unreadable.json | 4 ++-- mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json | 4 ++-- mobile/rpc-foundation/goldens/worktree-home-catalog.json | 4 ++-- mobile/rpc-foundation/goldens/worktree-retired-names.json | 4 ++-- mobile/rpc-foundation/pilot-scenarios.json | 2 +- 788 files changed, 1575 insertions(+), 1575 deletions(-) diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index a5bff9a1469..fbd70e2f6fc 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -3,8 +3,8 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", "scenarioSha256": "0431ac82cbb8c60b16f4432fd0da7cff665485d668e512b20fc1168ec63db3fe", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index 2a2f9fc217b..0466c40f254 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -3,8 +3,8 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", "scenarioSha256": "e97c6db772a70ee1912419ac67835b6074aaf9a341d4360389a11465f08092c5", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index 2201ee3760c..895e82589f7 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -3,8 +3,8 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", "scenarioSha256": "10011c7c75e74d9c2e880c5458c9156264ae07e91956a80946ede1d4e684952a", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index 3380d08bd6c..e18c065767e 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -3,8 +3,8 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", "scenarioSha256": "d52d3c5858298a4a6a90bd9a8986b780004477de105fe93f6303d9c303ffea38", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 584dc33fcf7..b68a6375fd4 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -3,8 +3,8 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", "scenarioSha256": "f50f63c4e2a69793b3d322ed16089c4241ff6169d8f9549106480230fb8dd5e7", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json index 084668fc2f7..3f26fa1595a 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "ebf1bbc01ad7704fe79eff0969fcc2d4af8ebfa7c2410d2ed9d3ae8c6976b434", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json index 76558ddfd1f..c99d841d0d9 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "281ccf5a082f3788ae8bf19f742ea4baf35c20c78bc91d7d91873845583e1a91", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index 637fbbb27ba..97cbccd9940 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "941fcf202417c94ef546f5ee331983689d8b72397dac6f61dda944ca24abed9e", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 4a7fecf0243..11b193eb534 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "f0ac1c996e5b1b4043e0a081978476c6c2dd8a5d517d5752857721205a588fb0", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index e8d4fd34cdc..1d89599b9ba 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "dc9926f95475413627315e1f1c96740ececa697c6a0a5e3c42e18cfd4d58448a", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index 05c6cd9d402..ed55fb8bd19 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "6719aea706086a68709894546f9595264407db2df94fa56180cb3c68b08aaa69", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index 4eadd875de7..5f11b46280e 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "3afaf2807506f5bde2159b367f34c6b777739d6aad564796d54ac0da05b5bd02", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index 3d7e60c77d2..fc4ecdd8093 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "a5eedea5f551e0ca0e143292f1d9aa8920dd7af62a02d8e8777666ab3779c019", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index a57ccc91063..432e4063c5a 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "23ffc912a432dcd3ff70be1903a8d518cf85634f27a2be6d21585963e338e7e3", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index 987922474ff..2c0893aae2c 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,8 +3,8 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "b31992be2f91bd61fbe1b8a5400da3b7a56753564b0b0b2b38bc5d549812d693", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index aef37a07ed5..d77019f469b 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,8 +3,8 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "130e493fcd7765e037405f59e6cc78a0cc1793b1ae092cad933ff9d5a9df8b7a", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index 20967718f4b..3b085021e6a 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -3,8 +3,8 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "95f377bc4d8bf1248cafed26b2e9f425ad37e8d3c99e354bd6a8c67eb9bd9b1b", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index 6cdc6b78b25..27a400e170c 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -3,8 +3,8 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "5071188ee492a4ced5be793b2dab32baa9750ee6535128635f274fd79198e2f1", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index e8d9e2015bb..911864d9b28 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -3,8 +3,8 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "eb2294c30af70ebc2bac092dc5105249ae8cf1941f750406622f7ff6dd70cc1b", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index c7b1b1f8a47..7ec3c85aa58 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -3,8 +3,8 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "73faa87b359a11959543542016295bb6b36a10934dd99ad5c1a77a4290a608cd", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index bf81e2a2849..0e166d8fd36 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -3,8 +3,8 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "34efd4d8568ac15d4e67d475e28194522d6413ad27dce1d6850718af15b3f874", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index e800c95bc75..e36ef5c3a9a 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -3,8 +3,8 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "e1bc21248ccff45ff217e385a51618b6f5724c037bfbefa03bb76a48dd4d793b", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index 4a705fcc0ae..8000a20f431 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -3,8 +3,8 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "5411e29436e9faf18a1b81f369082f3ae087a9a96a2e1ea0f97e65e9391bfd0c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json index 25f1aff4cab..e3c73eb77a9 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json @@ -3,8 +3,8 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "ea04d03c15d3cb94be7fe111a8a6219c4e0304095679bbae62af623f5b36c9f4", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index 58bf869ab25..f3f3de8dadc 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -3,8 +3,8 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "e6be7d5dd6b4f5083627a3ba864b1b040d71bf72b60ad940b7d0d575964a7b80", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index 22d2645fbd9..8a539481e8c 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -3,8 +3,8 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "d6db064741cd68d6ad8a3e490a774d8145adb4a5b7cca8218f5c972b1a31f93b", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json index f02db3bcf4f..abe8177ac96 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -3,8 +3,8 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "e7f492523421873f045726e15ec95eac26d43f7e971a33fd89ec1a9749492987", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json index 51d108b4d67..185592da718 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json @@ -3,8 +3,8 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "195c2f15d81c70012ea88750ab3f84bfe512f7e422f7d2d09fb7919bd9cfd85a", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index 8a263abb86e..50a83b43433 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -3,8 +3,8 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "160101326d39705c2036c12646ff6b13d997a1cf91bdc86e5e29279ba31867e4", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json index 3b7b5554e00..406c8261143 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json @@ -3,8 +3,8 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "89ffa843d08ee27dd44b7bba507ce84661b0df73c35f7a8c273e004604710dc9", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json index 95bb1a35bdf..50773d4d185 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -3,8 +3,8 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "bf0227939c2d41d6a1ebc5b07a31196043e78f1580811bd32ec6fc5f447f5934", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index ad81a1f90d1..2087d205c03 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -3,8 +3,8 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", "scenarioSha256": "00260be9809576607ac91bfacd9fa6cc4f8a190c6b88804c977ea07d36d7162e", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index 377ab7f30fb..62e2604f5a1 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -3,8 +3,8 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", "scenarioSha256": "d85a4031b701e563941afcdd7375cf78a1ee1487a0dab722f8516c2bc8d3dcee", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index bcb5d9eef87..6ed0cf0ce3b 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -3,8 +3,8 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "88570b9d2376863c7f88d7ed8c745a5fb771deddbc7409f8944fa861dd4bdce9", diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index 8ec466a0669..0dab47c35b3 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -3,8 +3,8 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "d4052f119c7ed68dc922c8beeb0074701f1532b10e48e284fb71aa165a17e437", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index 8eb566781a3..a574bee7e01 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -3,8 +3,8 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "2e0d3021621698b63117e250dd5e9762b5bfb3dc1911e27c510e9539bd2ee6c9", diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index 8436eff2d05..41434d3a54e 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -3,8 +3,8 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "82b891c7a7a2f255e2d22a372ee6112c9cc1f650259244e87f2e8c1356e97e5f", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index 41c7f615e42..33e8b9d253e 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "b7579013e65f0f5fe503c10cf2294cb9d4ac1938108275de001db6e02cf2cc21", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json index b3363700ba3..9821aa8a0a2 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "6c71b0f217a464dffbc6f5736605b840edac74ebaf0664edc0ab85984bb64328", diff --git a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json index efa63b3d998..548df57a3a9 100644 --- a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "3e7fa054f77587b9ac24b6732a9926273b0f2ff35a266e633d0ccd1dada932cc", diff --git a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json index 42566ff54ce..212069d49f0 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "d1b04fe2945a2799ac8465d8fd9e45ab790ae29b401a0cf4fef68ebc5fa3cc76", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index efb4b64b219..9d14ced994c 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "fa0a81462196458fdded5b7c00aa4e73975c2111afdd8dac115871490a481da2", diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index ba19d4de4f1..42d57fbc5e2 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "182f37fbe6ae7c0694b50d603ecd4a03bc9a8c7c9738ebb775b47eb5f3b9660f", diff --git a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json index 80bf8c762d3..a1e265ed43e 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "538b68485a2268d311fcc7e13ff1a3e446ba4aa010bc18633af8e938a6688257", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index 4b60ec30b2c..3904f8e8cc5 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "a927e85c3ae58cd3b017955fe28aeffec6a5471b51d782f116639a3aaf4af77d", diff --git a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json index c94d8f8df3b..7a92f652958 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "297ea4075333e178ca20247eb0abf6600efb44fe2b33f5142d1c1cabffb5a2d3", diff --git a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json index 629ac6878c5..87e8d693c10 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "f1844c2608ce90250fddfc78c0b35bb1bf3647598a5ab2914eca0d8cb4403a38", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index 3aec65f9e2b..b44c75809ab 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "0f76b96408081737b3d630ee837dbb80bcce6670b6acc4f1f7c033a69bd40533", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index 21aae6589db..066eca7441e 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "1ada35966dfb65afbb9b3dc8139961b8f042e2d53241b9608a080d0e655a5987", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index 9c332f5508e..4a2636bec74 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -3,8 +3,8 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", "scenarioSha256": "ec02d3f76085619fad35231e12654ec6e926e423c6799fd0df53708743000612", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index d6717958b1e..907f3c129cf 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -3,8 +3,8 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", "scenarioSha256": "0b87a230cf1c4219e415c840a088502c8bda7cd2fb525bde1a83a5903d6fd96b", diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index 546835765cf..0230f5ca994 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -3,8 +3,8 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "8d24f52eb4194c3bc5d9f0dcabade6d7a09c066f79f911657647ed21dbecb3b1", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index 52fe62c2e0b..8e064f13c2f 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -3,8 +3,8 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "4cd0be3a1c338b4b68211c717fd10738653c553fdb3b072db6706ff8175a8bd1", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index f99113382dd..8365b4dca5a 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "dd8b7a0916a84c7d763a163759c02210ae99f8fbccfccaa98796b8610c5da97c", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json index fd1d3444578..e6b9a284029 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -3,8 +3,8 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "39fe4bce80a2323dbc276235daab48e7fe55d1fdb9c1ea1eeac7c17bceb8df18", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index 82154035ccd..9e7774c18cd 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "d15c1f4e0f95b5c49a8f889d2d37458225293d7306c8439690d972d2df4c29c0", diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index 38689c6a741..6d87ede4381 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "055f3b45442c1736f10ee98c493e2ece1885fdb68d925ee627e2ba20853537e0", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json index dd8668b178e..88454ffd21e 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -3,8 +3,8 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "767af05bb58756f4adc960b95ee6547cc28145c7f6c7c6a4b27582aed9741e37", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index fc0c99bb440..b1ea27f78b3 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "0ffaffb472b663e08a42317d799a2a000211dda5b127fb21cc64f04f73800130", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json index 81542177808..9ecf6bf32a6 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -3,8 +3,8 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "21c3c61a18939873157f74c8d2d28bceae01abc702565af1ca897ab7304cb93c", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index 515eefbb070..e3eba1df83e 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "bcba4a7d9d929078c5ed80fc7e1acd45859d0b4c559767a919b0396dc6a70a3e", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index a2c2b81e3d3..7fbaec502c9 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -3,8 +3,8 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "8b5c3e87d989966d7b252f19a537040cd5078ba9355a824e7e49af3424390a3e", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index c4372f02ff6..dabd9d051ce 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -3,8 +3,8 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "6e124d16173074d851d58593b20b25889ed13a3d8021c08fbed53b85a7d3196e", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index 010169d86e3..a7bd4e6fadf 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -3,8 +3,8 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "b636221f719dae19a3af6772b687b0bcb300c9910645d23e98c309578ec1c5c5", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index dbd5b10a795..3e99ce4585f 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -3,8 +3,8 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", "scenarioSha256": "366426641e25542fcc6fcd351ece6a1ee897c8b3974c08eb7557eadfa4d3b06f", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index a44954f83fc..9536ef31302 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -3,8 +3,8 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", "scenarioSha256": "bd5f4e5f24a29d96c4c98950691c6571918332f71ebb1f29ee97a9abc857ac29", diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index ef593c8d7a5..ba33a04e3f0 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -3,8 +3,8 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", "scenarioSha256": "1ee6031227fa3efd5b36841afa60f7b2264eacdd9c6126e5851d5acb39ffaadd", diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index 19937022044..52012746a34 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -3,8 +3,8 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", "scenarioSha256": "720add498c79425ca8efc9764fd5d8307fe33bc891842604cfc899f770b79811", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index 045e5f773d1..6a22a0024fd 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -3,8 +3,8 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", "scenarioSha256": "5dd5e7eabaabba1e471b13958f59891c3b28f553087c96315598c83a14ded7e7", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index d4169a8db0c..20aa2ac4f55 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -3,8 +3,8 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "6ac11f1ab42fea3b718d9714e512a4e8a344aea66ae170fbe9ef2b5ae82dbe0a", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 395a570dbf9..800bcbba6bc 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "d6c57a5153d915f0a0c0fd9e305cac70b41b7eb8be226fc865227ebf1821e5d1", diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index cbee7d9c27f..c8df79bebe8 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "2d5c6dea28aa1a7bb9e4aa14a4c8441527d9ad401ad30161f05ea1f8da836bb2", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index c914cff468e..caf48e152f0 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "3471f5bcd6923c7b8ba3a737bb45b5239689deb78c00e85a828f38a6d6d68a05", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index 36ebaee3378..e5d957adf6f 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "73a468d5c7a51c2dbb7af2642f0050d05d861fce29295460c48d7c51f86bf57f", diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index 65b587d98f2..296af5ce16f 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,8 +3,8 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "8be12d116865d27e8dfd37921d2c723d63da101ec1197b1f5b2d9510838e1943", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 3cbb13bf6cf..fb4421ba4a7 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "46bbafcc57fe2e3aee41a14bc26a0375b7b56e58030705fe4c28841a272b2560", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index f00062de9c6..1da7dce7b47 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "eb80283956c93849778f23cbabf1dbf83b72744197af4f6f50335b2fc1590d87", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index 034b415bb90..8cf67cfabc1 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "3a8eab831602443d320ca0aa0f35dc269d8d511e76bdae8fd025c433561d068d", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index fc3552a26a9..964e1076f9d 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "136fb1d8d5925ad12ba22f4dd6c72573a9ad03b6a6ec8308668f0d9cd71aa36d", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index 5e81ff139a6..349e0a147cd 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -3,8 +3,8 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", "scenarioSha256": "b6eb40d9a91c89179483afec93fbc121b99ef679d3b2433575b26694d0c577d5", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index 78808fa4fef..3c57220eb32 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "056abf42b34537025fed30a4401bd465c93bc21558babb826f21b5c803b487eb", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json index 46a6bc13f10..f704c4b9a5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "81ed7c1a4276cf1d891334c64c06362b88b5f9690b3ebae6d0988559b1faab0f", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json index e35026c3e02..234c2446e74 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "eed54b1c4c77eec4b411d8daa197b20c8cfb4b86ac2d01f1d7ee2de23c27dff8", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json index a6efbb55d4e..e04d08c9c59 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "211e3780edc0ff5fa0529c0de0e8bc2fd746a19c8a6b4e49900f6c4e56d53f7c", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index 3c62df181fa..2223a62abe7 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -3,8 +3,8 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", "scenarioSha256": "efb8d1cd2a2ff0ade4cdc48a1aacec565e33b405bbe95c100b86534cdde49740", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index 522bde605d0..a4c79a7d42a 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -3,8 +3,8 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", "scenarioSha256": "30e00c0d94413aab61b164fb9a658e526448addc4c42fd8892b1c28335d30beb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index 1063b637e92..07b4d69c4e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -3,8 +3,8 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", "scenarioSha256": "b146be741484f2a6dca25e97f52b9ed10f8a2b28bd00e6b56acffbcd82136b2f", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index 693a8456f47..ceed0f10e4c 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -3,8 +3,8 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", "scenarioSha256": "5365449c24d789c6c01604b520b795502d0bec352032686efecd121fc4497f96", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index 288de16ce31..798aa45a1e3 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -3,8 +3,8 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", "scenarioSha256": "a5698ca720dad08561a509c9c18f7586611fc68c58bd86791f4fcefc7915ab1f", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json index e431cdf50cb..5636e0c3932 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "f721ab4438c4183927ce5ebc5fd6f1f18414701a5fa3b2807c359785829962a3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json index 8bd26bb66de..3d013131d86 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "aa812132ede83e4aced73a644e996df82a38bfc70ead70a5db2e9af8a8b1bfff", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json index a377799281b..f363314cf98 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json @@ -3,8 +3,8 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", "scenarioSha256": "c3b400967b0b1c7bd3f82a278f4b10855ade72d384922ec4d34795c7bb20084d", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json index d62a83ea7b3..0f33f4487ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json @@ -3,8 +3,8 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "9cae732e4227d4c2fe874a5fb2e104552c16cbc91b59523de8fd46454660cde8", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json index 1a50ec3cf83..8ff5cda7225 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json @@ -3,8 +3,8 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "fc3411f3f4cb58b6a1338ea943f59446fda7819931814e9ab002e35e2de42462", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json index 8f86b0396db..4e43e82fae6 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json @@ -3,8 +3,8 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "c1df871e84d4399e9a447caaff244241933c0dfc7c3d5e114d022e54b0ed7ed4", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json index 19cf2be58ca..ed66f9e666d 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json @@ -3,8 +3,8 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "91a1ef5e07178585f583dd1609543c3bfb0c71d13eb4ce1eedab3acc2635f0da", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json index 1ae43acd959..6ef9b1ad021 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json @@ -3,8 +3,8 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "a302565f0172a763bfb0865389849703d3ceb52d848e1bc40f2204ce7f97bbef", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json index 7830cba9404..73e49ca5130 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json @@ -3,8 +3,8 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "33cb8faa9c8b50945a2d8275140c65f483b8023c890a5a16165a759f0a59baad", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json index 7ee4a7e8d15..67b8b1544f0 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json @@ -3,8 +3,8 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "96c4862084d7ba2a8e07047abcee1716220ba35d9c754bcb97f7227020976c95", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json index ea17c64b0c3..7519d6ab85e 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json @@ -3,8 +3,8 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "c1283bbfb340e968bbd4c86ac63489995d6191290751316228d27779dc6a2c55", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json index 95369979ae1..b591945f11c 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json @@ -3,8 +3,8 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", "scenarioSha256": "092777c9ce457dcae95eafbe083c70510580569ac74e31c0d4224ac94b9fad76", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json index c63b126406a..b1cbf36a8e5 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json @@ -3,8 +3,8 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "fc690a2ac59a6fbcdc08f9b91e769cd793f5a48d9034a50c2d587ce0d0fca3d9", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json index 6d09f0058c2..a0e05833af0 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json @@ -3,8 +3,8 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "fa69748b6d0e29abc37757b48bcb22dac07af1686554200ceaf18b4e3d62e4ac", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json index 09954e76ed8..c8a10d86c6b 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json @@ -3,8 +3,8 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "a4bea606723da4d4a86db6e04c46266801149a852a8861b15e637d5c0855c679", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json index cad7309bbcd..06ca2318732 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json @@ -3,8 +3,8 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "06c2ad6d4b464f889a640be7a238f6d0ff7c54b0e93fb5ea22aaa856dadb0336", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json index a7964dd6d64..433da45363d 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json @@ -3,8 +3,8 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", "scenarioSha256": "cc6de73be00be072f9a3b7fd63459fd1a529c45d0916a67d5fe6d90dbee61e91", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index 07ced4a4e15..d921b1b4fca 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -3,8 +3,8 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "f88ee2f5d19b19cc53dca5180a9b5936a13a00a82e8a4636a5e895262b669dec", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index 5935cc26e55..e81314a362e 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -3,8 +3,8 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "79a49cddf66935007afb9be8a30593b778f02237894e5fd4d2898b526fc125df", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index f2ded5eb6ec..5afbea76de3 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -3,8 +3,8 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "8b8f7fe7227d44330e216e0bf5d366c41b54d9bf76acfb24df2c1770984b9f27", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index 5e556e84bc4..2a0f56af99e 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -3,8 +3,8 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "6832d23c6500e4fcb20abe7c53bc4f5abe72180dc0ad747a4907b82d99bc75d0", diff --git a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json index 6f955ea6107..093f251d812 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json @@ -3,8 +3,8 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", "scenarioSha256": "95f7dbb11bca7203d29bd13230d4a286f49ff20596535163094e1bff73e7f3cb", diff --git a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json index e46c048bcf7..5400b38dcd3 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json @@ -3,8 +3,8 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", "scenarioSha256": "a844134d7bad3c7c12107d60dbd298f5cfba778703fb9dd0f7ad454615d26b07", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json index 4b7e7daccfb..fa3d9634318 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json @@ -3,8 +3,8 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", "scenarioSha256": "38b5590173c1f6791d1b35c0f036e2f844ed4b0e39c880e8e376c5f314adaade", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json index 8cd459a6dea..ea7a8fd957c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json @@ -3,8 +3,8 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", "scenarioSha256": "8c1fa604104c5551b8418af225c9420b1292f0c55b392f847985947c66749959", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json index d0309aab357..7fe451469e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json @@ -3,8 +3,8 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "5815450e8d07f463423ca0bd8237830791c220234201abffc6fa13e698913516", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json index 0b94838a04e..763e01f1ba2 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json @@ -3,8 +3,8 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "b9cfb187224a4b42efe8ccfcd96145833730d135c4fffa345716f95991a4700f", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json index f349642235d..c91ac7cc9b2 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "0bf3b17048bceb0bc8592405facd99cb8086274509206d9e55cd589a05d7415f", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json index 6fe81215c6c..35165311492 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json @@ -3,8 +3,8 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "83cad9595793b30d38115ba7118ae1400ecb2af21327853183e91289f4f33a04", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json index 74b92ed6d15..76c1a9b3f57 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "dbd20e271999641affbd4b52635c8864e25f08aa6db820a46d0773faa09770c6", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json index b76e1ab7926..08ec2c14e25 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "c4a09003352ba4e97a17ec4109cc125298cf7123b317265cc5eca06e8dcc0615", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json index 47d55014861..f7dc939a68b 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json @@ -3,8 +3,8 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "ecdeff2713e454925158dde09782713d76f39455729bb25688a6ffcfff154f30", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json index 68af56c16f0..d4c1361675e 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json @@ -3,8 +3,8 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "1a4f7dff351be244712bedb8f495c83a531cfbfc5b5923267d1ed46bf2d6d11b", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json index 1c45531ff43..9084a207eb7 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json @@ -3,8 +3,8 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "8d443bda91fe5a1fa74910d525bb2ec40f639109bc71afbd42bccd949b3d463f", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json index b861453dc8f..6379581726e 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json @@ -3,8 +3,8 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "0962cb776492001a60db2ca89d715faec94c648962f140077fc9a85355784d81", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json index 9b9811763a9..2e50c5c072c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json @@ -3,8 +3,8 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "08d73805619f00748caa5726a7fb0d9b9c561f93ec1e6dd28b27065a4515d65d", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json index 65413da7547..c1b2f674fd8 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json @@ -3,8 +3,8 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "65895c028637185238baf4fd4f1297c11f528a289fe8173a41c48dc5a0b37c26", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json index 62999b7049d..49c178bea86 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json @@ -3,8 +3,8 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "2ceca0ecd901fd78838fcbdc789cbb6b96f04674b850a0994c7611f5db804915", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json index 361c4203d7f..4d6407f044d 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json @@ -3,8 +3,8 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", "scenarioSha256": "c4db2424b8a35fd97b3fea00b4dd91a3c8d50c6fb73795811ef5402e3d14f8df", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json index c3f9267b452..8ee3990f7f0 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "73dbbb8b96662af57240194be4af5726697d402b624a807d003ab56fea04dbd7", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json index 18493ea2846..73deb452a55 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json @@ -3,8 +3,8 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", "scenarioSha256": "ba8728e890e0e9c10ea163bd16f4f99fbc9727cd2f9c4ed69c56436d8bc29f89", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index 46bc933f03c..7f7c0f4bfb9 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "5605a2984d7692aa80e5e38f804bdfed4b1ce8ac2102def5dc728b1a79dc1acf", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index 72fa26f689e..93f2325e51e 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "ed0e97ead1aad0b45bdfc48f5fe4e498810d0cfee88f07d3c6228db56fda1dd9", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index 5b90d565c9f..e9ccb50838b 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "1db6919b94df3b8548838ff4c206fafa3a09ea096b17c04483f78f9321ccb1ba", diff --git a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json index 49cf0e2c7b8..41717ca9e9c 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json @@ -3,8 +3,8 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "42879d7300691a80aa0d09a2aeb5f8cb9438fc72b0ac7f24b0bbae4309f3a2ca", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json index a25621f1cef..2cbe3ad68fa 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json @@ -3,8 +3,8 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "afdfde4c58b6ff2047ea1b2f18b955074c0ae23850d4ac8783e69c0724096eab", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json index 5003cd87ac2..5c9ebe77087 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json @@ -3,8 +3,8 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "a428557212fd35e84506b671c2b254d7ce5cab486dada61fc9b5e5357f76df2c", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json index 5bc7755c672..c5683a2989e 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json @@ -3,8 +3,8 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "901b8f44a090b5871b3edc20be72c4f407889b4830fcb00be0e99c8c96274cc5", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json index 3b808329fab..72c98271778 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "d40c9235948cb1cd85bec5451ce0f4183074e0a12a35834d8c911c35e99a6599", diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index 5fbd35ff7bf..2a312060a5a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -3,8 +3,8 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "22ea5279155ecf749aaab521ffd570221ac3169b177fc1daf85ef93a49d38260", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json index e18865057e0..9c16122968c 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json @@ -3,8 +3,8 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "fc12c33acd3f3a34cbdb5893204be009bb089e2383a715fea4151c912de11ab0", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json index 7e25f818498..810ee0cc137 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json @@ -3,8 +3,8 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "52610bb18381d371e479a5bef4a160814547baa355104059949b1c35b87342bc", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index a6be0ade2dc..3e47daa50bd 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -3,8 +3,8 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "86254ed87ad3427d6ee4631d7348075039ba2d4d7496d59f27f03f78580f35a1", diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index 0696186a6e8..aeb13811112 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -3,8 +3,8 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "5009c22df7e74a850bcea41fc110ea7d7eb4bdada623837279f32eaa5149a9b8", diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index d13615eec12..04c11b1f35c 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "485b2751006ee8fb4df28b228ea7adda85779feae83974eb0f7e795e31c500a1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json index 01a4e6653a7..f38297e5044 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "0c8f09683408a919dd3ac2b7cd12197d8745882627134cde95ecee209575b027", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json index df5d5d250f1..d0be637b0f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "4f2b61df5e59d2efe81d467214a78132654035cbcb4d929385410b53f735c9fe", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json index 305146f389c..b1e211df717 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "04b2052cce6f24d9e208c992f204355639409ddc9793b3044b394c0e38d3c284", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json index e24ad921b59..e2be72e54e0 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "8f849437158296753a84a75dfdbaf69852bcdb6cdc6e8205496961ec16e4be21", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json index 66307350eb4..7efa0a96ade 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "77eba7deff3f15e64795cdcbefd009abfcf2379e7293eefb1f154ca1e99f4d5d", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json index 598cd5fc4c9..b0410d9bdf2 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "2235d05e0d2c6f1a9518cfdd76870e303cccd35289d6a44334147b6a5b6b675e", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json index 4d8d9bf633e..6cd4bcefb7a 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "2cea87c339b35daf62c963092c01c6379db43f2ee4ca5cb2b5f817975b0caf65", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json index 621e62ca93e..f4ac74fa733 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "78de11de556c91782590725819b22821732a12d3769f493d165c80bc7fcc1f53", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json index 696fb9cd7a5..dfa05dc2326 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "ac7e91b4d35021eca63af8ce01f9a2c7959109e4cb824009881437cb94dbfe82", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json index a146ff099ed..d36e1c26dab 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "a85682e5009d634bf468b8dbe4f35a957988754ed9c2b07d57ad475c1590d1f6", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json index e62bf1da0f5..c0de95e1e91 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "30f321fcbfaf09505bc6e18e64c09ca49c0dcdb7c5e12c4e27c2429b3e1066ca", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json index 22672a7875a..45ed408f76b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "507743a5925a37be32156b3d8df83ddb8e08c262d2c2f44bbd117dee4672bbc5", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json index e0c6bbdb4e0..26ad31f50be 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "6efca320cf31a1de988110004a9fb7b67fdad279a789e046ad6b5141b66e5bf1", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json index 52b56c00f38..c26c0226ea9 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "c77d7c7a05ecb9b27180ef28ca63a28e1c1ca42db2bb54a468699da77281ae35", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json index 7af8c05f111..ce5be18b2a9 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "3b3d3032b992fc7461b13de8a42512affa12fb018ad583898179a9934c13b414", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json index a1dc3d613c9..71f61c2549a 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "4ebe865f874b0b4a813860ec7356b8dc214ea02f0a9036cb003efe863d89b83e", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json index dda7d8fddfa..70069f2ba08 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "bc3350efc030f824fc21046aea8c6dc9a46993b6c75613c66874fde59af9171a", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json index ba541ff3aac..6ad4e8de93b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "9171e982babc4e56852fe35bafa7a9f3aeda2be5bd4648f29aaa04ca7119d5d2", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json index 18f9ccca79c..2a48fcb99c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json @@ -3,8 +3,8 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "fe91d5518501a078dff3010e74c4b9d70122f88a629e384336cd1b6a84de36a8", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json index f590354fb05..07ab3e23c2b 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json @@ -3,8 +3,8 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", "scenarioSha256": "047e4c8fb2c4b374406658ec3954ac00fda96928bdd999a33ab9867284ffb4a4", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json index 91be4949224..37b950f5bf7 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json @@ -3,8 +3,8 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", "scenarioSha256": "5518e08c1b20f0ddd4cb6bc81ff9af032b1a38daf24f3d3497bac5df8b2d0ec5", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json index 650d06369c6..5edfc2de04a 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json @@ -3,8 +3,8 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "0604294e541a4b2c73e0501cfc393c84384342ffcae286211a657ca5bc5892b7", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json index f63b0c9a8e1..4e326aea0e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json @@ -3,8 +3,8 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "6d03d17a5711db33473611858b225e2ac42fe33d2a982fbb0defdbd1dce037d6", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json index 6e501679ec9..55df71b99de 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json @@ -3,8 +3,8 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "f36f11b3d69397bd98651fdad4614a4115098e23667e4dfc397c1d9ff2dc3186", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json index 72800533d30..0b7b5cec658 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json @@ -3,8 +3,8 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "a70fa323de86b706f05818f321095349ed55b265804ec0ebb54419314a3ca612", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index fb1ef4e5de4..f0d0c26e42b 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -3,8 +3,8 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", "scenarioSha256": "a9a3191e2e8c36870ce2769a7bb972813f435267fdc6ed9e42632a57a227bbd6", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index b67f83d5e20..570bc8427f4 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -3,8 +3,8 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", "scenarioSha256": "30cbeff90a845ab5dd576e302156e859338357b608e87e9fadeddf18ae93d9ca", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index 9453088040c..bea898d3846 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -3,8 +3,8 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", "scenarioSha256": "7e842584620018d5ec5560711d63a472302e8da80cfe65dfcd2952aebb509af2", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index 4e211524f7b..5695950385c 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -3,8 +3,8 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", "scenarioSha256": "c47656a4ca21762e4b5a247ddf9a96efb746bec81e942ffa308a774bab1449e2", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index 4e22605638b..9db098ce940 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -3,8 +3,8 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", "scenarioSha256": "054f1b1380fc6cfd4b0f4a85d6f143822a12a0a732d550dd85eef64a6556b3ba", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index e4b2f4650a3..e92c1dbade2 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "fcf5cdc7388457156dd81fe28a470f42fbabac7435ec5572cb19e209f410ca84", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index 6a091816bef..38369dad1e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "26e666a57805f354602a5b3906a691b10c8d6db66c77acc96c67153279c515a7", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index 443180a31d8..0cf7bf4cb1f 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "08c25b6cb5bc12a7f67e858f229d15cf66b98b2ad4601b11f18c4c03f4a59669", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index 28719884e06..7cb806849b0 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "ff9d1bfd6337607f3d3e8162692b589ecea4a32ae01b5ebb3c602f8f0a55642c", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index 5f6a2793759..29c0d98933a 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "efeb9b248aeb98fac71c043d50afe0036cf804d3c11edfccd4e050fe8f3d8f9b", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index 44be0eb215d..82573facfd4 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "7b0d9ddcb8df83fc4e465aa6b0dcf05aa0d8f266cd4bb8651969cb8321bcf549", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index fdeb036e025..44a9294f299 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "72c1f08739db1c0dfcd48adffaca582a3596116c1c377f95f7dab8b08b7e6cdc", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index 96f4ee2b443..eb0082b2176 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "1f002f900c1a3c92e8f7c72261579ee5015ec1529c003a1b32bcf3eaf98b672d", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index ca0fc55ffb2..4357a06beef 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "3a6487a07457e0e5aa6fc3fccfa43687acfb06d94334e621081728de937e4e8d", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index 52b45e40336..e94eb772549 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "7355c45a707fa8a31f0999c4805a5b1dace4c65b727e711231f784b2f92c05ff", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index 3e3a46aec41..0f8cd996ff9 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "45cda6757b76399d282d4b07992dab21bbb8236faadedba5e92eab8818e886bf", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index c8debdf7a91..5d01f7040ed 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "0ca08d5e70e1780a6ee5c919491dcddb062a22623f803e9960a329825f274cbe", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index 3f02fbf5e23..7e7b229892a 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "658eb7bbf63b3a4b38eca0b1733e523962b6b6943644d65aab6f5c7e62534d6a", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index ba7eb3dc199..c08bbeaa528 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "75ba135731290bf734a5eef0b65f9ad8b7cac453c4e2006faac88a5da9dbe3a3", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index 631d5c32574..0c5466bc891 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "beb1161b98ffde8c5f1128e843766a1da3182d195f1f0a9012e12e5318ae01bc", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index feb07d6b8df..23fa7be32b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -3,8 +3,8 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "6c4de90e2617d204e82ca5e65eb17fc397acbcbb9dc0ec18594d2a7739e3528b", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index db2a3292918..3e27d9181ed 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "4f6472fb7add960be9bcc8596a748264d7cb0755a782ebe9e85753ab1d1d5710", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index 34aa0587a2e..b19049ee92d 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "048c3ec55ec67d09d9b02e17822f1154adca577e57ffe6d3059102d552d2f759", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index 512b437a183..e089cbd85f9 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "ad458a3407e3f1303343b46a1308b43535abef2c9ed2f68db59157db5b91daa1", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index 5a2740c5f72..aaa92100713 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,8 +3,8 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", "scenarioSha256": "52742d894d0ea53db89729101664a393b10794d9c2d2fe7b40b020643a13af81", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index 0e801224b8d..da2dae1680f 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -3,8 +3,8 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "8e00afc85e5b82d75bedecea0c748a3c8658cfc8545650e755c03f51fdc932d6", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index 21f2b3894b5..76bcd40a871 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -3,8 +3,8 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "40289acce4a3542773f74681d255d67cfddadf6c42317928d6728f26a76f6cfb", diff --git a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json index 57718ea57cc..1a0e6357e72 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json @@ -3,8 +3,8 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", "scenarioSha256": "c7de1f6fc0895d4ddf1b87a83859da46c583f098e058f14c8f85d48120c80c40", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json index bf0077f3ca3..6e7349d497d 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "f3fa2738875e15b640f21e56d3a0628333cd5b271242314b9c328822eec01d34", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json index 04bc0a4b9e1..54bfb0f2105 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "24b9ef7c06386b8af8303cfb196d2f652e46759b40aadb7b6af5144c964ea179", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json index b6e5023c1a0..001435c2e85 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "e13633e6b76dce4aec343c391359adfd39430e91af1b183140fe4423316c81e1", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json index c7ddd727d56..2052a1ee512 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "0bbbff1a914ba146777515d6d971144cc62f29f2d484a264513a1ce0f48be96a", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json index e8378a0c680..bd720303184 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "2ed321bfdc419635734c37b0a1cc4f85d6d92766846e25a37bd547dfa3af8f72", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json index 09869ca3467..04a8ff286d6 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json @@ -3,8 +3,8 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", "scenarioSha256": "ab7efe1dc6ef2ddfffa69c88bcc936f43393968a2281bc0ebfc864be650e7af1", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json index b2473a35b6a..ab82b2a6394 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "9de83f3ad2d85d1304a6ff00046b128a97318b3d859c78aa9acde73d9419aabe", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json index 9c7b0c2f96f..d49e9576369 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "b15dbbc7f1d9ecc6918c9b9aec395fca93108e39f6b02c82f559dc060383a03b", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json index 95e53a76992..0f1c1a0c737 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "89539ae4d46c4fd7d93f06e464da91ebfb6da6abad4eda03f67c3bf727c8731f", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json index 6e52c8c8b82..7e0463f03db 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "f453e5e35d31bb2f1638ae68d97db16a0955375d3863e124e2aa57f7d1fa292c", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json index c5b7cbbbdb2..6dcf9fa6ceb 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "8f5211fd3de1d77d3ea139f22bd6fc6119c72c08aa016f252a0df14bedf23ab3", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json index 6c3cf5b3d0e..33ebaeb8445 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "165402f60bc723edb2a867f09573fdae11a7c2ee4ef2be891916cfeb8aab21a0", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json index 490fb26c58e..37f6a02054e 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "443fb50425d5cdbf0b289c6c051732090f165abba9b1ba4c39b712a459c612c9", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json index 71f67c6409a..375f6007ba8 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "0478b692709ef0fe3cd75bf0d47fc27fcbb50ebc779e231537ba36638a0125f8", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json index 88f5ad10373..9de4da6cef3 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json @@ -3,8 +3,8 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "1305f50c6e838d89d0a9e65d9011d2a532a2f75f7b3aea73f9e33330214f5724", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json index d79468633c4..257c2a42062 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "860d3a80815ec68dc3fe2891a69888b80ec60fa205940e31f14643fb1097ead5", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json index fd1bab06b8f..6ad3bf8c8e1 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "c4193d972250dbe72907dcd32b8300b22986edfa5eeb651268c3838835177c64", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json index 839b41be4d0..c97ea154bcc 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "e13d3072f9a0ff4458ec4231013d29f7131144b52089914cd08914652a2e136b", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json index 36b021ab4ab..d6a8236605e 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "aca5a21c0928ca5e84aac346f543a6840691c92b124c845cd01e3f2de553ea3d", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json index 39c85bc9bfb..df3582d5321 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "53c8aa2ecfc045a50180dc707e353e22eef8511c0815ebacd3bda8cfb69d65c0", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json index 1b926be5a1b..7b3a8d6e043 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "afd3985ecdc07a1880aaee81739432633a1e87ec2de2c02166f43a6c78cb493b", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json index ab060747113..10decde0730 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json @@ -3,8 +3,8 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", "scenarioSha256": "b7a862c0de7dd4efcdf3f4db7c5aeda6b765ab58498f40f3b21232264758b16f", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json index 2b56242e51d..bf9aeeedcc9 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json @@ -3,8 +3,8 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", "scenarioSha256": "9ab21cda2ac956c70ddd23fe589778bbb46333314508cf92770d668ba59d5a90", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json index 7ccf450ac81..f54a44df6dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json @@ -3,8 +3,8 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", "scenarioSha256": "c77a518d35203370669fea20f7669d7695a2ec851f22b220a8be417c546efeab", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json index 8d79cc1be81..488dcb07943 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json @@ -3,8 +3,8 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", "scenarioSha256": "bb772b4b000644f18b48dcf78b05863b30906bcf588824d94eff086014fa69e8", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json index dcb001dfdca..92552ae7072 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "a28912c9abb97a227904723ff0de8162de31fce66c056da1813e0c18f6e01ccf", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json index 49a6a611f0c..6d11a54a2b1 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "ccd2ef7c617d13bdf5987f5f89fed6917206205268633b9ef061b92c3580d672", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json index dfbdf9a5b81..63842857839 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "399fa8b85d9c2fc341ea2284a54aed278fd1a5b19a84cc9284c29d5a583bc519", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json index 51e845b8fb3..c637941cdf6 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "14931cc23cd0e6d850f596c880014c606834898acbeee4abff3dc83a94b0c6c0", diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index 592d6869809..e7687eb51e9 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -3,8 +3,8 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "926f0d8c37a33d465bf3a04f056600cfc9f1669b1eca7e968aa1a1f797a74c61", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json index c07dd8a13f7..bfbaf6ea1d9 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json @@ -3,8 +3,8 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "042d0f9ef57e2a18bf661b79f2f8f92a12125dbc0fc65dd8605f8cd6f7059d10", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json index d8096f4b193..b3bb2e94afc 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json @@ -3,8 +3,8 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "530aa1f2ddc6fce10d485c8a160ae3e695250b2e80e1e7fc3e8dacb9f5117347", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json index c66b8114928..0f15d826213 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json @@ -3,8 +3,8 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "18b1855f354c23cd5bb7af0db698f0a23d28208c3c6b7347f667bf6cd3ed612f", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json index 3505c9e128f..ecf5638d3ce 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json @@ -3,8 +3,8 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "03355bc2696d02fed125d9f0e24c6c26c8df2f3709c1c5f4412aaf9317cd41d4", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json index 6cb47b865f1..a349f07a806 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json @@ -3,8 +3,8 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "d0c4dd34645308f30c0999ea74c16b53f20e9183832fdf016c3dc21434744b05", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json index a8f9076a9ac..e2f094e3ada 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json @@ -3,8 +3,8 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "0fe163f405373adbb1913dddd79d6d596bf88d69fc27c824ba5a2cd4c1406446", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json index 2926d88b6b0..6d01b7a55db 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json @@ -3,8 +3,8 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "5aaa104a652d4f10cd48ab742112cf59fca22f52bbf85ee3716505a9642fbe37", diff --git a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json index 14aaf5dd8fb..fb753b9038d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json @@ -3,8 +3,8 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "6c606813584d6ac89251c725a556d717603acde405a8d45ef6d844e06f2da67a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json index 40a81667389..ac229534044 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "06bf92360e6985770c08a9e53be0f55b6e8ff120d4e6411dacc22e88d08cef32", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json index 471cc050388..63313568f98 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "17f87233352ff8e452f061f4558d58b44643efe49313162d4aad140343a1ead9", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json index c1310d46987..bc7ea033087 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "5100bd674509d1d83b1e2594eee036af21ea14b12226185b44070555d1d43060", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json index 9b65e28a357..70c8b8434fe 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "b9ca49e401df8710924f200f92a071bac2e120ed43df7be2cfb8a325ab1cd9cb", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index a8d0336c035..f55b2658495 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "ae1572c1670073d0ac3d2d3abd0e7bee9910cbe4a0b09ecd941e887eeb1db165", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index fac141996ad..013b8d5caae 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "62930616f1017ffd4b74546c80b17f3aa3bcee52a6ad62ec144d95872fd31321", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json index fc99ff6b7a5..56f541a4499 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "e54684bad13aa8b8b4794e06351c709053b1c4c6d87776ecdbb1dd1b4406a694", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index 8491d188f41..8afc31fa6cc 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "c583058382c949e977b7a1287895ed9ccb8cb408b684aef186b629976ff1da4d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json index 5163ed413bb..60c48487d48 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "17e2b30594a2b37e82ff1976377722c2f1c3ae7f01857e50e010a2dd2e89da3a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json index a847db45637..73d50efbad1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "b99f51a5527e42a32ea9203ad75b16f9dd3cdcdc2a3ed235f6467ac1c7e3a4f3", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json index f0d0364cc5c..ee8179fe7c0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "b4627f9ac9bc090a2b48fd35f32d5dc3d66fefe0fb65abab75597ef2c73510ec", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json index d47a77b59a3..5a3e5126ffe 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "c9f720134506b6db71b742c219abe736fca1f90070403d7c96df9396fd048b6f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json index 66e43ddce11..343c24b8bce 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json @@ -3,8 +3,8 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "f17bb817f9a172e776f1814920d58abc7db122da9c49cfe3bbeaf217f82d70d7", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json index 341e65611c1..6053f4c3f5f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json @@ -3,8 +3,8 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "c4b6c9c21433876e2bb3822f289627e7c5607830559289d35cae066ff895fc23", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json index 052de7a011e..47aed719877 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json @@ -3,8 +3,8 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "42ddfcc6c8cac61e5891b72fbac9d04763fa9c52d0cb4f44e1044714a8a1ee77", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json index 649f363a848..c4cff8415a2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json @@ -3,8 +3,8 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "389c6118f3137b78e88af6b901554b0331c652063a00d8ba3119e0883ce82f25", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index 0eb3ced6a70..d94c2431222 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -3,8 +3,8 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", "scenarioSha256": "8a6f6d37fd7dbc9e0da081565f24b270949306c47a874568874ec8ce2579076b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index 23344c275bb..0e179cffe0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -3,8 +3,8 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", "scenarioSha256": "de804d4fa5287b07be9079d21e0a3cfc7f22d0e4b8649f13a3465a30683a77c8", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index f9ad70e2d04..a8a17200f64 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -3,8 +3,8 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", "scenarioSha256": "f7535862ae280e7e47916ad25701040e25d3318baceea52515c9d81b4144ae92", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json index 52ae3864556..4a503dec267 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json @@ -3,8 +3,8 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "d6a0472f274d55aab584b58b67018d042ee1ba6799f42072a8a5986f45554bfc", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index 052a649ce97..54ba64508c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "a60de7b496f8149593a6e89cd2d29e20fdf75d26536592fec6b0100b43322d3b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index 084e04f02b7..b12a342b4e5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "96499f352af2ff884bfa94996659609fdbd228e1d071ad462400b978ab8e239b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index 78fc9460485..3e7c78311e4 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -3,8 +3,8 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "c28251d769ca9788952ed4fb29d8665c870fb85f2c5a4f32f8af33baf44498af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json index 3dd0b230dc7..58b54cede01 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json @@ -3,8 +3,8 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "af78542ad2c449b629f8705b940ec92fd16f879a9bcc81ff6ae3a192f804fa2c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json index 2e32bd1fc69..8f0c1dbdfc6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json @@ -3,8 +3,8 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "1e7cad00f4dfcda65a3830b0b2468020816b940611af1b68600de33cd8c1d7c2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json index 4ce3480e730..0a94d21d697 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json @@ -3,8 +3,8 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "d456ec18056eb9663e99d4991784bd802422256d81ddd7509720814da06915f4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json index 775a8510972..c85b8d042f3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "039f26cc90d2239028d6ad1d9ecae9cc976d48f386f2d29fbfa425afc702657d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json index f8ba2492afb..198e2c923db 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json @@ -3,8 +3,8 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", "scenarioSha256": "97a92fc3e9b496a6295fb01e490407c1154cd19c54777fdce67377e07af08729", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json index f62f023686a..8ac29dd97c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json @@ -3,8 +3,8 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", "scenarioSha256": "cb052d2107df7bbf24927199935dde7f30352ce875c7ddd7bbab7d8044da0add", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json index 3d35a99e397..d85a5654017 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json @@ -3,8 +3,8 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", "scenarioSha256": "461af8bf31bbf47e84f64a9177bae34c9b796a3f0158a16041ccb0a1a3ea62ec", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json index 1ad91eba964..c6462f96ec8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json @@ -3,8 +3,8 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", "scenarioSha256": "1bde693e45b01d09c47a1542cb7e43b4b7798ae52755ba41c5812bc07a425914", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json index f76d26abf3f..3824d40d20c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json @@ -3,8 +3,8 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "0d06d27000a8f6ad66480a16c7b84e8464f4b6e1d775326aeae005926e134cb4", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json index b93070adfb1..04bb0a8e17b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "a288e105a0af6dbbd9657b84f7c4860826184fb7f50dabc6e31b114a70d7ca44", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json index 67d25521fe9..c1e88f92c80 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json @@ -3,8 +3,8 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "05d42780508d46d5c88f0c987448dc693bb49208a4298e6cf2f0d5e3979066af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json index 4ba9dfad544..6fa899a9e22 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json @@ -3,8 +3,8 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "9567f8594e0287954c1b236b2f0617ed06be04d2b97f82e527c8e3ce732e65c1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json index 296e411bd14..12565587163 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json @@ -3,8 +3,8 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "95c8c5451aabbbebab8422ccd867b70e000ac62aacf1d231ad0d181cbec3a2b0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json index 6a6ca61066a..3b2dcebd0b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json @@ -3,8 +3,8 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "91558fbfdf10c17a363e38cc0489b1a59e8b1657c51f7444840b2e7d37646851", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index d9286c28002..df79c2610d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -3,8 +3,8 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "651cb070a4b4ba7deddeb37c08886094b83f006e7cbe2bef65d5433ce0430dff", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index bedf832b5dd..415f8233f18 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -3,8 +3,8 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "e0ce643010302d304ba3611bd43932c6cdd0fa9290a3ecd4fffb4fc92c758b77", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index 6b9542fcf32..43d9f901932 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -3,8 +3,8 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "c6b3b4be6b55daf53ee1f5ff755e73a032c4da5379f90f4973736c032eb79414", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index 69f3ad11916..76e67b6b245 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -3,8 +3,8 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "a71815661dee6129a1b133c398b37ea89a9e4939b45626dada16889acabb2afe", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json index e63b89d8d52..5e916449d30 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json @@ -3,8 +3,8 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", "scenarioSha256": "f4d1e8eff8e1a3dc7b04fcf5dafb2befca2211922443f1958ba801c49f488152", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json index e9beddf0ee5..dbcaef52628 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json @@ -3,8 +3,8 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", "scenarioSha256": "7ba969778e57cc5591365c688ef13ce5b09c41db9fa3666d61bbbbfe4e3fd35c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json index f6b1dbf9a2f..9421f95d56f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json @@ -3,8 +3,8 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "d25fc19d4e3e9b12f2a60a076ea10741e13fc45dcd9a76bd3a9d88432c3e6fbe", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json index 29894131a17..1dbd4dc2bee 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json @@ -3,8 +3,8 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "5b050af6f02aa90f66340838cb5cc53c8fc0d5693d313b653c23881150384874", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json index 8e52e38b250..ad7ea2ce854 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json @@ -3,8 +3,8 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "64c12ae20aaf7202c2271134d57e4312127e8f909ad086ba69b4662bbd749bb0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json index eb3d926ceac..7cc08a7ff3f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json @@ -3,8 +3,8 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "89141e3e400feea78460ede72d5269bdc885f6d3de75388542b4b7d6dff2840d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json index e5f867e9502..e590b65756c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json @@ -3,8 +3,8 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "c97d925b63253e87ada0777e95a24ccf4e4e1682eda048800b44e335767df648", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json index 0877a240242..ef242034f3a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json @@ -3,8 +3,8 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "55c461287c1d8fb6de3c4fe28f801d095678dcf52bfcce39c336c9d61e8be908", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index 8fbe10feef5..25c351d48df 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -3,8 +3,8 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "73366226aeaec1581aeeb47219fc703917143cfd7f6a2eb01d7bd703a7c7612d", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index a3e12ab3d38..d81d75ab6c8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -3,8 +3,8 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "c38f2bc5c9faca0774dfe202137877bada9deba165c5e9c955cbe67eae0cbdd9", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json index a7bd96c2b7e..8d9910fc77d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json @@ -3,8 +3,8 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "fab12524a09049d976da752cbe1b837a0aee04ce6e1eef75cbf517c862cb0d4a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json index 1eb826ebf3b..2d8e6bec1cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", "scenarioSha256": "2ae1839ae5f4fb98c2ef250fb5e071508ec1376e737dab28ac589c08f1a42b0e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json index 903397c629a..77f5b727e5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", "scenarioSha256": "27e0d44ca22593ff2ecef93de075508863024f3f10f00161818962c07e7b59e1", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json index 6e332cb8689..1d6b0f0491b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", "scenarioSha256": "53ffb52ef015193fdaaeb1ac5a1c88488a607e6cec2e39e87d1e4e3173321983", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json index 881cf886aa8..7a68013f209 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", "scenarioSha256": "d7bc4d7a3e7e54accd4b51eef7e83ae70cc0c794738a83af222503a0f12ba70f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json index f960f787544..df3dcf3211d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "cde3cac5407ef731315d18fc855b2696b9bfd30b90cb43d4a2cc812059cdd987", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json index ba25cf37ec9..b6176064baf 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "6575a0f89894d9b66e50a73446dfe92293ceccc7048b556f1ff114fd3ce05b8e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json index 81503d53370..824e17a8819 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "12f1006d704e362552317a3afcb4db4366146da3a863a1c55b524c6fc1b9757a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json index d82c3f7778d..cb0c0a781a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "27c4f13ae43042cf5e6b5ca95e9c1a37db2f1f0c08b31a1164bb56cf0967549a", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json index 71820264d8c..ae2d911f42a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "449f8c793a371dddf1bb9b3beb36b2dbd9b991918dae1f6290df75fe6d2aeace", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json index 4c904160153..c6bdffbf86d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "5cc67ab6df80a81be814a16cb60dcc4c703b0fc17594e409697a9fdeb0edeb76", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json index 39dcf998602..0da09a38cac 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json @@ -3,8 +3,8 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "e7fb7a8083aac4c8c5edc7dd52465ca53bfcded00bf8b1ec18ae7604651bbe32", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json index aa35f80342f..8b2439c882a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json @@ -3,8 +3,8 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "b1e1b221ab7bfe5356c89a09cf4252613c78aecab48b2212e84ac7de885ec569", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index 424b42c40a5..abe11a477e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "a0effc9a0be519ccd18c1b1abfc8b497cd3858b89ea8d345ac0f8bd6d195cf21", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index ef180c07fa4..4b4741ad295 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "c21b2e0e97fab86664f634cc99d77dd587df4af4d02e6286c8380e09844096b2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index 627eb962ff3..70226717173 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "46c4e32a921612c736c8cf45ff72ed513431c917ed3dd03f289c0ba4c28d6adb", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index 17fec1f983e..a63de0e3685 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -3,8 +3,8 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "cf671da175d50a4c2e1336f4e8338c24c4752db111e1eafd226bee6ff3582b1d", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index 102e659877d..5e568e34ffb 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "01408cebcc193f8e30119381c8acf494fa5e29850fe010809deb330c2f9bcb36", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index f2d021ce8f5..1bcc830471c 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "19b445b39da98d28bcbcdab6f70e47ce208ca68f165e7b62c5fe9762eee67c8d", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index c29bde2ed10..97ab26944e8 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "4953dd0de509ce620b9840d7f460e472dc74f54d53636694d12cba2e3bb51da8", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index 1bf68fab7ee..8e2ae7d2bf9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "70f601caeaee957bd3b172fc0fc12e85d6e2d6bed7683c86869559c6c9f25834", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index cb23b2f5444..4316732f1ab 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -3,8 +3,8 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "c5db2d14be8dc1b3b3b68c151507da49d1c96b44f94b080c5908a65a885f1763", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index b872771c5db..3b55ac284ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -3,8 +3,8 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "c9b66eb3c676b1e18db588b8e35f267f5618c007ae0955adcc3292740fddfe23", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index 10fc651943f..87579c68f89 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "9eaad87e8f648fdbfa33f493f991f9c7943fbf81f93580067819c4bbb02b672f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json index 52314874f24..a382ecf0577 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json @@ -3,8 +3,8 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "c6d5040d0fd1e6b852625561aa67d11f555f5adb12303b6f8d0176183026a6b1", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json index 306c2eff808..e0b566c5bf6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json @@ -3,8 +3,8 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "b9edb5c27852d4e1e968f85668f1ea7afde3ed1c6e5c6582d6607f9fa5643356", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index c1a6b310105..af48e0f739b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "687b109bd2bcc0c85b7c858d553e68e2fc4cb5b281d9f8b32836dbacc4bdc8f2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index c1e431ef833..bfdf2a038a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "3199c745e22973b432b0a36c34bb0bdda994334a4a0cd7ad2daf8b172625ce8d", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index c2233430f47..25ebaff2e5a 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "5e9c3ff57cf432b24a17ee046636b61b94116a687cfa506cd79dee464542b76b", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index e9101f46427..d8d592224df 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "a88061d3d1f03074b0ed2b663b523f1602362bc317ba614106f1f646d037d6e3", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index 4f17c3cbd48..3e07728f497 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "50ba7c7963cb494e4b3d484eb334977d21cc69018da57d75a7b6fd0c92860bd2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index 1470afdce29..37342b57985 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "8dfd4550b39f0cfcb9aaa72fab0631b11f9e776ed389b206b326359d7f4c2d6e", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index 8b2f8cd12c5..350613e1933 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "85dc526201f66409dd6a411c5e14615b82389791ec210efb9889078f5d580373", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index f6deaee7619..96f3b42b20a 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "080e10ae774ef097082267da0c8b6d0ebacae582d57b04a189c123258d0e5131", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index 0c003776096..ae695726687 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "5988fa5ce0bf6b8585f7ec66918123ee086d5cdf1185a4eeff2e88904985064c", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index cb15881ed12..daa33e07d03 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "f5033a7a3567cc9e016bf09ac8bcd8ff381c3054c041dbccc773f7011918bf1d", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index c42a5d4c511..2d7fe5a7d67 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "0931b3d35868e5452cb550962f2408b6ce7cd6c89e90a9cf2897425edbb4b42d", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index bcad45da786..8b4c5376232 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "d0f8d9bfe0e1469af3b0dab8b5c9799d91cc2234e72f0e031d6872059654077d", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index ab64681e87d..801d3d7b486 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "3f448feff463b59c3927dae020ecd8d4931bb4a6036df6d6af080de2ec5fcf2b", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index 6dc04eb7ab6..aecf70a8eb7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "4438f9fd62876333bb980157612aaf457c5a9b9115659c8c941c3b373ad071dd", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index 7e5ff2d5b6a..2e57c82599e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "7e4c5bb29e0f630cda8a09233575b9295e485f3d3e315ebdc0458c69515fcfc7", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 98056689ba3..9f1f8c0b2ff 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "1d7713cf4c23d053105c2abb02340d81d5eb689f4311a0984932d8ebd031b4ce", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index 1524e3cca30..85ff2950da7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "994ea8b4ddb05774a8c2d5902bb68bf5e8f25399a787262b8f23f458f2790698", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index 98810a06cc9..b695d4f0e4d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "30b3f8d79589e9fb3d7ef804233554fa231f68ab88e5130ddfa78e79221e3c78", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index 2628f651847..d578487105f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "a5366cbd31d899feeb7e1901edd0c78191c2c8c8179ad5d5b24b7ca22bd538f8", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index 428812d4a58..a6811237c68 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "797ea410af6536410335ebe93b8bc354cd633cf980eb95efbb10bc46f5516cb7", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index ae91d586227..6c050ddf6a8 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "3cd29e7b6a1cdfd99796a58cf6ad6f9aa3dbac75dd6e989ea99ba6027c210028", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index 82b8a29cf05..19c8133d2d9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -3,8 +3,8 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "a89bdf93df71a958810aba72c80e42f663644781a29e934898e2ddf86c5dd5d5", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json index b57ff2e399e..7f00012554a 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json @@ -3,8 +3,8 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "cfc84498c2ed080ca2be50725c8ad4fac84eaf2e64ecad1445997949737da11d", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json index d136a003498..a1281ea3ff5 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json @@ -3,8 +3,8 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "2cb8e1e5c1c4f9da7dcf68e3fcd51a3979966019b5872279b7347d5f09f381d5", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json index 0bf4b8c2db0..38f6e9dc257 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json @@ -3,8 +3,8 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "c72f6ff7dad56e5dca200f2c9c71567f0d9811c7e39a9b9aee4543f0f9d9ca0a", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json index e7e64ccc20d..9768badcdec 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json @@ -3,8 +3,8 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "ef890c668aded545c5423322aeaab0e0715ff80d5f76c6512d6579e277853f6c", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json index 67ddeb4a940..9499a4abee6 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json @@ -3,8 +3,8 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "cd65f6b30f92d9542d19ad74cc9437ea33a1ec8fbdf4439ba13c14960f8a1994", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json index 81f1eb4cb0c..e668cc458a8 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "407a52b099cbf2fcb261010e4235dae2e5e16e11b386fb6bc99d5a2d237af541", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json index e1f8d9e0c90..87709b930cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "140ede79a9ee0c02bedbfe67551060ef85692b6cb71968a48a1c41fcb4863804", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json index c13c38b7da4..d0ae0814f42 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "6c0a9d2f2e28acd10c3a1f03888c23961e95bff02af10fe0e703d34fe6d60e8b", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json index b663a089e76..9a83422d890 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "d63062b89cc83321f6bcbd05c55dd21c71c6ff8c8026d026b5d94e44278ed3d8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json index 4f65cb80a32..5e9aaf36944 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "7bf61d34ba5217f7c00925612bb17542f3db17e1298294ca3ed647cd46278745", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json index 1edf63e5e2f..1308ab6b415 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "0186ae4ee9b3653fd9e7050a5316608a8e68f8bb463e1391862cdb3cd312676b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json index 91c3565c208..12e01cd9e56 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "8f5a47f74635937c88584eeac40d2d9bec960823e7bf8f242e48575355be0d4e", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json index fb386c94f72..89bcbbca071 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "c91ad2604cacc2b02ccd727333ba66275a27f783f3adb1faf64699ea0ffa67c2", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json index 4c686549dd8..7e72c357c71 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "984dd772f207f674d511d2711819855c7c6923d858ef87ea159842c337ed3ea0", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json index 5587d0e23f8..472b24cf798 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "1cc7fdf3139e0c5a81cd83987ffedc4f59cd4866fc1b4018b61e35eacc74fc11", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json index 7528c063dcb..6a3171655ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "154d91d00db23ea2718a6ee0b6c5cafc7233084532b3d561731b059d58e006f5", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json index 8a65c136228..0a5808cc9f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "ae07579881829263a2b5590249d4119f5d9f4ef3877149ac3b780ad985413f00", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json index 0f52c825d91..3007cc4b0a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "955729f9100dce7eeb103f7b4d08ff56ac66853b9ad0d33627c0838011287bca", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json index 672e796bdb9..7c5d5195b45 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "d163c6125fa180da7575642ee29f4bb18e3678079b7c30a42934550896d5b3c1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json index ce7ffcbb1cf..b341bb773d6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "83b30b2a160d162d16c66aa3bf6a633489e86dd4fd1c30828d2d244164b9c95e", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json index 9fd4cf59947..590b6238e0a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "06b06c84f4b8a1ee8e5159d8ada2656da4d7096729759c76601ad4e251310924", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json index 806c28c1dc3..484c8e36198 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", "scenarioSha256": "3ef6ef046f60d2d11ef81adf69bd5216036405eb05e5fd30cf1c402120488ee5", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json index 4e20eb50b48..fcd9b61e2b3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", "scenarioSha256": "a2ecf4ddc2c9870a8d73cbd920b46bb0663a958ac156dc6de092be3c9474ea2b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json index 6b0b99c45fd..aed1514179f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "f40d99c60523ad1a6a3a761935e86ab0739337486dd316e6ab3248980e575027", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json index dc9584ceea7..026cd54c13b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "27587bfb5745051e3cb27b01dc49b90b6f3c8ddbeb38f20772502fae2f562d91", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json index b80d2847254..fd29176f8ed 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "01664112d8f24d0a08fa7ba4e2d7f389acb363ce493e083f359f90fa0b87911d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json index 2bc0d7a7bca..048742ba25d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "186444ac8dc34dc63d1fbf304275e2265d96c6742e4c8f1e8e5568aa5504bf5d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json index 566c4d97836..5378dd7289e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "c6e5ae446b875afba3944a96d931fdca6006ed8e904374e5040088004eb9b044", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json index 9668343cc73..25e4ad86327 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "2da11d5a9c7a223a59c56ec416ab33acd42d900b4080132bde27313e042808d9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json index b09a3a33306..a89fea1be21 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "a5d8ba37f44421d3efd19617ef319f37fda3fe53004b6829ae2c320f85d5c98d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json index 47f0eab1820..1782f94cf27 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "abc4b882c92ba90a52d3635fe882b2c653bc91ab4d9927f240ebee4dd147b81d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json index e15a12d4464..a105db8e132 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "77b0812842903075bb3d3ec1f7bcea94b1e1dac5993c3c8854bd4c3d2a567988", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json index 434f9c7e53a..69774c6ab50 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "96a1813de0159396f6a5eb36a764fad4511de0a25eff6323571e1fade2a7f334", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json index 91fe97db45c..095bf5c9c31 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "57c41b51e34e451975a9f28a6461aa3b8e9dcf04cbebaea80ddf14afc4b78edf", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json index df318b399fe..a59f3cd6d1e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "64a7af1bfac25dfe673832ef0ef7776be8d1a628c995eafd938fa3ad11d7ba0f", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json index 195d356fe42..dc6bf053fde 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json @@ -3,8 +3,8 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "8443a59a1d432fbcfb9d158995cfa69bef364c33b66a97cbef0e70e197fcad4d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json index bf504f7235d..d3a64c77c21 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json @@ -3,8 +3,8 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "a60d7c0ce3d155116aecbb3d1ca015b4d9de310155f4e840b2fc391dc9d04860", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json index 0a259c1c539..f9e94c43f59 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "a2c0c5c200b36d2194815e67df2ea50422f6ecfd9df411132202e9660c87c41b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json index 88999336d6a..6c3505603e5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "8a5c5b210459d3938bc010bc88c69696a631072cd5a6b7e1a3eb3e1fd0da9c8a", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json index 90c562a2d2f..5d9de9fb801 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "ab0ac02611d487a9edd58319c6e4b9302148684f27711afc3b69feaa73fc4b07", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json index 547e6ca2930..9be0758f7e9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json @@ -3,8 +3,8 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", "scenarioSha256": "c4a5928ef7035ad8bed20945f235f4fa509238b3a167c91df50baefff8e8433f", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json index 026a6d93756..1ec2ecc669a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json @@ -3,8 +3,8 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", "scenarioSha256": "8ec517c98775f3af0d45776dc74bcaaf99dcc751e85343084e39c557b6ddede1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json index d116a214167..b28fcf6ec33 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "77b429e1066a877bb3c8513e89e42314bb974478385695ceb36dbaa7c57e654a", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json index 06466d0a16b..454b058312a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "8d58d134262cd828348bc3416f7208b51259dff9ea952eeceaab6e6dcfcfd96a", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json index 079bad0a6ac..396c38e2301 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "4dc2c4cfc9c29ee998796980a7ae6ded99a39f17e143e3db588239bc224b2cc5", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json index c1abb5846b7..825c74026ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "d622fc252f4e2e1d2e244d8781b260ed6e42dee572b8d2fd39d6c3b49baae04a", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json index 9b9312c7965..2860a714dce 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "7c16d49ffeace5c689309316da6e4638fa1b70d3ef52a78d27db934ae56cf64d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json index 86eca3a0720..74e1dffc6d1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "c5cc9a11a75a86780195be7d1055d1064c8aba78bb8e4e8bbdf033409c2b2aa5", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json index 3eafed93c8a..9547b543ba0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json @@ -3,8 +3,8 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "7f748bb55df907bba315ea6899c585837d97fa0ea9b312330cf35d95bda87bd1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json index 61b5453a181..b6b85694563 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "4ab4e4022cafd69cbc7af45ddf72d2dad3e8215df363511651d7faea014147d3", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json index 81c2791cb64..ad223705849 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "cf64dd43e708ff953ba2cbb2a70378aa8ab5d13ede157af7ba0967d913755b82", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json index d2eef17be22..a15040b6800 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "667a06cf9ff8129eee64327566012448abe1fe31f58c0d9b882b13e74db5a877", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json index c86f9f77cc5..1ac8d2d07cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "9fd4756c2224f3ecbc9ffc82e1ee11615a9e057e600bc56ccaf5c5e2c0e411d8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json index 8cb6d048ed9..4ddbad874cf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "cfe0284f502001a57a9d18585222e5ae4b254d9bac32e0bb4080ee9363e8e019", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json index fab7f9a74ae..abaa900455f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "b21a3b3f568bf6caecca62272f4b7882062027684c70878fd5f883622ff2a9ea", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json index 105046bdee8..f153b597624 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "e286edb942c338fd844ce8437ac03c872838228515ce833cfd310332354da725", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json index a6d1e7d928f..047a5da13a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", "scenarioSha256": "52b2cc222d9819121ba99f8f603bc76abe420d731e5853116e802a4db59e2d3e", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json index e36fb8ef5ee..f5c27bfc649 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "0fe8c60631cf04d33b34371155f5f80c8426b07b1cbec2fabc5d6d619f2633b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json index f328515ab3e..fb7afaa772d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "8ad021e101d8ef17ed47472a5fbfbeaebb2690a4040c89a0e9ee133e69e4fecd", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json index 042e19ba4da..1c396fea483 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "4d33ce12f3f35bea8a98fec2c0378e73caf7fbfb93085545e890b082a392cd60", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json index 45eb4b10e04..5f644abb53f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", "scenarioSha256": "3ed626e9d776e009f008c85f5165c8199bc2b85afd279a435e2e0f800737b42f", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json index 001d5d70a18..98f76cef6f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", "scenarioSha256": "1e402d7984c7958ba23fb700464a450374ad72424be8f2669f769199a1a2899e", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json index e5ee08270ac..62a6601b7a5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", "scenarioSha256": "1b2ead3380b546c94c5da2ed61c9208cd158db9a56ca87e3e72e450668e1e507", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json index 78bc91dddd0..d62d6b50965 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", "scenarioSha256": "947b1c1aa9688688a1e4cbdbacfde185fbf5d0b28b13e753ba87bdf7f999aa00", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json index 459d6469b84..232667a4774 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", "scenarioSha256": "cc450fcf20e403eea6ce1ee103c131feccb2906c0ae31c7cd3f3d3fa0b739d68", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json index addd4680c2d..5fa84c9593e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", "scenarioSha256": "2303fc902e5a6cf6a43db58ef2938538a7b770d1509cc22a9811f4c83051aea1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json index 535f47a005e..6fb818a44ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", "scenarioSha256": "8b75854487566ca6da98d391b6822c81c99e61011fc094458ae038905c1c9287", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json index 1210fd1978c..fea5dac99d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", "scenarioSha256": "7c948e76afb331e4038298215181adc97cd533c4f79e205e183c08e8a2db20fc", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json index 0bdf0bedd92..ae5b9635901 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "20e9631df87e40e64d35cee0c0e06b922fe53bd93e3d9e4078633b85a5278b7e", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json index 6b8d269c517..10556f5e7be 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "2d947b63fb35dad9bbe201061cb51d0b3c5a5e6f3e8018eeda2ec1f962952809", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json index 04ef2305118..ed6121038c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "ca6b0d81b19811806ce332a776361a40e526a73bef90cfa3df05a764e2ee83b6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json index b36102f02e5..c1f4f7127c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "bd58d88e0534366e281869ee79eb75fe65b418d02523d815d8d0d58799edc31b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json index ab82fbdf6b9..6c025fd7a14 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "0f220b97bbb64ef8d347973690e6fab4b305eaaa54c9b7883340415c54e61206", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json index b8ff80fa8ef..a2c9ba0ce87 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "85160836c5a5c9bae76aff82c834ceb908f9262a90facd6e055c289362664eaa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json index 67cd78b32dd..1c15fdbd02d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "5bc59cfd0d951ae5193df5c49ce3618d9536be0bc8f8192a7b03000963c2001b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json index 8684667f6e7..f2734d3a2d1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "0d8c240718b3911464a6fc486114d67cd6f60aa295cd0886ff755b77d5036014", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index 128daba6571..81bda4094a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -3,8 +3,8 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "e9bdad78cf60e3dd931eab8810d011c6e0066f9a336a206f8f9ca62621b21fee", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index 5b93ba7f8e4..d5b13c17559 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -3,8 +3,8 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "a5704c6849de9a45564c8738076ec8c0307754a5acc2c039880fe436771e68b3", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index 729f958556f..322aa4dc860 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -3,8 +3,8 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "e518040cbd40e3cc8c22e0b70b6de1087386936f75ed2f65f6e7b4fcdc344ba2", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index 98a85b69c71..0bd080556c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -3,8 +3,8 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "f02acd2ed6319b8cb674c04b41a1e96c50e53123a8914790acc8af5410d63f98", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index 9a0bfb80af6..a1df1ba606c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -3,8 +3,8 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "2b3006bfe1e7f3040b86ccc698e58ae19ae1aa11389aee2ec2a8f2dfdb0270f4", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json index 6aa7e0529d3..38a7d0eda2f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json @@ -3,8 +3,8 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", "scenarioSha256": "6373b83783b1a9b061bede3bba7aa3b573c3a50b897102a094df93f926856fdc", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json index 307542ab09b..bc1fedfa507 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "85c9e5107fe3e74f44d8b3e5c791b80ff0370cdc3ada69dc09fe38d8058fb263", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json index 9755ac2a4ec..75bdab932ff 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "0db7a8e1e5e740c341167bf045a0ab858f61708e995777fa4cf2e88cff1ec727", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json index 20abbb3fafe..5c2ba361494 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "5d9211140d751c6cc844775d3e818f125483f1dac5b78960557ccfe73f41d08f", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json index dea90b5cbca..dc16b214da7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "1cfdbdeca951545a4fafd23649991e93a02feea72d174a7c469f18dc64decfc4", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json index 90d2e1ccee7..f7910b19a92 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "9fc0825ec19c2c90cec44ee284e7c54399e1552bdb15c329727921a15ad62944", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json index 5b6e2c2b8e6..5f46ac251ba 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "014040846f649f3c6ca1175b610ed1b51bae3001e7de103549d7ee1a511a2506", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json index 36dfc6b780c..fdd0e08b0bf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "9efe74e5a1b0d92f674e6044edfd534693c15b0a153bcb2c0e283a6cd53fa61b", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json index f9935ddd8ee..2fd65f9ab5f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "217a3da50a54c4cbe19a68835ecfa038eae5b350430f0b8fd0a616bb5bdfe32d", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json index 477371db448..db5e02dba8d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "cb4c10cc6401e5b68340bd1fa09381c973348b6e0c7ffef563cde92080c57a15", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json index 3a55325a76b..1af491a042b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "038221907a57f5bb25338f9b07a744dbac3ea0a7ea2ba43281f171912f45a586", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json index 4a51345999d..64f2b2d981d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "e36b90621e11880ec1e6c16e8e3c89cc14f5d68a4a9e06b8a3098fc0bd0104a2", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json index cb413f2b607..41cc8236745 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "39ef28af17e4d3774b42bba1555606770667bc9010692fb1a4492413a940c0a0", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json index e499888b594..192846b835e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "c589700af145f58c37292dafb891b3b7d50302fd4ec044155c044ccd48f79c74", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json index c89ee7d9d24..dc58a5d5433 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "9e91d46870cd69279cc7d8ebfd317ab8b13136ccff9662876c7601a3a83ecafe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json index 3b83b0e1da9..9653601a143 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "2d4a681bffbc5ff9d3040ea0d6bb2603ee940c3c497269d0b63caca564fb25e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json index f8db871100d..29fc3ea62cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "3d4637406e2d658b72f73153f0a5e176cb8b143593b72bce0cdf979bc6e4cbdd", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json index 03ea809e2d4..1cbe3c60db2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "3453024581230908d1e0e9335f5310e7b4ae03faf50d93654b9ff28c464fb95f", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index c2e59e9677f..dcb8086cfee 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "28f7ba289c188bfc121ef7b969133711189e887fbfc7b37c8a5401ea7f30b56a", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json index 2bea4909183..5f40b016087 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "35771ab92d0d4ff44a1dd6e5f1e5d3137570a2e013bddea5ca77ecac7989ed53", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json index 5fcbf069493..e6a65b6219d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "39451d3c811068754f91ac243fe1208f4ce742df53261314345d8209ba761e94", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json index 22c7a877ace..a1de76beea6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "5cc1773d06d49d2616da72f2790753322edda6d67e12b5e41116971d34787391", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json index 3066f670d95..b2917c32aef 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "15b5694a63d54aeb4f4d9a861729e7e3b42ce06b15af6784fcb1afab744ed18b", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json index 35cc0012757..5abd5601621 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "68c039f74da08523d67d8d5b0a178c1d12507ec88d25891cd1377cd3fb40205c", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json index e501f96e59e..a35b5b8f1d9 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json @@ -3,8 +3,8 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "19d55a77e9c2f64f062070c9985f756e0ba72f3ccc3c884a80843fe888f42a47", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json index 05f0a84b225..ebe985ab90a 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json @@ -3,8 +3,8 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "6913de3f553b47a7e6663e21b0b93e97df26405c210db876f0b9593bd38936be", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json index 1e56cd81200..22cdca290ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json @@ -3,8 +3,8 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "da13ec556c0f672371a8cd2aabd4dcc68001c0f9aa47015dfa4b5937355c4c2c", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json index 3d5b7675a92..d1113cb84dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json @@ -3,8 +3,8 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "bde05ca916393d7f5ccd48686cf13a52fb2dfa599ea874b084c58bd59adb7e7f", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json index 273970d36d2..6bab4731f49 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json @@ -3,8 +3,8 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "874b1a120443ee679e2f4b3974762fd84fc929e93a2117b20bbb0cf373316616", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json index d146554c978..bc05e93b882 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json @@ -3,8 +3,8 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "d6be62e5eb2737d75634c053098d06a4bb175c64ff915cec6ead79922492a068", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json index 8ecf6b92a66..34ddee98c56 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json @@ -3,8 +3,8 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "1d032c86e7cc12efa3d5044339cb990bd258d830119d0e7b61d1b995b4df29a3", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json index 2fd2ffbb50b..d5e2cbba818 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json @@ -3,8 +3,8 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "048ee6d7848e0e4b8d6463ff9dc4124bfd478114ab87f6b52ff82a1dfd6ebc04", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json index 14a79fc1d50..943a41a07f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json @@ -3,8 +3,8 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "887b5d16b4bee027edf5e2f1122a79a34b223f6926322a99f6e3de83f40bf6ee", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json index 8bae81837da..f8f0b6cfe3d 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json @@ -3,8 +3,8 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "95ca47f382997c412da974e564a46b1ae0c20d6e0f3ca14258d33c8d8b51a160", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index 7b6928f2a76..ef535a9fa6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "e93a36ef900de27e1a566cdb2389ba4f900a330eadbb476b9bfb1ef05707b352", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index 67498f2d2ba..f011e623297 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -3,8 +3,8 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "fa0e28a167a5fba6fe7ffebb9f4ad28dd413d601c07116a24a7781d156f54beb", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json index 8e47d77c2bd..bdddf225e5c 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -3,8 +3,8 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "a84f8a5acfd428eb77b5c02a3de0fa8b780c666db31bbe574ecf76cdf84adeb2", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json index 97bb15e8c5b..6bfdbc583ff 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -3,8 +3,8 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "45783a7cbb44b04dbbd6bfd6735799bb4c75e503f43f1821cf8640d11f7464ad", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json index a3e2515a70a..c0255bf9170 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json @@ -3,8 +3,8 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "d321d6c17e67ae90f6ceefb775495ff765a86a35423ed33a212e71ec5e9e94aa", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index dc96a010c46..a5ca92320b2 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -3,8 +3,8 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "2fc093ec505bfac04a4ff0adab991baeba985253486dbe9e3ec9884b8d5f0920", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index 7a5e9ac19ab..7710b23ca58 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -3,8 +3,8 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "f70c6b1753377b5a502bf7d1e69dc95617f24c42137471320eb393567efbe735", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json index 7b8b9b7565c..360ebfbf2cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -3,8 +3,8 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "8487fd14ed779708415b264e2b80b26b0d5094e379a04f4f32c2cd75ec469182", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json index 15859b82106..bb76405eda8 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "9bab59797b04304d8e25573664b011949e9b15010270ccbfd0e3d2a4d772180c", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json index d5b93bd71ba..ba0f4e07c57 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "e3044fe513a5fc1fb10c5c00422d965dd760eeea37cfcebc79b4597141f4baa1", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json index d3fb08c02a0..b5a0e3e2546 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "5e9f4e6878be3f534288a90f7ccabc9cfe4e968aa29da372a98c3064d8e4d89e", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json index 78c270775f6..d4689a2d1a3 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -3,8 +3,8 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", "scenarioSha256": "7333b1c83120bcfd9bdac8888ec1528c1db9b36325509f1328a551676df219c5", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json index 3c26b7d093a..dad5f2a4841 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "8e88e44d077ce8f3f3e3214b80e9e8b1e770b9a73025bc284a8333d6a8be07f9", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json index 819bbd40ae2..db4a5e8d2d8 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "1e2c7d2c238bb87a0fd1896a0151a6cecd670c9b1b2679ca47b39520355c6e05", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json index a19f9027e4d..28190769fe0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "43575aede8307947e027239b4e582df281f6424c9216135ae20d150234f247f6", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json index 7591427d87e..9759e0c35f7 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "4ff80434eea90f31c03522c9277983cc1c88d0292ba7c384fa9760fc1dcb03bf", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json index 7622f2cbe04..b5e2e0a997b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "1f513883eb62cdd1673eb58809817e2b2f5fd64b74f80ed6e3b9d8c2ef2d33e9", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json index b787a43c6b6..6c2a5ce42bb 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "181b02243b1ecf98364473ee0b3f4c82a6037b5f5bae33703ab4f611cc050db4", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json index cb38b4dbbf4..87bc9ae442d 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "d7dca3742c4086f9ced0ba4b2f6c32ee9fa9272a5a955e8623fdc9cdb4c71528", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json index f11163afb62..8b37946aba4 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "09fd9bf97a4da7313e24f8c333b66c7c1af927bbea0a6d9fef9803856a608c78", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json index 63fbe211e87..35ab4d8e950 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -3,8 +3,8 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", "scenarioSha256": "db0397f8f6ae28de0cc7afd91552ee9416d7f7054a76a42aac02f480c6f363d5", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index 4d8d37e4c25..847f321f052 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -3,8 +3,8 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", "scenarioSha256": "674cab85ed9896dae311bfc0acfb1d1d1a2a7f25b7546b4714edbb0a585e6178", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json index a8b0c62a30a..5c66065baaa 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -3,8 +3,8 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "46d80cfd496b06ce42ff1ed985e513bbf49b5188bc1128c6d01a74675741935a", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index 8fbd0929be6..ca5e994a280 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -3,8 +3,8 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "08a9ab4f180f2d6bb44d2a23f87be0443e8dfdde15f966458270a1bb6f131e0b", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json index 61488a13e7e..f7c1bb3a18b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -3,8 +3,8 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "0c6afb12dce2c402dfd7ac24eb4a306e09fcacc78415ecb47b5320b2fe8102b5", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json index 0978e6b1feb..59c07c28d23 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json @@ -3,8 +3,8 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "55f675f7d3f380db10dd966ae6d011927dbc7eb8f3abd36e09edf5043ad1131c", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json index 7b32b8b21b2..29eb3e5786a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json @@ -3,8 +3,8 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "57b60064eca4526e5d533de5862e7e86ba69b6722cd04692228bdc768486cd76", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json index 449963a0c8e..dea871bd7af 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json @@ -3,8 +3,8 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "a180b7ae1f84bc69d7819c7c17b1e2915cb380e8ea8543d68fe6777feb90d0bd", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index 51984600786..6b46e9a71b1 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -3,8 +3,8 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "a72f08c12c244912912be34deb3ec12bada6b74f1bc29c0034b347dcba9ddb10", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index 631a86cf4a8..567a65f08d9 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -3,8 +3,8 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "a7419d4b3e00d49ebdac7db4fa97ad9d3af9516201f9102beed0376b181e74a5", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index d723f9c75d7..12d41bf139a 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -3,8 +3,8 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "977ee5602e3a612d537686d17d15312f8b0e775df8800b27bb0d42b8642b4675", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index 15beb5e9994..7d8331bb1db 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "3e8804f21d32370bb0bc48c4ea3d182748d48dc19d73de1b50005f18368566ba", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json index 5bd7e4375b9..24edced3ac8 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "b8265928eefc167de59c64dc62ebe40635007a5f73c87ec8b6eb5e0f6dc0ce00", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json index 0d55a9a3ddc..c7572222570 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "22f7711ed82a4d56f1886a746838e3eb85c555c9069694ba3aa958e121cc1ee9", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index 2a0e4664054..62a567137f3 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "7e05773966a18123aaae297aed9ac44bd841713de88c8a1fa30c31b324118f6f", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json index 76eff66efbf..53cae98b6be 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -3,8 +3,8 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", "scenarioSha256": "a89ff803859c60e5645126de8b0f423807c435dcd856fe8825721f71f3b5bec5", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index f24172fce7b..8a12bba0353 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -3,8 +3,8 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "ef016c6b4b77ba0c40b6feba2f670398c641f8ce9f411896e3ae41f38d8be17d", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index ea83eda06d3..63ce66521b7 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -3,8 +3,8 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", "scenarioSha256": "41ac47445191a24de5e48877478bdab6fd9c030f48024ea43e053de7b6b68bb5", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index 1fd713b3038..a5c8e8b6af1 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "24f8c05639f82bc5e32abe3864c3d01a0589e295369028929fed2f0c684f1d0c", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index 981e35a9ff8..ab10b762979 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "0495c25d6e5d8a84fe4192d7aa0e2901fe86ede19ac9c4d72dee27da6694878b", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index b108203fb0c..e9fb31a7336 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -3,8 +3,8 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", "scenarioSha256": "359add5203e68fcb85586f06babb694ee9d548e1dc58481e6d03871ab9f922cb", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index 592791899f4..a13d26a90f1 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -3,8 +3,8 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", "scenarioSha256": "e19c4ff95d568edbb5c0d6058eb17843be31bdfd528825985963f8ece8cbc652", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json index 625e22e02e6..f242b8d8168 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -3,8 +3,8 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", "scenarioSha256": "3edeb5f744ed832104df750544b99fea1a1cd9d1b8d2f3f9a5e7c13b415edc3e", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json index 85a58f01d39..46566837ce5 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -3,8 +3,8 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", "scenarioSha256": "53827d812956f05e86d72e44effd017270328eb36588ca90431cf9a8ae97b146", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json index b09c99df8d2..9700e71028d 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -3,8 +3,8 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", "scenarioSha256": "37439be5aba4a84cf6c12c5f583a0334405792387edb72ceab388e7c64109eff", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index 06d4e112770..927b052e0d2 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -3,8 +3,8 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", "scenarioSha256": "ac2b214ece34dc25aef2b73d020e04343f81cd48b3d243508be92cfdba01eb45", diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index 36342d7c935..fd00a65b831 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -3,8 +3,8 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", "scenarioSha256": "90f49e430518bfc6e662da1d0c55b0084f30ab54dabdd15293b1f8ad9d2fa592", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json index 4d3e2495c8c..1048b3e6c59 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "e4fb7aa3b071c1207f206adcc0f31e92b6bb98a80f86a0772e48b5cebcab24ff", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json index c87f6e0699f..6516d1ee3e8 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "5696ea2a62bb3f24902305f8bac0c4ae8f1e505f359db76fd69aabe353adae86", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json index a5984d7d569..3ebaeec062a 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -3,8 +3,8 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "9ab72231bf97fbe7eed232019c56568a9411433835eae424628af62c7c6a10c1", diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index 4a0ad095ce8..d9d0c38fdee 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -3,8 +3,8 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "d1b208a7bee947a603949fdc1f0d145e8c5576926f89f3e32330585f3a115290", diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index 40d92f2ab07..1e864ad75e8 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -3,8 +3,8 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "bd715681a856254e0c374b504cece07e5df4c9a75fa2b97c35498df12210fbab", diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index fdd18807443..50d7db74187 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "73cc5da2649b9687bb0d8247fa4ecf3c085746cf398515e8d2b40be2ed0da688", diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index 771ad40c43a..404a0877ed6 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -3,8 +3,8 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "f425131d29fee8826c00a550fb537d0bd1a37bbaf3b33992984d5e04a990a512", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json index 219377950c9..33f0e9263ac 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "70a94f45dbf7d58d9024c1cc4c94edd98fa48cd5ccffe8f2b7c53fa3e55d18d3", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 2ccb360ac7d..b970d64a988 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -3,8 +3,8 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "e9291209234bacab12201f4c13bb592e06bd405215389e19f0754c23e79eb197", diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index 66322555362..41245841723 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "ba21f7fc3966cb1e3c1e59d6e8a8b184fa0559bf3037365391ce23e364befd7f", diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index 703f26a5e13..f88ca141938 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "4246e7ffa62e85aac489c561171a6468f862b7f18eefac9926b65073616b6d35", diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index 106361e4ff5..465631ac1df 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -3,8 +3,8 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "b2445b299e18664b698d659c5041860b8a253314687666ae467aab441ab07235", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index 9ef1aa51b27..f1faa733582 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -3,8 +3,8 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", "scenarioSha256": "1b489d319f6517fe2ee33beaeedf4e0cb0f531c952dc7e9f833ca814366d60cb", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index b543ba96494..7adce3fc9ca 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -3,8 +3,8 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", "scenarioSha256": "d4da89a37325ac4e92d9eae8de33f9cc6d9414fcf89a4f44064a7b1e4254102a", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index 161f7930fa1..ecd60dbb264 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -3,8 +3,8 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "071e745453795d18d683aaab63e810783e3cee4927b47425c20a3d915397d0dd", diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index b4e6cff961b..27c9201b9b3 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -3,8 +3,8 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "669260c675021b37252dc5023a7a78e4e90536c2f35d9a5da3e53778e6a0cf52", diff --git a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json index fdcf3765394..d01873b1be2 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json +++ b/mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json @@ -3,8 +3,8 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "58ad4e4d5b0200c44f329236e10fd81918a1cc36b33b22cba24662c79dd61b4e", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index 537c0e85aa3..e255f6757a4 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -3,8 +3,8 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "8bb2ff4e899ee873289fed7c1ef12e7f9dba91949125b1f0e4336d378ceb071e", diff --git a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json index 3a517fbf461..4b778ae391b 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-send-locked.json +++ b/mobile/rpc-foundation/goldens/pr-triage-send-locked.json @@ -3,8 +3,8 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "d80bc346e84bc35e6dce70643dcd3af3ea9e5a7f8a1f2b7a9e92c71c0c30d4c9", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 69a99df1eb3..df509ed0abc 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "819fa73c7700b4d526da91c37558a6498008d745d1debcc26e6bb757550ebf99", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index cdd857540b6..5dbbfd56843 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "500396d72abd2f73d11ef066bca3f88798c8cbdaef09fa7c1c8d1fbaf0b3b85a", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index 03597c5372e..ac38c1a9198 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "daf68df8840ea6872521d823cc17e1e5de3f3a74a8855465fcf40cc276e9c2ce", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index 791819fbddc..d68d23fb9ee 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "61e36caf6b3bb01c3ad0db282b7f0fbc0f300d40184f9cf3d7e4e3a3194a4f2a", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index 83036b13499..6a3ed036a3e 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -3,8 +3,8 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", "scenarioSha256": "318fab8c1efb1786bbdaea7f13b769952c1ce463bc5504dca6a9f545e905b7c9", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index 16af2331366..84dec11e9b9 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -3,8 +3,8 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "42573387533f75122f626ce6ec81c1e61fe54cde5df416154bb7cba132f53309", diff --git a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json index c4a3c593c94..0d9637ff3bd 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -3,8 +3,8 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "a621156bbb662c78a0baa356b60d0af41d10a7bd14e54f23be0581a59377cec3", diff --git a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json index f3aad7bb5a4..20cd579d489 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json +++ b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json @@ -3,8 +3,8 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "72517336e451e1e078135103073c1821e8af27c0702f6dc83b9a686fe7ff8c04", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index 93b6a6c9dc7..4a40e741c78 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -3,8 +3,8 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "a92bccd183127829b6dfd85add28e42370d54990f63214940b1c584f7fde56a9", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json index 76f917d8293..1ed9400bf96 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json @@ -3,8 +3,8 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "7d5cad367e76767b039fc5ebc15837930f02c5e1bed33ae7ca4695bae56287bd", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json index 2e919e297f0..10205efd8c1 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -3,8 +3,8 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "8dc3fd27c5720276608ca8743990e4f57d94eab84f620e9aa42702a4686fd5a9", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json index 834c367a057..9aaa077af88 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -3,8 +3,8 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", "scenarioSha256": "7e54c3af4b8b8e6eac007267fb96620283b735491883c505401c79656d920ca6", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json index 44f5ef5d11f..008ccc1943a 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -3,8 +3,8 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "c786fb19f9593ee60238e42813561edf6f5e976fcf217dff8de977a333fe8451", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json index ba932154341..0a241931b80 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -3,8 +3,8 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", "scenarioSha256": "96df784c1d56de3bdd04afe20ab019339c7d9a616528ca2617e2fffe4f0157c8", diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index 4290bcfdba3..2c0a2ed5434 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -3,8 +3,8 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "36dbf57e82d87819b403839c22044209f0f399057f62efa7004bb8b899fdbe81", diff --git a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json index b7707ad5fd5..57b324b2d08 100644 --- a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "af837de2e273c5f03130b51d6e1a9bff9ff99a4e8d1c9554a53134acb5924c72", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index e4194123e1f..49ee3d3a351 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -3,8 +3,8 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", "scenarioSha256": "30f0d2ecb8ab5a51a85037196325d5bbac53eb48c5d59a5af62e3ab1fc9420e7", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index ec2ee81e68a..70e0452382c 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -3,8 +3,8 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "7981ac87c4397b9703330835327c70828cb5a76633f1c4803ded1b66520d184d", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index 8c09baa9fff..9b8fc12d50a 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "9f1c39e91a97266b0a550d2b2d061592ae050078401ef23177dd2e00db67bf04", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index f9e616bcab2..44d1105dbf4 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "c858cfca59148548146b4c0a775e9518d3ab826ef268ccc9af60b04e52cc9e3d", diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index d3c7f3b3300..e398bb2428c 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "2f0a6aa8ce100edbce9ccf64c81efe64356fccbdcd2e16f561360e2b11b5700f", diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index 4b80caec7c9..e47cc6533df 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "7099d7311b2046dde21a2750201718995c0869bc4f17fb7f3ce2b997ce0e6506", diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index 2a75f8b0cb2..c7aed259a70 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -3,8 +3,8 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "8b4a06c1b2918d5ed5f40102c1c7cb411e02aba5e7601e98658d673634784bb0", diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index ab20c3dbc56..06272ab4917 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "793e954960d371c6477d4cd3d70a5215c0bd90764684eafdb1e1a2e09790cc86", diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index a12ee0aad84..77f63f0810c 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -3,8 +3,8 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", "scenarioSha256": "46b720fed417d29f79dbde4cc7941579f2d6a2f920bae24234537480079e7de5", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index 00a64b4802b..032384fb890 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "374129def6baa0e06b808c067831820966638d79d7a782e96c1f2f891cc9dc86", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index 3d310ab8b80..2142473acbb 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "97a8b8f5b9a7c7467745666becee07f5dfc57fb283d4e80dcbe7941509177598", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index 32407af777c..7f19b46bd8f 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "25a4762d735dfb4979e6ef31b9fdb380941a824a54b45b3d08ddb2cde25c2eb7", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index 09a1b90fdf6..81d6cb3f354 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,8 +3,8 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "f3b193f93f6c9de41d11e706ecbd99648eb2ed41ccb7c66cdb80c934e780ed7c", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index 86cc36f05f6..c226cf13e7b 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -3,8 +3,8 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "819d010b9a8bb2741b09f104415f648e0e65d797882a2152ccfa4fb8a107cafc", diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index 7ff59a3d959..06d6496dab1 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -3,8 +3,8 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "f2c4d64b3761fcd036d9538155037edc4e35492e67ce5eb158372ddc76e55cc9", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index 92fe5944390..b8f0ed2eb68 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,8 +3,8 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "013db25622b180a8333bb1ef27c22a5b1f8e04148201201e5cc3413a10640781", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 3b64151c55b..ccf578aef82 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,8 +3,8 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "15bf6c17f4b524dfbf5373b2eeed61ee2e659421cf8b6e3cff6c0378c7692cc1", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index 0e13433fc22..9cde3453241 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,8 +3,8 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "540f05d84d1cbffd566af933c547838c75500bb5d708e8558c21fe8131d724e3", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index 51f8a0c20f4..79c1e6b39b0 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "7d6099248aa6a2ef19f2e169ff917af794649d9d64d139aa9ffeea6a41355ddc", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index b1d00507731..1f1d782b10d 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "447f9b3d697dbfe21cb7fb6e12d1bf5fa94b023b7e1697bdc2dc82ce7072183f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json index 451e4b41a4b..34ba4830002 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "7a032109038c14b0a0f254097939b877eba62c10ba7b963070988bb4a1c06ef0", diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index 1c87c3aedbe..49e08a728b2 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "2deb0435ef63a3e0102e28f2f3f331039486d193d1e1ffdfb53ad86d3ff039f0", diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index 522891bce56..770caf11fce 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "0f86b3e6059c48cd327c55df2452a9bc6ea85584ffbeacafad496f600c20e06f", diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index 7ce45d7f714..567136a32bc 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "5008e69a8e396b1deccd98712d92650a630f971cd76a02862a461afb8617b8a4", diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index 493ecbc4ef5..9cd4ba3a360 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,8 +3,8 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "ea71081982a101d0f8624707b59c99de9981e9b1d1bafa25c66d575e3f876ff4", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index 3347d9709e6..1b477541dee 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,8 +3,8 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "f6a1595073abe11b33973e8865900a1d849f44221961da5c12ea13aa696f6490", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index c17a15f2be4..6b4816c1d1a 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -3,8 +3,8 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", "scenarioSha256": "27e71d989da152adcbe40a02186df8b8a2e19a6a0e8bfe727e2133c4360f7639", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index bbfe575d693..a37f4a04ee4 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,8 +3,8 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "83f61085a91458bad529905ecc6fe240c598cddfe44a56dd497b8aed9fb8a7e5", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index 6e81cbc02ac..f2dba2a80df 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,8 +3,8 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "42b0304b2fdce08b7ff52ec979dd9f199f368e5e4ef0b5370acc417909b592b3", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index 61b62c268df..2ac4dca7e78 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,8 +3,8 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "4520bd54a55eabfe6ec64a4b2f824f095f98b2fffd1bf22fe4f9ec7f63cbfa3f", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index 277b096d819..a8d73ee930e 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,8 +3,8 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "b65bad4f8c0ae0b686f6c3db93bd43ffa072ae426f978f1a86ad8008fb24fa24", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index ef619dd7c91..5448e477fd8 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,8 +3,8 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "9de96287a4dfa6cf5c9a8b683cc696fdc2cd387f86f231e22ee3f100a2e778e3", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index 89bd5cf43a9..c9769f17f4b 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,8 +3,8 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "adbdfcc3895cc04d830900de518e689c9e63f6f75569127e1fde24488658e8a0", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index 11c2223667f..cbf8f0bf737 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,8 +3,8 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "ec2847b4af357d8564d8e0a9a1072713c1afd7ff86ba69e9c83c056a6841ee39", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index d06a2b81f98..6650363f6a6 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,8 +3,8 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "6cf6ebd20adc4cc76a12d3424863ee9db2b24f36593664a1f4e0e05de9a53d39", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index 225c5f8c6bb..2125f24865a 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,8 +3,8 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "e6e8197a541cd73e5811a1f28b0dbfc414a4d34c1ae6929fc1bbae1213820674", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index 75fc0bc8163..b21f4e6733d 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,8 +3,8 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "523d1ee21e3871a4dffc32f48d2e28c31ecea48cbf3f842acffc8355be06b14b", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 6dcbdfc417d..a67edc03230 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,8 +3,8 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "00b26cb279b0a934df98d93ba98a4a0c79e302c7690c582e778dc0156ab4f235", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index d3ff348fbbe..ad1a061f6d3 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,8 +3,8 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", "scenarioSha256": "fcd61f1ef46c42889827a87876239534b851c425f8ef7a9405ea95b8d07d2363", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index 4615be9ef4c..095d3123057 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "db1fb2a584cd806028b9be861283a63aa4836c83f61558f3d518ddbb7a59498d", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index 2cbfa79877f..1fda37d82cd 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "dba1676583dc832ef059285a6bd4c3eefe6be0230e42100cb9d7a125755d136b", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 249649a940e..740ca301a50 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "de079de9cc21bfb40da6e1431273b10c3e2a5b5402b91b2b8a85c8d7ac41bc97", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index e53fd61cad6..68cddae2d47 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "a29d518b2d075e8e4811404e0fcbf8948fbfe53a3aefbf0948cb2f2e622e8cbb", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index 162449348c3..6cc3c7bcb7b 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "37ba7780ca9525ab313c0a9c781ce5bb26344e63af9272c32ae889621f383b2b", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 11c912847ad..51ccac16aec 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,8 +3,8 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", "scenarioSha256": "93193acd57d6f00abc8e6c22ec3a8f1ca6ce7c5808d906d0aa7c11e41dab4635", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index b152860d0f7..96b8766c6a4 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,8 +3,8 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "b59fb599dd3a5fbc79bb8602dcec4b1c51a392c662efab7efc8324fc718ce8de", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index 3ad598bf809..1361096f240 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "fbd311a377672a9335521c30734880eea1b04bab0aff367854c1deebcf66b105", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index b3926c263e6..ece1bc29f11 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "2726d71130f623e3ad02c168c13269979ca6f84703bf1c5aaf36bd4432dfb516", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index 6a96cff79aa..44afd3ead19 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "664eba1468e229f9ac2dced262e7ad896ead01688dff4c570f397c3f8594efd7", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 91e6e5c7f9c..d25f716766a 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "7bfba3fae1dc33acf40e8a955bbfccf28580b3daee3270dec6a15e6cefd45a84", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index a34efacbaf8..11cc1db550e 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "02e3ca10296704b5478185e9d3dc0136596a2ee57580d7f9268672568dab4cd4", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index 74aa3b505c0..4b7e79a9e23 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "eb980fc027ca0200212ba6ad3bf9a1ab3460936a7bb4b04353a9462eecd287a1", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index 1c49ff2f43a..148042d56bb 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -3,8 +3,8 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "b4b584b1da9569f18475211ff84d157de3e5d3f300719f6848ac51c01d011531", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index c61a7764b01..f80d07ad389 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "59b5f0aac2f8c1aab5fc3a457fb69e1a2f46cc88c617c25e638175acb7f7c0cb", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index bf22310c8cb..30b7030cbd7 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "36dbd24dc3d24be8c14217ced1963d10ef8264438729dd146cbff79d9fbdf279", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json index 50a47a05734..3e8f8934628 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "be8d4b4be07b0ee5988471b26813d7d0d2a97fbd789b52e7ce00dd09a2e9d75c", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index d5855e0fffd..ea2c09a2611 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -3,8 +3,8 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "4118eea1175cba0f15174072f5715f9054ded09a4cb0c359fc4ee41ad00e9440", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index fdd4bc82a7c..cd6fc9007a4 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "df99b9c0ebcffb3d8f173c87783862edb0a66befcc3cd73f1096871f45963913", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index dbbfcd83bfa..c66688d71df 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "fb9828d5e13917ce394061b2d702890e5198df04ce803363b2ce9f939921d00e", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index 79edf50623a..0dc125b8ec0 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "e9c32bdf3357ca5d69ac07cb7bbc63fe279c1a23c44e5c79fa0b1db4fd1c55dd", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index 0e06f819650..fa35720fa7b 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "726e19307f1e82f0c8ae9a555ec9c27c8eea7f8f40fa700dc0b71c54ecc9ca11", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index 2fc63a5a256..78ca447b373 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "4dfbe31daae64a03fc588f2095235fdd9b37f26ecccefe596c35c8ef8f623651", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index cc56d0cf94a..731571d1c9b 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "fd654a7e34ae8004543a34a7a301d51858989fdc2bffb02ddf6edb814071b22d", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index 347d0680a0d..6021e78e9e3 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "da78ad25ea15e1c714f0147d34207529362743a56a519153f36b09345dee0ebc", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index 571cfd1ef31..1d44545800b 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -3,8 +3,8 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", "scenarioSha256": "f1952a0e2c9108dadf2f5b85ac1f43ec9db09db0ce81a575bb4dd3a8d18d9c83", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json index 7fdd660bc28..6fdbfed3841 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -3,8 +3,8 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "5ab16800f82813778078e84739e0ac72886c145d20789dedcea9428ee31b9182", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index e73e9c09565..5415f24d530 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -3,8 +3,8 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "795286ff495a243059a3eb55ccbbc9b4adfdd2034258f91f9182e83c7492fa60", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index 58de71b1d24..d627e17a782 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -3,8 +3,8 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "82c1d8e87e0a87c1c1a6dba7bd4f61e08f77fe0ae1b3758d1b5543bd9719a53f", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index 5c3d4f3fe18..148904495a7 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -3,8 +3,8 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "29565833b19968faa701f656d166050419f149d6bb76d1f63f4dd3af76b8e896", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index 78694a36ff8..d3fa143e382 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -3,8 +3,8 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "8375df17afabf282a1d37ffaa3a334b1fa9105ba04e6618f16082f66bfe3f76d", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index 45439043dbe..fb692c74d7e 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -3,8 +3,8 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "76bccdabb78dc3f16b36987f6b7cefbe41e08167fe39612620e27ad476089f93", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index 47477148d4e..68f583131b9 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -3,8 +3,8 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", "scenarioSha256": "a2b5b73b2455f9efc48361e4b96ab1d3ecc3d136459403c9c3678104604cd774", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json index 1cf678f2413..8658f34abbd 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -3,8 +3,8 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "f46cef9d1c6a5ed6d8b6f1d180cdf5c666f52e043b70feb07e5fccee885ceeda", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index cda4911f0cb..33e0040c63a 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -3,8 +3,8 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "f88c39d410655b229aa740b45ccdaa10186f41f7c6cbff9fed6eaee3f0a55844", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index a4413ca0872..77a7fad760e 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -3,8 +3,8 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "63227f28110e90acac78058baa875b1bc7f8895b5033e47016a2ffa9f42f66ce", diff --git a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json index 3822ac0a84a..962ead42810 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -3,8 +3,8 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", "scenarioSha256": "efb7ff45ac09f1b7f0f3ad7f1b6f09d4d5c349e22de7d9c4d63ca93530552418", diff --git a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json index 53c90f2ae81..29514b42006 100644 --- a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json +++ b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json @@ -3,8 +3,8 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", "scenarioSha256": "82ff29efaa6ee0bf49ce41bfe7dff2083d0f942c10cc9d8bab48925354d17519", diff --git a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json index 92743968b15..207f3f11593 100644 --- a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json +++ b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json @@ -3,8 +3,8 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", "scenarioSha256": "f540e4a06380a6a5d82d670230c76b7afc2c60302bc27dda13156b620095c5cc", diff --git a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json index 61503132255..bdb4cdcfc94 100644 --- a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json +++ b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json @@ -3,8 +3,8 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", "scenarioSha256": "18d0bbda11a394a13d0eaea12b8748dbeede1ae7212907b0ca70a9bafa037424", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json index d6157441f56..33ca026e92f 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json @@ -3,8 +3,8 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "72a972996461cc58bda2b1c11dbb4ecdbdecd5ff2f977c3d61e40d635f63c1b9", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index fd07d336255..eaa14d423e2 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -3,8 +3,8 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "e5049c9ee2aef93194adf1b9540c1eb4b085e6f975648c5ed605718d19fc6afa", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json index ea6a9a4a7e1..9e43997554e 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -3,8 +3,8 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "44e25e8624c6d7f072c5f7aa3c706df29d0e751bb59b3fb58bec003233f7e5e3", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json index d63a2e300bf..b4b0ecd34cc 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json @@ -3,8 +3,8 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "1b3da0207aef65f4b2348aed334284259170c640e767fb354b9e95f3c1369445", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json index 82e7213d8b8..d0e24e6bc37 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -3,8 +3,8 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "fe83a7d50d08f874d863eb8872bcb24d974c1dc46571a93d3f9184f8feba63a4", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index e3725a5ce28..52b82389042 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -3,8 +3,8 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "194b010d00fdeca85418870fd053ac500c38cae02e98c4bfc544b8fea78bbcb8", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index a376d3006b5..124db799d9f 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -3,8 +3,8 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "334b50e135103db21b93e60acff31047823371049171b57388df050179a6dcbc", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index 08158247ad1..0ec7caa51ab 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -3,8 +3,8 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "9e877af8539e5425f65edd6b3ff8af73e719aae2033980411a8e8a3b508dcd9e", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index 873b10fa9ed..d827b458f63 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -3,8 +3,8 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", "scenarioSha256": "927e7cda255922c2e6ac893f0068679774ce5de988376df1ff8b7d5072f7d127", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index f913c85984e..6bd44d0ffa6 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -3,8 +3,8 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "4f55a21a5c42ff8d96ccb1b16de235f91f9f7e38fe90de7191c36c8c16cc4e43", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index ed64c17b263..9862552ca6a 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -3,8 +3,8 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "29bee268df8e92fb1e3fd59291262c8d2d04a1b7e9fe40f6ed8dd181b0df828a", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index 7e04ed1d340..4a69e89c9ec 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -3,8 +3,8 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "101e8ce865088891800680db0e3df787b5f15ceb05ace9840931c384d9732d1c", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json index bae9383b322..557cbc2f2cb 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json @@ -3,8 +3,8 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", "scenarioSha256": "5381b6ade796596ac362d4ed64349266e65fe8fd2b4fc778a54315d4b6fda3da", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json index dbe4c4e7fdd..bc043532fa5 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json @@ -3,8 +3,8 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", "scenarioSha256": "a4ba13014a128c0e06a3d40b9e3fa3b0136b07f52a92c89a9afa3c73bc39d69f", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json index 8b8ad3bc1fa..e30bb15439e 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json @@ -3,8 +3,8 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", "scenarioSha256": "10295c4779f010c5040a7a04264a8094c342752ea5d75d887ab99f1a57ba4b2e", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json index 8b958d70a67..e0669b09c7e 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json @@ -3,8 +3,8 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", "scenarioSha256": "bb278c4da53b82d84cf50b308901ce196cc1f07c1a84790ad491319ee6746541", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json index 45de20c77ce..86d57c2119b 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json @@ -3,8 +3,8 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", "scenarioSha256": "16f645f758f2463bf6e16559d581afbc8794aa56069210f13a45952158d35a4c", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json index 3f993f999eb..f5dd36f2620 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json @@ -3,8 +3,8 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", "scenarioSha256": "fc81b0ac28970f9db27276c90f486306d74387f1404d757216a09823fedf680c", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json index ccc5afb8ee0..8916e2f7b4f 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -3,8 +3,8 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "d0f5ecc9c8fcb10193f481648215a54460ce5a54a8fbd2ded3921a9599fefc0a", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json index 4265e7188d5..cb1403d6d9c 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -3,8 +3,8 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "5c2b3237a14f9df9357ab458b2e21f2df8e68ad6bf6dc5670c0ace7eeb567e80", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index 82d09225a16..47cb9cd6e1f 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -3,8 +3,8 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "9f3fb6e58e8d9ef4f52b2b6c0a42b6e4285d5786060f966261795cf64d055f65", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index 4269b1b7857..35677b07ac9 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -3,8 +3,8 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", "scenarioSha256": "10a8ba5332f4fc2f90cff537ca69f2f1474339cbc4996f67a6feaea70669fb25", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 1c984c35c72..6de76d8aed4 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "4b4b4a8d1acaaec1c8dde0233dc49a696ffe53466578477efcbcdb7263dbd617", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index 921342520e4..ce0ce379cc5 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "cac4980465661fba372e187699741123a4edeb9270125a0a1cad7bbb6a6adebd", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index 96dd68d7f18..0e22643db51 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "f8ebd2348373b2b39c167735e0c418dfe868511fb5306ecba90cb6f2a905b95e", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index 61228b65873..90cd03474f6 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "f7e63144421a689f05cc50ad87ec901a9eaeb3163165656887be12a5f2753005", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index ce96cf093cf..87fc8071005 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "d8d6f738ee11d84d6e9e546624f4babcb42476432f6bddbc74e8519d9ca18370", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index 0d75d26cc5c..ee2ec55e965 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "a32b2fc99e830c58460e6f7c857aed0048738a55501e508eb236604680b9c235", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index a8fc051ec45..16b11039d34 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "78c48025c4af6cc0f1f136448c0ede9f76b7485d7b33b1356d11dec017bd9053", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index 3c37692974b..48628bf6b98 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "c22f33a0ed28622d4d53ae31e934056f87d2c10e8dc4475831ae1ee5fd3a9b8b", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index 4110eb31134..daa08130fa9 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "4f16b43dddfb9257828342b0297317868df24b03fd98a89c66f5cd1897829d73", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index 8d773d7353d..005df31eb4e 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "b6fb40be3bb92d7d9f1a79d99dee077cf95097077917679dc99702f912241fa4", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index 9bfdd98381f..2bf8b6ce6f0 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "31f8a348322551738b14207b3477bae492d48d45c5d51be4d97ffaca2fe2b6e1", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index 7e84926a040..f5923ca762d 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,8 +3,8 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", "scenarioSha256": "1347663aba0ada1eee8e88fac306757dc0d29fe21f0d062ac2d3968a30a2f214", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index 2fc87827a18..ef20ea6cea4 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "adebd553e2648278d719a1d7299cb36683fce714682a1ab7b49d4c9027eea34e", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index e9d551b4e2a..7f046994e54 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "b45eba2007e8e2668f524cd7503b8a711eba67816c9c35af5c3725a1afe32d8d", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index a542bc97107..b7e875213d8 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "e67b645d8c4fa42e93d4cf36f45311922f7b9d9d8cc597a12f55c664d840901b", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index c84bcae68df..121cec5c29f 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "b1c0b957b828c32e7ec388ec6668273fe84bbe5d11d8286b9a246fa92395a26e", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 308988d82a8..b9a5b38f5a4 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "867b6905c8a533ddd1c7c8174bf4aadd5fd725cc72bdddbcb2ea8af26e219078", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 23f2d905f9c..e942c42300c 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "d8a00a72849f1ed254c3b35ebcc330dd1bb15b189f006bd1517853a19e53de6c", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index 300b8bd38f5..6115a206ec0 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "741db13a84dbcec2e97e80605d742e69558954657c72f8450f3f8bc177dd01b6", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index acce2130571..0def47fd07f 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "7a8d0a5305aafea56733c229989b6e825fe9b8a681f48f6cef405350304520b6", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index d54fa729c36..f1e6921dbeb 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "a11756ecaf7c2d3955b9512aa7479ca55d810341f1492f472985abb538e140e8", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index 5d5a93826fe..baacbabaa92 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "def0640be601a8013f9537f161b60d8c14ac9551931e5ee4d4cc2acd3c2baf2a", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index a44f85a613f..b8ebe3f59b5 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "17c31d0c2b7ae5322fd59bafcfa1d2779ae9eff841e12c0cf16b27e454b49f13", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index d822f3def93..9794d1f9ceb 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "c08cce5d1f71761dbf504863b736e5546abb42b9ff4ab8ced65c7c42e3d66c0e", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index 4b7879a5b7a..ff1372278ea 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "b23c3076081901c89e8a8fb8d20028e03f030db040c9cd793b6f2c7cd49d8f25", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index 7882c28fd75..c9ee45cb0be 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "a510ff7505cddbd6dad3c7e5a2dcde206a5dab1940901511d72c97aca576a6f1", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index 30726bf8da2..7c4ae69272f 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "d4a2fef3aefb78bdb4aed94fda982124f24a3af3832227654d324735f44aaeeb", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index aef7cf226b6..98f433b5fa5 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "662c3e04e31bce5757f09f91e3e3739fb9d57767b7443be4dc936705b64b1432", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index 987ad45fec5..9b8726f44bd 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "8ae9e1dbb32d404eac9e01f71dacf1c37497030220a8e988c0093bb7ed2d159b", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index fc8f1f45b8b..64f78be62c9 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "5c4c890e4c71e80fa8847a5e29700fc9df3ac3bd634bad6289db37522fadd621", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index f7fdf4e7a37..a3af5656c50 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "a699a0a5b128fa422dab0c7557b5aa18599b2d23fa6685cdcc02e17edf328af1", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index f96bbbcbd67..fcfcfade1e7 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "a5e812cd508826b3f01ec3798c621ab4303de6536a364113f01a4770dd197bb5", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index 9efe504aa9a..1f69119775c 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,8 +3,8 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "bbcdefe16b07068a81f3c46ae60df01ccb0fbe5a7c1eade3f584f6f0130c23fe", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index a551c66c019..7fe0cc3d848 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "287f94e469548f28c9d5591ff6ffb916fa22caa18776b091542b75704c9e1fee", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index 9b4a806e12c..004a849887c 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "76793a56e7b9e596d8c42e9a5a1c47337db32d7437bec2e41d6e7253943f3fd8", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index 89dbfae21cb..25078c7a37c 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "ad19fc24973b49ee7d14bc31460a7e4af5d207db6a2375b52b5a0aa878e09205", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index 315c35fa286..07e001c39d6 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", "scenarioSha256": "a2c80c9cdbb631f3a8fa648dfbb9e691418467d6ead8fe769d72e7e1d8b552b4", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 0f3f283987d..c8733f487b4 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,8 +3,8 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "b10ff86086c134284cb0446e8857cd4b55f5ff2bd0507388ec659a95f25e2a19", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index 85658a24529..786e8638399 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,8 +3,8 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "373ea3743dac4e0845df01d5c8f75c909563b8c517f3858293a478234dc9ca5c", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index 095370f8d7f..7eb5a0247a8 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,8 +3,8 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", "scenarioSha256": "dfbacbd6392ae8e8199550952fe917e7c01182349df6c99a06eb0682cfd9175c", diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index bfb9046559a..1c50939776e 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -3,8 +3,8 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "08b583790d858a4cb7cf7377126818b6d05766c02587343ec89a167f94094082", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index f44d5acafa8..c3020d0b67d 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -3,8 +3,8 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "35fadd03f2c98344e22d6aec2dc85ad15ca5ac8e092fc1c29a20366db5d46628", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json index 41c7b139fb1..8dcf3faeb8a 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -3,8 +3,8 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "75b8d1b7ed98b2bf7420986958d0a36222b007c535f85c1308b534301241ec2d", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index 7a1ff3e67e4..a03a8a7d89b 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -3,8 +3,8 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "b1c4a6d6c94d54fb5f60eb2deb437562ededd724140dd3973d842d7c29bf1a60", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index ad401ae33b6..b3e2a74772d 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -3,8 +3,8 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "5fe007d384731f954e086c16886055722fab45bafac23f4c4d52f0bb1f47acc9", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index 2e235e66637..bb199073141 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -3,8 +3,8 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "cd3be6291ce181c4acfd7bacc0a8380f296fa5db13a52c4c0fcd75cfff3ae7d6", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json index 6d7a84b6967..da0a1ab7c2c 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "69c1c20ea667a2b6a5b53aeb3d9af11b2b707e04086e15ee4ff9e954b1701851", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index 3a1a6c85ee3..c7911fd45d5 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "748e1bc0575fca6baab51b19cbbea5ac6185fca2a15dc4d8577212134355cd7e", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json index 9fa69b7ad10..51c6dd72fa4 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "0ba2d8283fe98206c7500b62732e30acdc5b8e55f50b7ea8cae96403b803d519", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json index 79432b50a5b..32180edd077 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -3,8 +3,8 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", "scenarioSha256": "88e563aa087955b9ef6447b7e73711f90d5befb7c082270da58695c3a7169e67", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index 333bc2d9864..3a50fa016e0 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "6e2d1633645a3072241729328cc71ea2979533d28797c14de86c8b3457ac018a", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index e516187e248..8fdbf51fd89 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "0b5dd55868490e4d62d7d718821dec9634773b20d32fe9889523606a3f6b9168", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index 012234b0b7e..e61a2d22f76 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "64916f2d8ab51132b08c3e8c72f89c3a2698d83945def294f42edf22eb6d08ea", diff --git a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json index c5587972db0..16b143a9bd7 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "fb1ab1209f0a7c03ee1b3985401ce2df080d673532f045c4fca26e754d5e5a9f", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index cc740b81da7..2f0677e0056 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "d8928461e3295d265872e5f98fb8aad445b5edc55fc76f137e45c0cff0d8b961", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index 480c7bfcfa8..40b76d194d8 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -3,8 +3,8 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", "scenarioSha256": "c30e9cae49e134715c009a98f423f3e4a9d552c014be3e28b38e98c57999b6f5", diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index a18c0fed97d..d2efeb2bfa8 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -3,8 +3,8 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", "scenarioSha256": "b62ca571d4defcc2e53960033c5b4eb3f7e406b57664664cbc6a173041a9f803", diff --git a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json index e42eb977ed3..f65d0d4d2cc 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -3,8 +3,8 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", "scenarioSha256": "97e6d63c6b4bc154e94be6cf3dcd25620b4656cacd2b62cdc872ff793b39ebd2", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index 72b1ba62971..84cb1993c00 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -3,8 +3,8 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "7cdbf3308411ed6764cc1cdcc0f663a27ddc9390fcc329c49fad4b27cb5a7fd5", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index c90abba07ea..eea9dc3d88a 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -3,8 +3,8 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "eb5e47e3accccc7ca280ca029f97405905b0cee8a05afb9ef15448314041d9d4", diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index e39932ed38f..932725e5298 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -3,8 +3,8 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "391a4f681443f099e6ea4417811d82d730242dfe07e21f74b0ad49a98bcd7c81", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index e4189ade8ea..e69ecc9cf1f 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -3,8 +3,8 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "48bb495de3b54f2b2edc0543f0f232ff4fd6068d5aca34d005766ebacbb078c9", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index 0bbc3da2459..f1692019fa1 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -3,8 +3,8 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "78e62b9125252accc7f6d2ce772b93c84a917b01d2df308c1f9fb163d9127665", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index e523025d4ce..4578972ceb9 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -3,8 +3,8 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "bfa1d5f83b4112d3cce6a19e9dc27daf9281ed99745bc01bd19226dd7b268c71", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index e4ab35fa7ee..74394bc6049 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -3,8 +3,8 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "ad03596f31eeccc5e9eb7e5061b1af705f9af249f76377c8f5ce1a9db257770f", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index 6a2223d85e3..a5caedf8ae2 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -3,8 +3,8 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "76fc0c1499ec48a67428f35eba705f68147d2ff2c344b67aa0c9d60ed999f173", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index 4f8cc1c6eb6..382840b45d1 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -3,8 +3,8 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "579077efd3a4652a6930af3f6690138c20536270cbcd7274246047d2e322199f", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index 4e6257eb97c..80704511e80 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -3,8 +3,8 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "c2236257032fab72ebb007391313eec4e5f0a1bdb4fbcbd907117f9914ffafc6", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index dd762f743c0..64d23e49961 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -3,8 +3,8 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "8ad436ca6c3de8b0b3148326337acce18a8a4cf3c514acad1a4530a001f28c75", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index dd7b3900682..8e9d62199a6 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -3,8 +3,8 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "f966c2ef2e747a5231f423147ddaf38458cb08c719434b274016a5e4abc10771", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json index 0837bc71bf7..9b035e4029d 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -3,8 +3,8 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", "scenarioSha256": "88961a369cb7a7fa92708bb83aa7f818e904018e8cfcedc2f50ef9a0058c8b9d", diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index d447ea548a5..21869fbd957 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -3,8 +3,8 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", "scenarioSha256": "f921c4a764cdf7a4c1df0a7ec618d7c3b1d8a05ee4baa42d66f3bc0d95b3f550", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index 9e44f301562..e866d9858b8 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "260a84280f69c43ac582149d9befe6ae547b2ad2b5f56a181d8e3a1314a0a071", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index 6c28c1a242e..44f8294ec48 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "a4d3d1f322d49593056bd20f4122bbf0309d2161123e404161950bcab65decd5", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index 857c600e8e1..d4de6f59303 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -3,8 +3,8 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "f6c7eea985190d9edeead5b36f90c8aa98679f69d19dab7579fec88841645259", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index 3ec53124ab3..833d006207b 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -3,8 +3,8 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "644a27c1e3fa6bfa73c7822c2949e9534508e5e3a996b0e8e56be6326658d123", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index af22a977705..340628d8b10 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -3,8 +3,8 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "fb0cdff9e02bac37b0bd8e1c47922474823d46fa735f3069ed70cdad41800be6", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json index 94ae1971e65..fa93a9c232f 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -3,8 +3,8 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "cb696d402b8af2b9a54d4d6f9d85abfc12280e73b360196e55ec25530e610eee", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index 97e8453c19d..1a5ed38fba3 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -3,8 +3,8 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "d993b9f74cf8c5d7af8d31a73cf19d97141c54f6fcf009e98ca5b9106f3ba35b", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json index c1102c05a51..cacb119145a 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "885dafb68162f5777fc050af34dbe3443940d041a45bfb18c51dfcf433ed54f3", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index 3f1c693325c..b76d7f939bb 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "5fbff075d7da476f93c2a7da871c2afacc67822d1317acf6c23000195e2b2576", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json index c2c015534c3..882f74d59a5 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "c49d2df6c31f37c488ce9d29a801dd8ad0aa668ae380fc60b1f790ef508281d8", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index f203e5e426c..be7e2edb3b6 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "36bbd34ed8b8f67e516ce7cd230b01ab88f14369fda632037c46dbbd1a0d95a3", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 69502f036f5..380913445c8 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", "scenarioSha256": "f0407adc774b29553bdd177885e8ea9047127c4ebe8298de160a421fb4c1fe67", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index edf5030935c..ce9b3ae1bcf 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -3,8 +3,8 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", "scenarioSha256": "e6429c235d8c0b0376f71fea7b47c3b9ff22b850c92922b85ff993ae8b6cd6d0", diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index 5be02a07fc1..d3baef6b396 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -3,8 +3,8 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "28d8dfb15fec1ecaaa1c675c9c8c0cdc196e184a6884625c44dfe1f0f9fc8e7e", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index 5ad84c81948..bbbd1651c60 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -3,8 +3,8 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "010c65eaa056c5df0b867dd5b5851e206e8479e9fcce02515f1e326df4b8889c", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json index 031509457ee..7f8ec31829a 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -3,8 +3,8 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "daa54da6cfb8d96c2a66c138beeaf70c764f96a60bdc84f2ff4cde80483cb365", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index 0a8e93d9bd5..58bc7c096be 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -3,8 +3,8 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "4daf374f040e27836c154245d6a7fbad9ba3f6e6c0c9608218451286e4712d6b", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index eae52d4b240..73bc7f81249 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -3,8 +3,8 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "b8e60092a29ea4944a891adc026444cc1da18c52221876f9e7c07b450b34cea8", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index 966b7a299b2..b1b262be655 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -3,8 +3,8 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", "scenarioSha256": "3ba358ff7b6a95d9158257225483f77578dfa10c8643cdf89f8a81e0022997e9", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json index bf50947d1ae..062ccc873ca 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -3,8 +3,8 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "5de4936aed0efd0d8bd5bc5ce893d561f6bc3d0fdf4a76c9e33e7cbefd6ec362", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index f1e51ca70fb..fd64165dacc 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -3,8 +3,8 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", "scenarioSha256": "85414544a9e8570567770b43e6bf5cfcc406c9b9b4ffed3a4ff0c8187beb7549", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index 7e6217099be..18f7e5a703c 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -3,8 +3,8 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "d1bd7ad9b647a50403953ba01d3a4bcccc5f4020c2aefa6146cca8c7688612c1", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index 5b65e5813d9..94f30aad53d 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -3,8 +3,8 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", "scenarioSha256": "354eaff5243b3aae774c375aa303548d82c4db106a89ada39907d6654fa549b3", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index 98d6aa2487e..3a9d1222397 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -3,8 +3,8 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", "scenarioSha256": "524c2421e61d663dd1344a47ab0552c3bb029ad09e0b6271eac91f1548e8265c", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index 9fed9423d6d..2cb3ad010f4 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "9b26b8f112021fe65c156b7d4067071551bcdab44a5858f8a933f7ace66e6f80", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 7f2ace39852..c91195ed285 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "ba91378284c5dc3dc470f0601e4ff0202622a8d1fb97f27ab45c096d65c0bb48", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index c064f744d16..e7c6cadf86f 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -3,8 +3,8 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "216ddbf8cb71481d179563d8b033d7dc6cd71bface049e83073b977909776fc9", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index 9ef180cc08f..95dda3e85e5 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -3,8 +3,8 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "7840e811e81d3645f1c874b871158202a53432989f3b5c849df12550b082813b", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index 44d2975452e..f8a864be94f 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -3,8 +3,8 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", "scenarioSha256": "a83d5768892764af2dd6866f03d51c09aef63d1c5d3e0645bd5e1c16454b201f", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json index 1c78bdc3121..a97d889b08e 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "6f1bfb05a9df6482200bbab4400fd07a09955d47fcc468e1d2feda1ca9875aa1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json index c658deda3dc..db2f313d3cb 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "e5ff558593fd32d66e6ba1722ebf54d50992c9c2b6db41ef2435b47972fbd0fd", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index c4965b1fcab..9ad1bed2168 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", "scenarioSha256": "f572b37cfd15f41f283e5f96b772a1055670f80e68064ac81477d9b768fc971a", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index fbf658fbabf..e8e0c79551d 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "af54a351f4b79fff4a11948fc47a9f5194733065682ff96d6b46b9ae292e327e", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json index dfd27044274..ff4723a8d9b 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", "scenarioSha256": "fef96700723edcf7d0a55ee05928a0293b8a28217b161cd3823437f3bc33b1ec", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json index a11b8975379..34ae983fc05 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", "scenarioSha256": "60ea389fe16734fb53db01101f1feb4496653a99faa09e4309b3790a50d558d7", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json index 7d64712a6e3..4d2f7cedd47 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", "scenarioSha256": "08caec8e0ab5e674aabbf034ca88fc4a000c4b645ad6397afe6dc18db1f7c098", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index cc902dc1484..f063728a2dc 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -3,8 +3,8 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", "scenarioSha256": "815f55c0fb848fc9345a66bb7b71b1351f17e56129e005eb1776d6b47c831647", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index 9eee68f75f1..41c4151a95d 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -3,8 +3,8 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", "scenarioSha256": "21b6272878cba5f2b41c89378c178933ffc9406fe69b9c693fc5021a265ef2c9", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json index d56f731f230..9efc1401dc5 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json @@ -3,8 +3,8 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "c5d28c2973881ae6cc94c7d8f6eef544046461f15e236634489afd272b5f1e6b", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json index 9746648d883..7c6278f2c1e 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json @@ -3,8 +3,8 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "95ef0a8b60bf92ef5c12a73f923dc143989b34374fb319f812fbaa58c79aa6a6", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index 53356530c19..7b95181477e 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -3,8 +3,8 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "6e0c3a784992e383a05ccfdf34e44e6f74ebd55ff17c4de0b05b2dfb4197c681", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json index 4fc1823c764..de9982d7241 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json @@ -3,8 +3,8 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "2ba1e1d70c98e2fd0d2d2dce6f68c11a186756f05207747e156375fc613940d7", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json index 3e3b9e77399..1089ca5ab44 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json @@ -3,8 +3,8 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "f25f444aca6cf768c602bf879e6b30235d1c4c0632636cfd245bd6e27959756b", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json index 5048b46135b..252865e3267 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -3,8 +3,8 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "9c5095c24bdf5ab65d6387cc22b9984fee3aa7d5ce93d96bcb470944ac253f86", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json index c505e9a72d5..441022fe73c 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json @@ -3,8 +3,8 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "a7871b5f1d37b0156970858d5a7fcab3105de7a8a6bfe827299a36f2b2ba5548", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json index 40ca469ae80..a34068407e8 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -3,8 +3,8 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "4f0ddbea3c08ea3e90f6e707215a4831f06aed408065b45d0771028d256d6b12", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json index 329d8fd3db8..0d8a414b263 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json @@ -3,8 +3,8 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "488173fa313295f97aa88fb4bf1944fdb655e37cfd2444e9d15515bd6ad82d95", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json index 00ba0637e55..a9b9b16d716 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json @@ -3,8 +3,8 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "dee9824e5ec32115fa7dfaaf223fc34c28d057a5526ac3dad42365543288a934", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json index 0f86d726fd3..3bcafbc6460 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json @@ -3,8 +3,8 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", "scenarioSha256": "a34ffec446f9bbc465bd3f7d0a43166c9bc221a6ece8714e0b5717169625cf43", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 8b50cf561f2..b15d8ade815 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -3,8 +3,8 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "9d79bcfd6957d11d5ce8c3296f1038a3cfab81eedad7f990b44071104dfd0f91", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 28ccb52750f..208f268e6ed 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -3,8 +3,8 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "fb9c0e4b7c34f9bd1bd355b606c6ba75a7583cff3788000b7ed013612f5574fe", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index eb700e30d2e..28622ac3027 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -3,8 +3,8 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "d73fed49e0a7e3e054d5c2fa75780f98bf78b6fa7e02f1ccb2fdc49465cf2fe5", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json index f1e2c3f7986..59b7d27d8b1 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -3,8 +3,8 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "85595f5fdde16441d22bf07c14c65edda6cb90019d036aae2c5031d19664e66f", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json index f32bce00b7a..9c76e077bb1 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "a99bb80a5826af1df8d74114fcc5654aa42c9208747b512cea9bd5ca65b64ccb", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json index 219bbe04291..d00b0b880cb 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "3cda09e4a4ad4092f5f9a48b7c9715a99a51eb3bc4bed00f7054537a9e21cea9", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json index 8bfe1d0fbeb..adcbc451b5b 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "61097c13b262a4454510936fa9c9554a07865b610f18407f1b67bdd74476df08", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index ab7574ba726..ee1d7dcb693 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "0278216fee698c00118bb0e73a7fe755dc59c3b8e2b0459153edae64f155774c", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json index 1d3ee12c979..b7d5495acf4 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "6cb658dadc9e146c4f36c6ce643451e72300b8cdda19cc684ffa9fb2b0822c0a", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json index 7559096ef7d..274b8bda06b 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "16abe9e1a8d4cff17b3ea29d40277ae30a3d555a4a9efa08b2745c3a85b02740", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json index 965dfc32451..9ad5aa3bf77 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -3,8 +3,8 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "f469b2b7d61e7fc500fa97b5548f5a3732b0dbcdb405412a514a609f786dfbb5", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index b43dbf986db..0e978694548 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -3,8 +3,8 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "b3734c24f8a083d3efcdd995ea6a57e608d3d3ae3bbbd514db19dcf69448fa48", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json index 58679803407..75a646647fb 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -3,8 +3,8 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "451e74a430d2549976fa360a0e43e76a8d855b1470b8e313797019770ceca4cb", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index 6fc5d731e2f..c3f60a57aa0 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "81be14c5ec31045004465de4234d1f682f5946d6f8dac7a42263e285b1f24591", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json index 00ec2395088..aca8172e109 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "e9d85c576adf93063a8f56d49d28869c402cad38122732b46b8ec021d25db5e3", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json index 9cff92c1122..74955710747 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -3,8 +3,8 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "14509b4c1cc3beb00cc329b6bae46913f59b3938f76c0bd3bf3e374f34fb680d", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json index d3d9e67abe6..48be865c4bb 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -3,8 +3,8 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "0d2e3f48aadf45abbf6927b72ed5fef4caa8e3eb2efd1046339a3fbfab6f9f18", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json index d618c53bfec..46eabfbdf67 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -3,8 +3,8 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "f54f5c6dbbe7dbcf8e85e9bd36b27ca9bea7de65d5e35dabace49b3fc766a403", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json index c6d4c97de04..f3b003ce348 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "5d869d55848aea469954eb945ddc2b74f366387f8fc2853b7ae01de779208c4a", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json index a3013a38ca6..8e2e6cb6759 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "4537bf83f7a030521eec549adf4490da5be183471d5a9f9e58b71815b29481ff", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json index c31d03b21dc..a25d83ee183 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -3,8 +3,8 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", "scenarioSha256": "9681485eec93644a01dd6dff0d07f9629361a86989a900176c5aaff3d5805db3", diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json index 17b3b5d3d60..82427615c20 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -3,8 +3,8 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", "scenarioSha256": "31bfa49f888b0eb3f72873bf4a3af26129e78c23126e8fc8fe45b952caa60904", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json index 9b3e0ed37ba..04e33feb424 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "2cd1e8972f226572744dad7da82afffbdf0452a121c1cd8c3334d5c3fde5d57c", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index a0a1ea0d736..8e21063e68e 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "046dd3a125a3c9abcf5a0dd122818939b516adb91cbda2554b3409d4bb3a7980", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json index 1112ba2916e..5c1b3585a64 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "865a659012dd882fd6073813585e2911a1d6252404fbf5a5e273f062b89fc91d", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index 6c80428924e..c67c5b1a1f0 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "124f664e339bfd83a1d892e1cd953a78fdf0dc4b20c4272356d24079c72a3e04", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json index d5dfbd46d96..e3b6a317468 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "dd25391fdd3dc864ae493f72d013e789884a21e9c71522edc79323bc2b6c7f76", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index 8defc8c0387..f7500d9aa42 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "495f51d9c2f7f3d71f53a53e88786b8d1f767a5bf66b8655c28222d2909a964c", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index 5bf629b53ff..7a39b994431 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "af0623c2d106d2ed18ef9149d4990539f9ed82146ae091a872a3e1d792efeffe", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json index 9df4dca2456..08c4e096cc1 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -3,8 +3,8 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", "scenarioSha256": "3aa23f15da8fe9972e47c767db454b41750ca353ab10797082fde4514ffe9da0", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index ccec33c897b..060217fcd79 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -3,8 +3,8 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "e28e66567389598edbc720c89f5af73b5beca836cd0357af235d8ddc3d29fbfc", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index 457dbafc2bb..ce2bf364381 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -3,8 +3,8 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "d2947158840576cbd0f0604ed3d37b0f446c63c6d439b4d1b7def7fe8524523d", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index 4693a9969a3..d2c17d22217 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -3,8 +3,8 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "4749bb3b871275ba08f026f9b6bcfd383605f443e7bba70a7f89175b91db6fa5", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index 04471c374fa..5910667bde2 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -3,8 +3,8 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", - "lockfileSha256": "1327605e77b3fed83c7410635d39fdab40c49380e5cbf483ec6ff1b404b4e1d1", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", + "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "f67c2f5b753d2a4f572599f6861fae8e6e917fe89ac75b58288f8e84be609d86", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", "scenarioSha256": "2faa07ee5f12b3ed584117359d3b7aeb9c78a04e8fc49e753372f8c3927a3740", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 4c494bf5887..839ed37616d 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "c5c1477ad4daabde26c6f1746beb22b2fd4e5475", + "baseline": "02a55944347346065453e179e071e64d9ae861dc", "scenarios": [ { "id": "b1", From d06b43e634370ebde6e553fa5fa622977c40c8ee Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 21:01:32 -0700 Subject: [PATCH 037/387] fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page (#22279) * fix(tasks): read a malformed saved Linear team selection as sticky-all instead of crashing the page A persisted defaultLinearTeamSelection that is not a string array (a string reached 1.4.207, report 0a2b6e7f) threw '(t ?? []).filter is not a function' inside a commit-phase effect and tripped the page.tasks error boundary. The value is now normalized where the page reads it and where a host projects it to paired clients; anything but a string array means sticky-all. * fix(mobile): read a malformed saved Linear team selection as sticky-all A host that predates the desktop fix projects its raw store value, so the mobile Linear list must tolerate the same string shape. Also trims the desktop helper's comments to the why. * fix(tasks): validate projected Linear team IDs and refresh parity contract --------- Co-authored-by: m4air Co-authored-by: m4air --- .../mobile-tasks-refactor-parity.test.ts | 3 +- .../mobile-tasks-reviewer-linear.test.ts | 26 ++++++++++ .../src/tasks/mobile-tasks-reviewer-linear.ts | 9 ++-- ...nt-settings-linear-team-projection.test.ts | 51 +++++++++++++++++++ src/main/runtime/runtime-client-settings.ts | 5 +- .../task-page-linear-team-selection.test.ts | 40 ++++++++++++++- .../task-page-linear-team-selection.ts | 16 +++++- .../use-task-page-linear-list-selection.ts | 13 ++++- 8 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts create mode 100644 src/main/runtime/runtime-client-settings-linear-team-projection.test.ts diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 859fb8d1459..3ba14c571d6 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -110,7 +110,8 @@ const hash = (parts: string[] | string): string => const SCREEN_RPC_SCREEN_HOOKS = '0f66df2141117dfec2f8a0adb3f598312e6fda8e80833a365a645796f5ab48c3' const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' const SCREEN_RPC_STATEMENTS = 'dd8f33cb3cf96f5c39abac397cb77e35f59079291033a1866ead462b041ab979' -const MAIN_REBASED_DECLARATIONS = '920a1b66445d10e2a64fbdbe9d7138a4ebe21bbccde1b9ac9c89267cecc584b9' +// Saved Linear selections now accept unknown persisted values; reconciliation tests cover them. +const MAIN_REBASED_DECLARATIONS = 'ec77d34712c7c4ab19ac6a4d57878f32d22aba790d2420cc14c2c0f000d120e9' const SCREEN_RPC_SEMANTICS = 'e07a63387d57106483ee703ec6c19dea593e0eca5c651758f42bcb36254850b7' const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' const SCREEN_RPC_RENDER_TREE = '086742f95f1e87fb89d8c67ffd9f7a229799ae05115f9f4bcc1a925e56dcc8bb' diff --git a/mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts b/mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts new file mode 100644 index 00000000000..3fec8b222ae --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-reviewer-linear.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./mobile-tasks-dependencies', () => import('../theme/mobile-theme')) +import { reconcileTeamSelection } from './mobile-tasks-reviewer-linear' +import type { LinearTeam } from './mobile-tasks-provider-detail-types' + +function team(id: string): LinearTeam { + return { id, name: id, key: id.toUpperCase() } +} + +describe('reconcileTeamSelection', () => { + it('selects every team for sticky-all', () => { + expect([...reconcileTeamSelection([team('a'), team('b')], null)]).toEqual(['a', 'b']) + }) + + it('keeps saved teams that still exist', () => { + expect([...reconcileTeamSelection([team('a'), team('b')], ['b'])]).toEqual(['b']) + }) + + // Why: an older host projects its raw store value, which reached 1.4.207 + // desktops as a string (0a2b6e7f); the mobile list must not fail on it. + it('reads a saved value of the wrong shape as sticky-all instead of throwing', () => { + expect([...reconcileTeamSelection([team('a'), team('b')], 'a')]).toEqual(['a', 'b']) + expect([...reconcileTeamSelection([team('a'), team('b')], { 0: 'a' })]).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/tasks/mobile-tasks-reviewer-linear.ts b/mobile/src/tasks/mobile-tasks-reviewer-linear.ts index ed5e51f8bf7..32b872550aa 100644 --- a/mobile/src/tasks/mobile-tasks-reviewer-linear.ts +++ b/mobile/src/tasks/mobile-tasks-reviewer-linear.ts @@ -196,11 +196,10 @@ export function linearIssueSecondaryParts( return parts } -export function reconcileTeamSelection( - teams: LinearTeam[], - saved: string[] | null | undefined -): Set { - if (!saved) { +// Why `unknown`: a host predating the desktop fix for 0a2b6e7f projects its +// raw store value, which can be a string; that must read as sticky-all. +export function reconcileTeamSelection(teams: LinearTeam[], saved: unknown): Set { + if (!Array.isArray(saved)) { return new Set(teams.map((team) => team.id)) } const available = new Set(teams.map((team) => team.id)) diff --git a/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts b/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts new file mode 100644 index 00000000000..c26eecb3d95 --- /dev/null +++ b/src/main/runtime/runtime-client-settings-linear-team-projection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientSettingsController } from './runtime-client-settings' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Why: the projection is what paired clients render page.tasks from, and a +// non-array in the host store crashed that page in 1.4.207 (0a2b6e7f). +function projectionOf(settings: Partial) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: get() reads nothing but store.getSettings(); every other RuntimeStore member is unreachable from that path. + return new RuntimeClientSettingsController({ getSettings: () => settings } as never).get() +} + +function hostSettings(overrides: Record): Partial { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the override deliberately carries the malformed on-disk shape the projection must tolerate. + return { + ...createGlobalSettingsFixture({ workspaceDir: '/w' }), + ...overrides + } as Partial +} + +describe('RuntimeClientSettingsController Linear team selection projection', () => { + it('publishes a saved team array unchanged', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: ['t1', 't2'] })) + .defaultLinearTeamSelection + ).toEqual(['t1', 't2']) + }) + + it('publishes sticky-all as null', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: null })).defaultLinearTeamSelection + ).toBeNull() + }) + + it('publishes only string team IDs from a malformed array', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: ['t1', 7, null, {}, 't2'] })) + .defaultLinearTeamSelection + ).toEqual(['t1', 't2']) + }) + + it('publishes null when the host store holds a string or an object', () => { + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: 't1' })).defaultLinearTeamSelection + ).toBeNull() + expect( + projectionOf(hostSettings({ defaultLinearTeamSelection: { 0: 't1' } })) + .defaultLinearTeamSelection + ).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 262021da0e5..76c7e772e5c 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -107,7 +107,10 @@ export class RuntimeClientSettingsController { defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], defaultRepoSelection: settings.defaultRepoSelection ?? null, - defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, + // Persisted settings can violate the paired client's string-array contract. + defaultLinearTeamSelection: Array.isArray(settings.defaultLinearTeamSelection) + ? settings.defaultLinearTeamSelection.filter((id): id is string => typeof id === 'string') + : null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, // The three that decide whether a new agent tab -- and so an orchestration worker -- is a diff --git a/src/renderer/src/components/task-page-linear-team-selection.test.ts b/src/renderer/src/components/task-page-linear-team-selection.test.ts index 6eb1878fbbd..b75b6404080 100644 --- a/src/renderer/src/components/task-page-linear-team-selection.test.ts +++ b/src/renderer/src/components/task-page-linear-team-selection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { LinearTeam } from '../../../shared/linear/workspace-types' -import { reconcileLinearTeamSelection } from './task-page-linear-team-selection' +import { + reconcileLinearTeamSelection, + storedLinearTeamSelection +} from './task-page-linear-team-selection' function team(id: string): LinearTeam { return { @@ -34,4 +37,39 @@ describe('reconcileLinearTeamSelection', () => { 'd' ]) }) + + // Why: 0a2b6e7f (1.4.207) crashed page.tasks with "(t ?? []).filter is not a + // function": the persisted setting reached the renderer as a string, which + // `new Set(value)` in the hook initializer accepts and this call did not. + it('treats a saved selection of the wrong shape as sticky-all', () => { + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], 'a'))).toEqual([ + 'a', + 'b' + ]) + expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], { 0: 'a' }))).toEqual([ + 'a', + 'b' + ]) + }) +}) + +describe('storedLinearTeamSelection', () => { + it('keeps a string array', () => { + expect(storedLinearTeamSelection(['a', 'b'])).toEqual(['a', 'b']) + }) + + it('reads null and undefined as sticky-all', () => { + expect(storedLinearTeamSelection(null)).toBeNull() + expect(storedLinearTeamSelection(undefined)).toBeNull() + }) + + it('reads a string, an object or a number as sticky-all instead of throwing', () => { + expect(storedLinearTeamSelection('a')).toBeNull() + expect(storedLinearTeamSelection({ 0: 'a' })).toBeNull() + expect(storedLinearTeamSelection(7)).toBeNull() + }) + + it('drops non-string entries from a mixed array', () => { + expect(storedLinearTeamSelection(['a', 1, null, 'b'])).toEqual(['a', 'b']) + }) }) diff --git a/src/renderer/src/components/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page-linear-team-selection.ts index 3ec82284a59..9cebac902b7 100644 --- a/src/renderer/src/components/task-page-linear-team-selection.ts +++ b/src/renderer/src/components/task-page-linear-team-selection.ts @@ -1,8 +1,18 @@ import type { LinearTeam } from '../../../shared/linear/workspace-types' +// Why: the persisted value is not validated on the way in and a string reached +// 1.4.207 (0a2b6e7f); anything but a string array reads as sticky-all. +export function storedLinearTeamSelection(value: unknown): string[] | null { + if (!Array.isArray(value)) { + return null + } + return value.filter((id): id is string => typeof id === 'string') +} + +// Why `unknown`: this is the crash site, so it must hold for the raw setting too. export function reconcileLinearTeamSelection( availableTeams: LinearTeam[], - storedSelection: readonly string[] | null | undefined + storedSelection: unknown ): ReadonlySet { const availableIds = availableTeams.map((team) => team.id) if (availableIds.length === 0) { @@ -10,7 +20,9 @@ export function reconcileLinearTeamSelection( } const availableIdSet = new Set(availableIds) - const validStoredSelection = (storedSelection ?? []).filter((id) => availableIdSet.has(id)) + const validStoredSelection = (storedLinearTeamSelection(storedSelection) ?? []).filter((id) => + availableIdSet.has(id) + ) if (validStoredSelection.length > 0) { return new Set(validStoredSelection) } diff --git a/src/renderer/src/components/use-task-page-linear-list-selection.ts b/src/renderer/src/components/use-task-page-linear-list-selection.ts index 8c8de172f9a..ae2e03f0603 100644 --- a/src/renderer/src/components/use-task-page-linear-list-selection.ts +++ b/src/renderer/src/components/use-task-page-linear-list-selection.ts @@ -7,7 +7,10 @@ import { buildLinearTeamUrl, getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear/links' -import { reconcileLinearTeamSelection } from '@/components/task-page-linear-team-selection' +import { + reconcileLinearTeamSelection, + storedLinearTeamSelection +} from '@/components/task-page-linear-team-selection' import { useTaskPageLinearFilterSelection } from './use-task-page-linear-filter-selection' export type TaskPageLinearListSelectionPreludeModel = ReturnType< typeof useTaskPageLinearListSelectionPrelude @@ -42,7 +45,13 @@ export function useTaskPageLinearListSelectionPrelude(model: TaskPageGitLabLoadi linearCustomViewContentsError, availableTeams } = model - const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection + // Why memoized: the normalized array feeds an effect that sets state, and a + // fresh array per render would re-run it every render. + const rawLinearTeamSelection = settings?.defaultLinearTeamSelection + const defaultLinearTeamSelection = useMemo( + () => storedLinearTeamSelection(rawLinearTeamSelection), + [rawLinearTeamSelection] + ) const [linearTeamSelection, setLinearTeamSelection] = useState>(() => { if (!defaultLinearTeamSelection) { return new Set() From 067975bfd1c54f755bca8e81a43a3d611322a7da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:04:31 -0700 Subject: [PATCH 038/387] fix(native-chat): every lease latch has a way to die (#22820) * fix(native-chat): every lease latch has a way to die A failed exit settlement no longer leaves the lease in recovery: the release writes no stage and keeps the exit in its death evidence, and whatever the dead generation left running is settled from that evidence at the next acquire or read restore. The settlement retry flag, its disposition and every branch that read it are gone. A reservation that recorded no process is released at startup and after a failed start, the never-written conflicted status and the processless proof are deleted, recovery resolution always concludes, and Codex records its child's identity at spawn, before the handshake. * test(native-chat): a re-create needs a release proven by death evidence * test(codex): the child's pid is reported before the handshake * test(native-chat): type the crash and exit fixtures without casts * fix(native-chat): wait out a terminal owner an older build recorded, in recovery rather than manual recovery * test(native-chat): a chat mid-turn at quit reopens idle, and an older build reads an unproven release * test(native-chat): explain the baseline store cast * fix(native-chat): a terminal owner's refusal names the process instead of recursing Opening a chat whose terminal owner an older build recorded threw a stack overflow instead of the refusal that names the process to quit. * fix(native-chat): wait out a terminal owner recovery cannot verify instead of releasing it A terminal agent an older build recorded keeps its PTY across an Orca restart, so a probe that cannot answer (a start-time read that fails on a loaded host) is not evidence its transport is gone. Releasing it let a native child resume the same conversation beside the live terminal agent. Only proof of its exit now ends the claim. * ci(cross-version): run the unproven-release downgrade test The sharded unit job excludes tests/e2e/cross-version-wire, and the cross-version job runs an explicit list that did not name the new test, so it never ran in CI. A change to the record validator now also starts the job. * refactor(native-chat): map the retired manual-recovery stage to recovering at decode Nothing in this build writes manual-recovery, and restart reconciliation already rewrites it. Mapping it where the other retired handoff stages are mapped removes it from the in-memory lease type and deletes the branches that could only see it: the acquisition refusal, the renewer skip, the unproven-release stage check, and the handoff-status 'manual recovery is required' answer. Older builds accept recovering, so a record written back still loads after a downgrade. * docs(native-chat): say what happens to a live child an ownerless reservation leaves The reaper runs once at store open, while the unreconciled lease still claims the child's token, so it does not stop that child on this launch. The comment claimed it did. * test(native-chat): name the each-case label for its role * fix(native-chat): continue a create retried after recovery released its reservation The client retries a create it never heard back from under the same operation id. Recovery had released that create's reservation, so the retry was refused agent_session_ownership_unknown while its row was pending, and agent_session_operation_expired once the row aged out, and the chat never started. A retry whose lease nothing holds now continues as a fresh reservation at the next fence, which also stops the old reservation's spawn from committing. * test(native-chat): name the refusal a replayed create used to get * fix(native-chat): one quit-the-terminal-agent message for a chat a terminal agent holds A chat held by a terminal agent an older build recorded frees only when that agent exits. Sending said to reopen the chat and opening it said two runtimes claimed it; both now say the chat is open in a terminal agent, name its process, and say to quit it. Error codes are unchanged. * ci: run PR checks on the rebased head * fix(native-chat): name a terminal owner's process only when its start time can tell it from a reused pid * test(native-chat): relaunch from the dying host's durable state, so its still-pending attach cannot race the new host --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 1 + .../claude-context-usage-restart.test.ts | 12 +- .../claude/claude-structured-session-state.ts | 1 - .../codex-app-server-connection-types.ts | 2 + .../codex/codex-app-server-connection.test.ts | 39 +++ src/main/codex/codex-app-server-connection.ts | 9 +- .../codex/codex-structured-owner-identity.ts | 26 ++ .../codex/codex-structured-session-acquire.ts | 12 +- .../codex-structured-session-close.test.ts | 5 +- .../codex/codex-structured-session-close.ts | 16 +- ...-agent-session-acquisition-options.test.ts | 23 +- .../structured-agent-session-acquisition.ts | 18 +- .../structured-agent-session-adapter.ts | 7 +- ...ured-agent-session-attach-orchestration.ts | 28 +-- ...ured-agent-session-crash-mid-start.test.ts | 235 ++++++++++++++++++ ...gent-session-dead-generation-settlement.ts | 51 ++++ ...ession-failed-create-owner-verdict.test.ts | 6 +- .../structured-agent-session-hold-resume.ts | 9 +- .../structured-agent-session-host.test.ts | 32 ++- .../structured-agent-session-host.ts | 2 +- .../structured-agent-session-lease-release.ts | 4 +- .../structured-agent-session-lease-renewer.ts | 3 +- ...gent-session-legacy-handoff-record.test.ts | 24 +- .../structured-agent-session-owner-status.ts | 13 - ...nt-session-processless-reservation.test.ts | 7 - ...ed-agent-session-readable-restorer.test.ts | 2 +- ...uctured-agent-session-readable-restorer.ts | 6 +- ...agent-session-recovered-turn-clock.test.ts | 13 +- ...tured-agent-session-recovery-exits.test.ts | 20 +- ...-agent-session-recovery-resolution.test.ts | 119 +++------ ...tured-agent-session-recovery-resolution.ts | 77 +++--- ...tructured-agent-session-refusal-message.ts | 11 +- ...ured-agent-session-restart-restore.test.ts | 26 +- ...tructured-agent-session-restart-restore.ts | 13 +- ...ctured-agent-session-resume-eligibility.ts | 7 +- .../structured-agent-session-reveal.test.ts | 2 +- .../structured-agent-session-reveal.ts | 22 +- ...red-agent-session-send-preparation.test.ts | 2 +- ...agent-session-settled-attach-retry.test.ts | 50 ++-- ...red-agent-session-settlement-retry.test.ts | 190 -------------- ...ructured-agent-session-settlement-retry.ts | 130 ---------- ...d-agent-session-stale-turn-verdict.test.ts | 27 +- ...ctured-agent-session-stale-turn-verdict.ts | 50 +--- ...red-agent-session-surface-lifetime.test.ts | 57 +++-- ...ured-agent-session-unexpected-exit.test.ts | 54 ++-- ...tructured-agent-session-unexpected-exit.ts | 12 +- ...t-session-wedged-profile-migration.test.ts | 231 +++++++++++++---- .../agent-session-instrumentation.ts | 1 - .../observability/instrumentation.test.ts | 3 +- ...-session-acquisition-failure-settlement.ts | 18 +- ...-session-eviction-settlement-latch.test.ts | 141 ----------- .../agent-session-lease-transitions.ts | 52 ++-- .../agent-session-process-identity-probe.ts | 4 +- .../agent-session-processless-reservation.ts | 44 ---- ...-record-store-legacy-handoff-lease.test.ts | 2 +- .../agent-session-record-store.test.ts | 57 +++-- .../runtime/agent-session-record-store.ts | 25 +- ...ession-released-reservation-replay.test.ts | 171 +++++++++++++ ...gent-session-reservation-admission.test.ts | 9 +- .../agent-session-reservation-admission.ts | 46 +++- ...agent-session-restart-lease-transitions.ts | 29 +-- .../agent-session-store-transaction-queue.ts | 19 +- ...gent-session-surface-release-transition.ts | 12 +- ...ude-structured-session-integration.test.ts | 19 ++ .../structured-agent-session-owner-probe.ts | 6 +- .../structured-agent-session-runtime.test.ts | 11 - .../structured-claude-runtime-adapter.ts | 3 - .../agent-session-lease-adjudication.test.ts | 72 +++--- .../agent-session-lease-adjudication.ts | 83 +++---- ...agent-session-legacy-handoff-lease.test.ts | 31 ++- .../agent-session-legacy-handoff-lease.ts | 37 +-- src/shared/agent-session-mutation-envelope.ts | 9 +- src/shared/agent-session-operation-ledger.ts | 32 ++- src/shared/agent-session-record.ts | 27 +- ...on-unproven-release-downgrade.unit.test.ts | 107 ++++++++ 76 files changed, 1494 insertions(+), 1283 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts delete mode 100644 src/main/runtime/agent-session-eviction-settlement-latch.test.ts delete mode 100644 src/main/runtime/agent-session-processless-reservation.ts create mode 100644 src/main/runtime/agent-session-released-reservation-replay.test.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1b52aac7571..6ac6de78ed3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -757,6 +757,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index babe8e8c7e0..f984ff56590 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -142,6 +142,7 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/rpc-contract/agent-launch-params', 'src/shared/agent-session-wire', 'src/shared/agent-session-mutation-envelope', + 'src/shared/agent-session-record', 'src/shared/agent-session-journal-', 'src/main/ai-vault/structured-session-ownership.ts', 'src/main/native-chat/agent-session-journal/', diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index 58f883ab06d..cb0dff927c5 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -6,7 +6,7 @@ import { selectStructuredAgentContextUsage } from '../../shared/structured-agent import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { settleStaleSessionStateOnAcquire } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import { settleStaleStructuredAgentSessionState } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { bindClaudeContextUsageCapture } from './claude-context-usage' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -212,11 +212,12 @@ describe('context usage across a restart', () => { // The child dies with turn-b running; nothing ends it from the provider side. crashed.release() - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal, sessionId: 'orca-session', fence: 2, - acquisitionGeneration: 'next' + acquisitionGeneration: 'next', + deathEvidence: null }) const turns = journal.snapshot().items.map((item) => readAgentJournalTurn(item.body)?.state) expect(turns).toContain('unverifiable') @@ -233,11 +234,12 @@ describe('context usage across a restart', () => { // The write is queued while the host settles the turn behind the sink's back. live.detach() live.translator.handle(assistantFrame('reply-a2', 3_000, 30_000)) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal, sessionId: 'orca-session', fence: 1, - acquisitionGeneration: 'next' + acquisitionGeneration: 'next', + deathEvidence: null }) live.reattach() await live.settle() diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 37cc762c6f5..60055874b37 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -70,7 +70,6 @@ export type ClaudeStructuredSessionEvent = cause?: 'unexpected-exit' | 'requested-close' fence?: number acquisitionGeneration?: string - settlementRetryRequired?: boolean /** Host clock when the end was observed. */ observedAt?: number /** The child ended before proving startup, so reacquiring would repeat the same start. */ diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index 5c3c2f425a2..df3f40ac39a 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -9,6 +9,8 @@ export type CodexAppServerConnectionHandlers = { onServerRequest?: (request: CodexAppServerServerRequest) => void onUnhandledFrame?: (kind: string, payload: unknown) => void onExit?: (error: Error) => void + /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ + onSpawned?: (pid: number) => Promise } export type CodexAppServerConnection = { diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index d67f2ebecc6..aa5a4474235 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -182,6 +182,45 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) + it('reports the spawned pid before it sends the handshake', async () => { + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + const writtenAtSpawn: number[] = [] + + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onSpawned: async (pid) => { + writtenAtSpawn.push(written.length) + expect(pid).toBe(child.pid) + } + }, + spawnImpl + ) + + // The owner is durable before initialize, so a crash mid-handshake leaves it stoppable. + expect(writtenAtSpawn).toEqual([0]) + expect(written[0]).toMatchObject({ method: 'initialize' }) + await connection.close() + }) + + it('reaps the child and never handshakes when its spawn cannot be recorded', async () => { + const { spawnImpl, written } = stubChild() + + await expect( + openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onSpawned: async () => { + throw new Error('agent_session_checkpoint_stale') + } + }, + spawnImpl + ) + ).rejects.toThrow('agent_session_checkpoint_stale') + expect(written).toEqual([]) + }) + it('completes the handshake and keeps the child alive across calls', async () => { const notifications: { method: string; params: unknown }[] = [] const connection = await openFakeServer({ diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 4bfd9a15169..c0ac7f07400 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -280,13 +280,20 @@ export async function openCodexAppServerConnection( close } + let handshaking = false try { + // A spawn that failed has no pid; the handshake below reports why. + if (child.pid !== undefined) { + await handlers.onSpawned?.(child.pid) + } + handshaking = true await initializeCodexAppServerConnection(connection) } catch (error) { if ((await close()) !== true) { throw new CodexAppServerHandshakeExitUnprovenError(connection, error) } - throw error instanceof CodexAppServerUnsupportedError || + throw !handshaking || + error instanceof CodexAppServerUnsupportedError || error instanceof CodexAppServerTimeoutError ? error : buildExitError(error instanceof Error ? error : new Error(String(error))) diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts index 44f25ea4724..805a767e620 100644 --- a/src/main/codex/codex-structured-owner-identity.ts +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -17,6 +17,32 @@ export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' const START_TIME_READ_ATTEMPTS = 3 +/** + * The child's identity, read once. The real connection reports its spawn before the handshake, and + * `onSpawned` makes it durable there, so a crash mid-start leaves an owner recovery can stop; a + * connection that reports no spawn is identified once it is open. + */ +export function codexSpawnedProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + onSpawned?: (process: AgentSessionProcessIdentity) => Promise + }, + readStartTime?: (pid: number) => Promise +): { + onSpawned: (pid: number) => Promise + read: (pid: number | undefined) => Promise +} { + let spawned: Promise | undefined + return { + onSpawned: async (pid) => { + spawned = codexProcessIdentity({ ...input, pid }, readStartTime) + await input.onSpawned?.(await spawned) + }, + read: (pid) => spawned ?? codexProcessIdentity({ ...input, pid }, readStartTime) + } +} + export async function codexProcessIdentity( input: { identity: AgentSessionJournalIdentity diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 2ce13d21250..ae92bd48f54 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -13,7 +13,10 @@ import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' -import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { + codexProviderHandleLink, + codexSpawnedProcessIdentity +} from './codex-structured-owner-identity' import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' import { openCodexThread } from './codex-structured-thread-open' import { @@ -106,6 +109,7 @@ export async function acquireCodexStructuredSession(input: { }) : null const open = deps.openConnection ?? openCodexAppServerConnection + const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) try { await stopSupersededCodexAcquisition({ sessionId, @@ -165,6 +169,7 @@ export async function acquireCodexStructuredSession(input: { () => input.handleUnhandledFrame(sessionId, kind, payload), Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), + onSpawned: spawnIdentity.onSpawned, onExit: (error) => { try { handleCodexSessionExit({ @@ -202,10 +207,7 @@ export async function acquireCodexStructuredSession(input: { 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' ) } - const process = await codexProcessIdentity( - { ...acquireInput, pid: connection.pid }, - deps.readProcessStartTime - ) + const process = await spawnIdentity.read(connection.pid) acquisitions.assertCurrent(sessionId, attempt) const acquired: AgentSessionAcquisition = { process, diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 17f3aecf671..dffa7157dd6 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -125,10 +125,7 @@ describe('Codex structured session close lifecycle', () => { expect(clearPrompts).toHaveBeenCalledOnce() expect(onEvent).toHaveBeenCalledOnce() expect(translator.dispose).toHaveBeenCalledOnce() - expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ - cause: 'unexpected-exit', - settlementRetryRequired: true - }) + expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ cause: 'unexpected-exit' }) expect(translator.handle).toHaveBeenCalledOnce() }) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 1ed8dcd385c..0a1de244dca 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -34,17 +34,13 @@ export function handleCodexSessionExit(input: { acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt } as const - // A synchronous sink rejection (usually backpressure) is handed to host - // recovery, which appends the bounded fallback before reacquisition. + // A synchronous sink rejection (usually backpressure) leaves the terminal rows to the host's + // exit settlement, which writes its own bounded fallback. const admission = session.translator?.handle(event) ?? { accepted: true } - if (!admission.accepted) { - // The connection invokes onExit exactly once. Forward a flagged event so - // host recovery can append its no-new-blob fallback even when admission is - // backpressured; waiting for a second callback would strand the lease. - if (event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) { - return false - } - event.settlementRetryRequired = true + // The connection invokes onExit exactly once, so an unexpected exit is forwarded even when + // admission is backpressured; waiting for a second callback would strand the lease. + if (!admission.accepted && event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) { + return false } session.ended = true // Nothing can echo for this child any more; the journal's pending-submission diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 3172a04597e..e3da044d43c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -107,7 +107,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { expect(record).not.toBeNull() const lease = record!.lease const durableState = lease.handoffStage ?? lease.claimStatus - expect(['live', 'released', 'recovering', 'manual-recovery']).toContain(durableState) + expect(['live', 'released', 'recovering']).toContain(durableState) expect(lease.handoffStage).not.toBe('new-owner-proving') } @@ -553,14 +553,12 @@ describe('structured session acquisition options', () => { }) await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) expectSettledAttachLease(reopened.getRecord(SESSION)) - } else { + } else if (failurePoint === 'proof' || failurePoint === 'journal') { + // A recorded owner goes to recovery, which concludes about it before the next start. expect(failedRecord?.lease).toMatchObject({ runtimeFence: 1, claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', - handoffStage: - failurePoint === 'proof' || failurePoint === 'journal' - ? 'recovering' - : 'manual-recovery', + handoffStage: 'recovering', // The settled operation must not stay named by the lease as an in-flight transfer. handoffOperationId: null, reservedSpawnToken: 'spawn-a' @@ -569,6 +567,19 @@ describe('structured session acquisition options', () => { ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + } else { + // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, + // with no death evidence, since nothing proved one. + expect(failedRecord?.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: null + }) + await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 7968176a5a9..ac2f1823e5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -26,16 +26,8 @@ export async function acquireOwner( if (!spawnToken) { throw new Error('agent_session_ownership_unknown') } - // Pre-spawn proof is single-use: this retry may create a child after the durable clear. try { try { - record = await input.store.setReservationProcesslessProof({ - sessionId: record.sessionId, - fence, - spawnToken, - processlessAt: null, - now: input.now() - }) await input.onAcquiring?.() } catch (error) { throw new AgentSessionPreSpawnError(error) @@ -47,7 +39,15 @@ export async function acquireOwner( spawnToken, ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}) + ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), + onSpawned: async (process) => { + record = await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process, + now: input.now() + }) + } }) const providerChildPhase = acquired.providerChildPhase ?? 'ready' // A starting child has proven nothing: the record keeps the reservation's saved options as diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index c99b16d38a8..9174afc7fd2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -136,10 +136,8 @@ export type StructuredAgentSessionEndedEvent = { cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string - /** Host receipt of the child exit, retained across settlement retries. */ + /** Host receipt of the child exit: the end time of a turn it interrupted. */ observedAt?: number - /** Translator could not admit terminal rows; host recovery must append its bounded fallback. */ - settlementRetryRequired?: boolean /** The provider ended before it finished starting, so resuming it would repeat the failure. */ startupUnproven?: true } @@ -174,6 +172,9 @@ export type StructuredAgentSessionAcquireInput = { /** Provider events may begin before acquisition returns. */ events?: StructuredAgentSessionEventSink recordPhase?: AgentSessionCreatePhaseRecorder + /** Durably records the child's identity the moment it exists, before any handshake, so a crash + * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ + onSpawned?: (process: AgentSessionProcessIdentity) => Promise } export type StructuredAgentSessionSetOptionInput = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index f39a183b0f0..bf62c3fffce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -21,8 +21,7 @@ import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -110,23 +109,6 @@ async function runAttach( await withAgentSessionCreatePhase('resolve_recovery', recordPhase, () => context.runtimeState.resolveRecovery(sessionId) ) - // Retries a durable provider-exit journal settlement before a new owner is reserved. Answers - // settled when the record has none pending, so every attach can ask unconditionally. - const settled = await withAgentSessionCreatePhase('settlement_retry', recordPhase, () => - retryPendingStructuredAgentSessionSettlement({ - deps: context.deps, - sessions: context.sessions, - sessionId, - params, - now: () => context.now() - }) - ) - if (!settled) { - return refuseAgentSessionMutation({ - code: 'agent_session_ownership_unknown', - message: 'The provider-exit terminal journal settlement is still pending; retry attach.' - }) - } const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => context.runtimeState.probeOwner(sessionId) ) @@ -134,6 +116,9 @@ async function runAttach( // attach makes it the session's; any other exit closes it with whatever the child queued. const attemptSink = context.runtimeState.mintEventSink(sessionId) let attemptSinkAdopted = false + // Read before the reserve clears it: how the previous generation ended decides how whatever it + // left running is settled. + const priorDeathEvidence = context.deps.store.getRecord(sessionId)?.lease.deathEvidence ?? null const attached = stampFailedCreateOwnerVerdict( context.deps.store, callerKey, @@ -182,11 +167,12 @@ async function runAttach( try { if (acquiredOwner) { // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal: attached.journal, sessionId, fence, - acquisitionGeneration + acquisitionGeneration, + deathEvidence: priorDeathEvidence }) } await bindAndDrain(eventSink, attached.journal, fence, (activity) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts new file mode 100644 index 00000000000..09a5aee05b7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -0,0 +1,235 @@ +// A host that dies while its Codex child is still starting leaves a lease behind. The child's +// identity is committed the moment it spawns, before the handshake, so the next host can stop that +// exact process and start over instead of guessing whether anything is running. + +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../shared/agent-session-host-authority' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { openCodexAppServerConnection } from '../../codex/codex-app-server-connection' +import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CHILD_PID = 4321 + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-crash-mid-start-')) + resetHostTestOperationIds() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +type HostGeneration = 'dying' | 'relaunched' + +/** Each generation's files. The relaunch opens a copy taken at the crash: the dying host stays in + * this process with its attach still pending, and a dead process writes nothing after it dies. */ +function generationRoot(generation: HostGeneration): string { + return join(root, generation) +} + +async function crash(dying: AgentSessionRecordStore): Promise { + // An empty renewal queues behind every write the dying host committed, so they are on disk. + await dying.renewLeases([]) + await cp(generationRoot('dying'), generationRoot('relaunched'), { + recursive: true, + // A write still in flight at the crash never landed, and a dead process holds no lock. + filter: (source) => !source.endsWith('.tmp') && !source.includes('.lock') + }) +} + +function openStore(generation: HostGeneration): Promise { + return AgentSessionRecordStore.open({ + directory: join(generationRoot(generation), 'store'), + hostId: 'local' + }) +} + +/** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then + * never answers the thread handshake: the host dies in that window. */ +function adapterThatNeverFinishesStarting(): StructuredAgentSessionAdapter { + const codex = fakeCodex({ + 'thread/start': () => new Promise(() => {}), + 'thread/resume': () => new Promise(() => {}) + }) + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { + const connection = await codex.openConnection(launch, handlers) + await handlers.onSpawned?.(CHILD_PID) + return connection + } + return Object.assign(adapterFor({ ...codex, openConnection }), { supportsCreate: () => true }) +} + +/** A Codex adapter whose child never gets as far as reporting its pid: the reservation is all the + * next host finds. */ +function adapterThatNeverSpawns(): StructuredAgentSessionAdapter { + const codex = fakeCodex() + const openConnection: typeof openCodexAppServerConnection = () => new Promise(() => {}) + return Object.assign(adapterFor({ ...codex, openConnection }), { supportsCreate: () => true }) +} + +function host( + generation: HostGeneration, + store: AgentSessionRecordStore, + adapter: StructuredAgentSessionAdapter, + overrides: Partial = {} +): StructuredAgentSessionHost { + return new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: generationRoot(generation), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW, + ...overrides + }) +} + +describe('a host that dies while its Codex child is starting', () => { + it('leaves the spawned child recorded, so the next host stops it by identity and starts over', async () => { + const first = await openStore('dying') + const dying = host('dying', first, adapterThatNeverFinishesStarting()) + void dying.attach(CALLER, hostTestAttachParams(null)).catch(() => {}) + await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease.ownerProcess).toBeTruthy()) + // Durable before the handshake returned: the only record the next host will have. + expect(first.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: { pid: CHILD_PID, spawnToken: 'spawn-a' } + }) + + // The relaunch. The orphan is still alive until something stops it. + let orphanAlive = true + const stopOwnerProcess = vi.fn(() => { + orphanAlive = false + }) + const restarted = fakeCodex() + await crash(first) + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => + orphanAlive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + } + ) + await relaunched.restoreReadableSessions() + + expect(stopOwnerProcess).toHaveBeenCalledWith(CHILD_PID, 'SIGTERM') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + deathEvidence: { kind: 'pid-absent' } + }) + await relaunched.hold(SESSION, 'desktop-chat:1') + expect(restarted.connections).toHaveLength(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) +}) + +// The client keeps a create it never heard back from and retries it under the same operation id, so +// that replay, not a fresh hold, is what the user's Retry and first send go through. +describe('a create replayed after the host that ran it died', () => { + const PAST_OPERATION_EXPIRY = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 + + it.each([ + ['its child was recorded', adapterThatNeverFinishesStarting, 0], + [ + 'its child was recorded, and its operation row has since expired', + adapterThatNeverFinishesStarting, + PAST_OPERATION_EXPIRY + ], + ['nothing was recorded beyond the reservation', adapterThatNeverSpawns, 0], + [ + 'nothing was recorded, and its operation row has since expired', + adapterThatNeverSpawns, + PAST_OPERATION_EXPIRY + ] + ] as const)('starts an agent when %s', async (_case, dyingAdapter, elapsedMs) => { + const params = hostTestAttachParams(null) + const first = await openStore('dying') + const dying = host('dying', first, dyingAdapter()) + void dying.attach(CALLER, params).catch(() => {}) + await vi.waitFor(() => + expect(first.getRecord(SESSION)?.lease).toMatchObject( + dyingAdapter === adapterThatNeverSpawns + ? { claimStatus: 'reserved' } + : { claimStatus: 'reserved', ownerProcess: { pid: CHILD_PID } } + ) + ) + + const restarted = fakeCodex() + await crash(first) + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { + mintSpawnToken: () => 'spawn-b', + // A reservation with no pid probes indeterminate: no token scan off Linux. + probeOwner: async (record): Promise => + record.lease.ownerProcess + ? { outcome: 'pid-absent' } + : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + now: () => NOW + elapsedMs + } + ) + await relaunched.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2 + }) + + const replayed = await relaunched.attach(CALLER, params) + // Before, `agent_session_ownership_unknown` while the row was pending, then `_operation_expired`. + expect(replayed.ok ? null : replayed.refusal.code).toBeNull() + expect(replayed).toMatchObject({ ok: true, value: { sessionId: SESSION, fence: 3 } }) + expect(restarted.connections).toHaveLength(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + runtimeFence: 3, + ownerProcess: { spawnToken: 'spawn-b' } + }) + expect( + store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome + ).toEqual({ status: 'succeeded', sessionId: SESSION }) + + // Settled now: the same id replays that answer and never starts a second agent. + await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(restarted.connections).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 1b5f73fdbda..05f7b4a7fe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -11,8 +11,10 @@ import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { runningTurnLifecycleRevisions, + turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' @@ -201,6 +203,55 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } } +/** + * Settles whatever a generation with no child in this process left running: found when a new child + * is acquired, or when a chat is reopened for reading. Derived from the journal and the lease's + * death evidence each time, so nothing is owed in between. Only an observed exit earns an end time + * and the exit copy. Must run before a new child's buffered events land, or a live turn would be + * judged. + */ +export async function settleStaleStructuredAgentSessionState(input: { + journal: AgentSessionJournal + sessionId: string + fence: number + acquisitionGeneration: string | null + deathEvidence: AgentSessionDeathEvidence | null +}): Promise { + const { journal } = input + const items = journal.snapshot().items + const verdict = turnVerdictFromDeathEvidence(input.deathEvidence) + const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` + const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const mutations: JournalLifecycleMutationInput[] = [] + for (const item of items) { + const identity = parseAgentJournalItemKey(item.itemId) + const body = terminalDeadGenerationBody(item) + if (identity && body) { + mutations.push({ kind: 'item', identity, body }) + } + } + mutations.push(...runningTurnLifecycleRevisions(items, verdict)) + if (verdict.state === 'interrupted' && items.some(isInProgressItem)) { + mutations.unshift({ + kind: 'item', + identity: { provider: 'orca', clientMessageId: settlementId }, + body: { + kind: 'status', + text: boundJournalStatusText(unexpectedProviderExitOutcome(input.deathEvidence?.detail)) + } + }) + } + for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { + await journal.appendLifecycleBatch({ + settlementId: chunk.settlementId, + fence: input.fence, + recovered: true, + mutations: chunk.mutations + }) + } + return mutations.length +} + function terminalDeadGenerationBody(item: AgentJournalRenderItem): AgentJournalItemBody | null { if (item.body.kind === 'tool-call' && item.body.state === 'running') { return { ...item.body, state: 'failed' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 3d064eed7e0..ee664ebc0d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -132,6 +132,10 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_ownership_unknown', ownerVerdict: 'unverifiable' } }) - expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') + // Released so the next start can go ahead, but with no death evidence: nothing proved it. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: null + }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts index 9ea59e18487..b29af8c06bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts @@ -13,6 +13,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { attachStructuredAgentSessionUnderSerialize, @@ -39,9 +40,9 @@ export async function resumeHeldStructuredAgentSession(input: { attachOptions?: StructuredAgentSessionAttachOptions }): Promise { const { sessionId, context, callerKey } = input - // The record is read only once this host has adjudicated it and exited any recovery stage a - // failed attempt latched — a lease left in `manual-recovery` by an unproven exit is one the - // resolver hands back, and the eligibility below must see it that way. + // The record is read only once this host has adjudicated it and recovery resolution has + // concluded about any owner a failed attempt left in `recovering`, so the eligibility below sees + // the lease the resolver handed back. const unreconciled = await context.reconcileLeases(sessionId) if (unreconciled) { return { ok: false, refusal: unreconciled } @@ -68,7 +69,7 @@ export async function resumeHeldStructuredAgentSession(input: { 'This host has not yet adjudicated the session lease.' ) : record.lease.claimStatus === 'conflicted' - ? refuse('agent_session_conflict', 'Another process claims this session.') + ? refuse('agent_session_conflict', terminalOwnerRefusalMessage(record.lease)) : refuse( 'agent_session_ownership_unknown', 'The session lease is not one this host may resume.' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 3c32989a4f9..ad2ac96da7a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -8,6 +8,7 @@ import { AgentSessionRecordStore } from '../../runtime/agent-session-record-stor import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { adapter, attach, @@ -548,7 +549,8 @@ describe('restart', () => { * whether a persisted session is reachable at all. */ async function reboot( probeOwner: (record: AgentSessionRecord) => Promise, - adapterOverrides: Partial = {} + adapterOverrides: Partial = {}, + stopOwnerProcess?: StructuredAgentSessionHostDeps['stopOwnerProcess'] ) { store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ @@ -558,6 +560,7 @@ describe('restart', () => { claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-b', probeOwner, + ...(stopOwnerProcess ? { stopOwnerProcess } : {}), now: () => NOW }) replaceHostTestState({ store, host }) @@ -616,7 +619,7 @@ describe('restart', () => { ...record.lease, // How a terminal owner an older build recorded loads. claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' } })) await reboot(async () => ({ outcome: 'pid-absent' })) @@ -669,27 +672,30 @@ describe('restart', () => { expect(status).toMatchObject({ owner: 'native' }) }) - it('vouches for no owner of a chat in manual recovery or one this host cannot run', async () => { + it('vouches for no owner of a chat this host cannot run', async () => { await attach() - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { ...record.lease, handoffStage: 'manual-recovery' } - })) - expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'none', phase: 'failed' }) await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false }) expect(() => host.handoffStatus(SESSION)).toThrow('structured_agent_session_unsupported') }) - it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { + it('releases a session whose owner can never be probed, signalling nothing, and starts over', async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) + const stopOwnerProcess = vi.fn() + await reboot( + async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' }), + {}, + stopOwnerProcess + ) + acquire.mockClear() - expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } + expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ + ok: true }) + expect(acquire).toHaveBeenCalledOnce() + // An unverifiable pid may already belong to an unrelated process. + expect(stopOwnerProcess).not.toHaveBeenCalled() }) it('does not remember a failed adjudication as done', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 47a6d509ea8..40bbc38238d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -101,7 +101,7 @@ export class StructuredAgentSessionHost { () => this.attachContext(), (sessionId) => this.close(sessionId) ) - this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { + this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts index bacc56fbcaf..fe584a50fce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -51,7 +51,7 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi acquisitionGeneration: string | null now: number exitObservedAt?: number - settlementRetry?: { settlementId: string; detail: string } + exitReason?: string }): Promise { if (input.acquisitionGeneration !== input.expectedAcquisitionGeneration) { throw new Error('agent_session_checkpoint_stale') @@ -69,6 +69,6 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi expectedFence: input.expectedFence, now: input.now, exitObservedAt: input.exitObservedAt, - ...(input.settlementRetry ? { settlementRetry: input.settlementRetry } : {}) + ...(input.exitReason ? { exitReason: input.exitReason } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index f1e5ca98305..96eb344fee9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -52,8 +52,7 @@ export class StructuredAgentSessionLeaseRenewer { record.lease.ownerProcess !== null && // A record parked in recovery has no transport the host can vouch for; renewing it // keeps an orphan pid's lease reading as a healthy owner. - record.lease.handoffStage !== 'recovering' && - record.lease.handoffStage !== 'manual-recovery' + record.lease.handoffStage !== 'recovering' ) const probes = await this.probe(records) const renewals: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 42ffe172cd7..5e0d7c83616 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -54,7 +54,13 @@ function openHost(): void { } /** Writes the lease an older build left behind, then starts a fresh app generation over it. */ -async function persistFromOlderBuild(lease: Partial): Promise { +/** Older builds also wrote the retired settlement latch fields. */ +type OlderBuildLease = Partial & { + settlementRetryRequired?: boolean + settlementRetryId?: string +} + +async function persistFromOlderBuild(lease: OlderBuildLease): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() @@ -134,9 +140,9 @@ describe('a record an older build left mid terminal handoff', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, - handoffOperationId: null, - settlementRetryRequired: undefined + handoffOperationId: null }) + expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryRequired') expect(await send('after the upgrade')).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -208,11 +214,19 @@ describe('a record an older build left mid terminal handoff', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' }) + // Sending and opening the chat both say what frees it: quitting that terminal agent. + const quitTerminal = + 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await send('while the terminal still runs')).toMatchObject({ ok: false, - refusal: { code: 'agent_session_conflict' } + refusal: { code: 'agent_session_conflict', message: quitTerminal } + }) + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict', message: quitTerminal } }) expect(stopOwnerProcess).not.toHaveBeenCalled() expect(acquire).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts index d29c2dacc51..b9db10c1579 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owner-status.ts @@ -18,19 +18,6 @@ export function structuredAgentSessionOwnerStatus( throw new Error('structured_agent_session_unsupported') } const { handoffStage: stage, handoffOperationId: operationId } = record.lease - if (stage === 'manual-recovery') { - return { - owner: 'none', - direction: 'to-native', - phase: 'failed', - stage, - operationId, - error: { - message: "Couldn't verify which runtime owns this session — manual recovery is required", - recoverableOwner: 'none' - } - } - } return { owner: 'native', direction: stage ? 'to-native' : null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 0f115da5ebd..e0fa13b3362 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -194,7 +194,6 @@ describe('processless structured session reservation', () => { claimStatus: 'released', handoffStage: null, reservedSpawnToken: null, - processlessAt: null, runtimeFence: 2, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) @@ -218,7 +217,6 @@ describe('processless structured session reservation', () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) }) } as unknown as StructuredAgentSessionAdapter - const processlessProof = vi.spyOn(store, 'setReservationProcesslessProof') const settlement = vi.spyOn(store, 'settleFailedAcquisition') await expect( @@ -241,16 +239,11 @@ describe('processless structured session reservation', () => { expect(settlement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) ) - // No separate durable proof write: the only proof call is acquisition's single-use clear. - expect(processlessProof).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ processlessAt: null }) - ) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, handoffOperationId: null, runtimeFence: 2, - processlessAt: null, reservedSpawnToken: null, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 5d579fbe256..a788e1ebb66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -27,7 +27,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined, - retrySettlement: async () => true + settleStaleState: async () => undefined }) await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index a11c3a85b6c..5775ecc5401 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -21,10 +21,10 @@ export class StructuredAgentSessionReadableRestorer { serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void - retrySettlement: ( + settleStaleState: ( sessionId: string, - params: RestoredStructuredAgentSessionRead['params'] - ) => Promise + restored: RestoredStructuredAgentSessionRead + ) => Promise } ) {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index a239e4a5154..72a908a3361 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -14,11 +14,11 @@ import type { import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { - settleStaleSessionStateOnAcquire, - type StructuredAgentSessionTurnVerdict -} from './structured-agent-session-stale-turn-verdict' + settleStaleStructuredAgentSessionState, + settleStructuredAgentSessionDeadGeneration +} from './structured-agent-session-dead-generation-settlement' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -157,11 +157,12 @@ describe('a turn recovery settled after its host went away', () => { it('is dated the same way when a new provider child finds the turn still running', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal: session.journal, sessionId: SESSION, fence: 2, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) session.publish() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 7cd92bf521b..ff2492c5876 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -127,29 +127,27 @@ afterEach(async () => { }) describe('recovery exits', () => { - it('keeps an ownerless unproven acquisition in manual recovery across restart', async () => { + it('releases an ownerless unproven acquisition, so the next start goes ahead', async () => { acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( 'agent_session_acquisition_exit_unproven' ) + // No owner was recorded, and the adapter closed the stdio of anything it spawned. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'manual-recovery', + claimStatus: 'released', + handoffStage: null, handoffOperationId: null, ownerProcess: null, - runtimeFence: 1, - reservedSpawnToken: 'spawn-a' + runtimeFence: 2, + reservedSpawnToken: null, + deathEvidence: null }) await reopenStore() openHost({ mintSpawnToken: () => 'spawn-b' }) - const refused = await host.attach(CALLER, hostTestAttachParams(1)) - expect(refused).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - expect(acquire).toHaveBeenCalledOnce() + expect(await host.attach(CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) }) it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index b647ed01883..7fbd785894d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -90,10 +90,10 @@ async function liveOwner(store: AgentSessionRecordStore) { }) } -async function latch(store: AgentSessionRecordStore, stage: 'recovering' | 'manual-recovery') { +async function latch(store: AgentSessionRecordStore) { return store.transitionHandoff(SESSION, (record) => ({ ...record, - lease: { ...record.lease, handoffStage: stage } + lease: { ...record.lease, handoffStage: 'recovering' } })) } @@ -117,29 +117,30 @@ function deps( } describe('structured session recovery resolution', () => { - it('does not release an ownerless native reservation without processless proof', async () => { + it('releases an ownerless reservation: nothing it recorded can be holding it', async () => { const store = await openStore() await reserve(store) - await latch(store, 'recovering') + await latch(store) const result = await resolveStructuredSessionRecovery( deps(store, () => ({ outcome: 'indeterminate', reason: 'no scan' })), SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'recovering', - runtimeFence: 1, - reservedSpawnToken: 'spawn-recovery' + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + reservedSpawnToken: null, + deathEvidence: null }) }) it('evicts a latched owner the probe now proves dead, without a stop request', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'manual-recovery') + await latch(store) const stopOwnerProcess = vi.fn() const result = await resolveStructuredSessionRecovery( @@ -160,7 +161,7 @@ describe('structured session recovery resolution', () => { it('stops a live identity-matched orphan and evicts only after absence is proven', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'recovering') + await latch(store) let alive = true const stopOwnerProcess = vi.fn(() => { alive = false @@ -188,10 +189,10 @@ describe('structured session recovery resolution', () => { }) }) - it('escalates the stop request but never evicts an owner that stays alive', async () => { + it('releases an owner that survives the stop ladder, with no death evidence', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'recovering') + await latch(store) const stopOwnerProcess = vi.fn() const result = await resolveStructuredSessionRecovery( @@ -201,22 +202,25 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') - expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') - expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGKILL') - // The latch is preserved verbatim: no fence move, no cleared owner, no lost state. + expect(result).toBe('resolved') + expect(stopOwnerProcess.mock.calls).toEqual([ + [4242, 'SIGTERM'], + [4242, 'SIGKILL'] + ]) + // Its transport died with the runtime that held it; nothing proved it gone, so no evidence. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: 'recovering', - runtimeFence: 1, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null, + deathEvidence: null }) }) - it('leaves an unverifiable owner latched and requests no stop', async () => { + it('releases an owner whose identity cannot be verified, and signals nothing', async () => { const store = await openStore() await liveOwner(store) - await latch(store, 'recovering') + await latch(store) const stopOwnerProcess = vi.fn() const result = await resolveStructuredSessionRecovery( @@ -226,11 +230,15 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') + // The pid may have been reused by an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: 'recovering', - runtimeFence: 1 + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null, + deathEvidence: null }) }) @@ -255,7 +263,7 @@ describe('structured session recovery resolution', () => { expect(await readPersistedLease(directory, SESSION)).toMatchObject({ runtimeKind: 'native', claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' }) expect( @@ -270,7 +278,7 @@ describe('structured session recovery resolution', () => { }) }) - it('resolves a terminal reservation an older build left naming nobody', async () => { + it('releases a terminal reservation an older build left naming nobody at restart', async () => { const directory = await newStoreDirectory() await reserve(await openStore(directory)) await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) @@ -279,63 +287,18 @@ describe('structured session recovery resolution', () => { probe: async () => ({ outcome: 'indeterminate', reason: 'no scan' }), now: NOW }) - // Only the kind changes: no process is recorded for a conflict to name. + // No process is recorded for a conflict to name, so there is nothing to wait out. expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', - claimStatus: 'reserved', - handoffStage: 'manual-recovery' + claimStatus: 'released', + handoffStage: null, + deathEvidence: null }) - expect( await resolveStructuredSessionRecovery( deps(store, () => ({ outcome: 'reservation-unused' })), SESSION ) - ).toBe('resolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'released' - }) - }) - - it('frees a conflicted claim once its named owner is proven gone', async () => { - const store = await openStore() - await liveOwner(store) - await store.markClaimConflicted(SESSION, NOW) - - expect( - await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'pid-absent' })), - SESSION - ) - ).toBe('resolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'released', - deathEvidence: { kind: 'pid-absent' } - }) - }) - - it('never stops the process a conflicted claim names, and keeps the conflict without proof', async () => { - const store = await openStore() - await liveOwner(store) - await store.markClaimConflicted(SESSION, NOW) - const stopOwnerProcess = vi.fn() - - const result = await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), { - stopOwnerProcess - }), - SESSION - ) - - // Ownership was never settled, so the process on the other side of the conflict is not - // Orca's to kill; only the user can decide which claimant wins. - expect(stopOwnerProcess).not.toHaveBeenCalled() - expect(result).toBe('unresolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery' - }) + ).toBe('not-applicable') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 40317552d6e..5e04cbdf90e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -1,8 +1,12 @@ /** - * Exits from latched recovery stages. A session lands in `recovering` / `manual-recovery` - * when evidence about its owner was UNAVAILABLE; this module re-asks with present-time - * evidence and releases the lease only on proof. A stop is a request — the lease moves only - * after a later probe proves the process absent, never on a timeout. + * Exits from the `recovering` stage. A session lands there when evidence about its owner was + * unavailable; this re-asks with present-time evidence and always concludes. A dead owner is + * evicted on proof. A live one is stopped by identity and evicted once + * proven gone. One that outlives the stop, or whose identity cannot be verified, is released + * anyway: its transport died with the runtime that held it, so nothing can drive it, and no signal + * is sent to a pid that cannot be verified as the one recorded. Only a conflicted claim, which is + * how a terminal owner an older build recorded now loads, is waited out and never stopped: it is + * the user's own agent, and its exit is its way out. */ import { @@ -11,6 +15,7 @@ import { type AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { releaseUnprovenAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' export type StructuredSessionRecoveryStopSignal = 'SIGTERM' | 'SIGKILL' @@ -33,62 +38,46 @@ const UNRESOLVED_REFUSALS: ReadonlySet = new Set([ 'agent_session_identity_required' ]) -/** Which latched records this module may re-ask about. */ -export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord): boolean { - if (record.lease.settlementRetryRequired) { - // Settlement latches are cleared only by a successful journal retry, never by owner probing. - return false - } - const { claimStatus, handoffStage, ownerProcess } = record.lease - if (handoffStage !== 'recovering' && handoffStage !== 'manual-recovery') { - return false - } - if (claimStatus === 'conflicted') { - // A conflict names one process. Re-asking is only meaningful against that name; with none - // recorded there is nothing present-time evidence could settle, and the user decides. - return ownerProcess !== null - } - return true -} - -/** Stopping a matched owner is only Orca's call when Orca owned its transport. A conflicted claim - * means ownership was never settled — including a terminal an older build recorded as owner. */ -function recoveryMayStopOwner(record: AgentSessionRecord): boolean { - return record.lease.claimStatus !== 'conflicted' -} - export async function resolveStructuredSessionRecovery( deps: StructuredSessionRecoveryResolutionDeps, sessionId: string ): Promise<'resolved' | 'unresolved' | 'not-applicable'> { const record = deps.store.getRecord(sessionId) - if (!record || !structuredSessionRecoveryIsResolvable(record)) { + if (record?.lease.handoffStage !== 'recovering') { return 'not-applicable' } let probe = await deps.probeRecord(record) const owner = record.lease.ownerProcess - if ( - owner && - owner.hostId === deps.store.hostId && - isProvenAliveProbe(probe) && - recoveryMayStopOwner(record) - ) { - // The owner is a live child of a runtime that no longer exists; its transport cannot be - // reconstructed, so the only way forward is to stop it and prove it gone. + if (owner && record.lease.claimStatus === 'conflicted' && !isProvenDeadProbe(probe)) { + // A terminal agent keeps its transport across a restart, so only proof of its exit is a way in. + return 'unresolved' + } + if (owner && isProvenAliveProbe(probe)) { + if (owner.hostId !== deps.store.hostId) { + return 'unresolved' + } probe = await stopOwnerAndReprobe(deps, record, owner.pid) } try { - await deps.store.evictProvenDeadOwner({ - sessionId, - expectedFence: record.lease.runtimeFence, - probe, - now: deps.now() - }) + await (owner && !isProvenDeadProbe(probe) + ? deps.store.transitionHandoff(sessionId, (latest) => + releaseUnprovenAgentSessionOwner({ + record: latest, + expectedFence: record.lease.runtimeFence, + now: deps.now() + }) + ) + : deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: deps.now() + })) return 'resolved' } catch (error) { const code = error instanceof Error ? error.message : String(error) if (UNRESOLVED_REFUSALS.has(code)) { - // No proof yet; the record is preserved untouched and the next attempt re-asks. + // The record moved under this resolution; the next attempt re-asks against what it is now. return 'unresolved' } throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index 0184366e344..9d5cab66e27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -7,6 +7,7 @@ * the action that supplies it. */ +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' @@ -16,16 +17,10 @@ function ownerDescription(record: AgentSessionRecord): string { } function latchedMessage(record: AgentSessionRecord): string { - const owner = record.lease.ownerProcess - if (record.lease.settlementRetryRequired) { - return 'The provider exited, but Orca has not finished settling the terminal chat state. Reopen this chat to retry the settlement.' - } if (record.lease.claimStatus === 'conflicted') { - return owner - ? `Two runtimes claimed this session and Orca cannot yet prove that ${ownerDescription(record)} has exited. Quit that process, or reopen this chat once it is gone, and Orca will take the session back.` - : 'Two runtimes claimed this session and the record names no process to check. Quit any other Orca or agent process using this workspace, then reopen this chat.' + return terminalOwnerRefusalMessage(record.lease) } - return owner + return record.lease.ownerProcess ? `Orca cannot prove that ${ownerDescription(record)} — the previous owner of this session — has exited, so it will not start a second agent on the same conversation. Quit that process and reopen this chat.` : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index f46ff48ce15..56d5e0b2317 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -49,7 +49,7 @@ describe('restart journal restoration', () => { serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined, - retrySettlement: async () => true + settleStaleState: async () => undefined }) await vi.waitFor(() => expect(active).toBe(4)) @@ -61,7 +61,7 @@ describe('restart journal restoration', () => { expect(peak).toBe(4) }) - it('runs pending settlement retry after recovery resolution', async () => { + it('settles what a gone generation left running after recovery resolution, before publishing', async () => { const calls: string[] = [] const params: AgentSessionAttachParams = { envelope: { @@ -81,13 +81,14 @@ describe('restart journal restoration', () => { accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, runtimeKind: 'native' } - restoreRead.mockResolvedValue({ + const restored = { journal: {}, params, fence: 4, hasProviderChild: false, acquisitionGeneration: null - }) + } + restoreRead.mockResolvedValue(restored) await restoreOneStructuredAgentSessionRead( { @@ -102,21 +103,18 @@ describe('restart journal restoration', () => { onReadable: () => { calls.push('onReadable') }, - retrySettlement: async (_sessionId, restoredParams) => { - calls.push( - restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement' - ) - return true + settleStaleState: async (_sessionId, settled) => { + calls.push(settled === restored ? 'settleStaleState:restored' : 'settleStaleState') } }, 'session-1' ) - expect(calls).toEqual(['resolveRecovery', 'onReadable', 'retrySettlement:restored-params']) + expect(calls).toEqual(['resolveRecovery', 'settleStaleState:restored', 'onReadable']) }) - it('does not rerun settlement retry when a second restore finds the session already open', async () => { - const retrySettlement = vi.fn(async () => true) + it('does not settle again when a second restore finds the session already open', async () => { + const settleStaleState = vi.fn(async () => undefined) restoreRead.mockResolvedValue({ journal: {}, params: {}, @@ -134,11 +132,11 @@ describe('restart journal restoration', () => { serialize: async (_sessionId, task) => task(), hasSession: () => true, onReadable: () => undefined, - retrySettlement + settleStaleState }, 'session-1' ) - expect(retrySettlement).not.toHaveBeenCalled() + expect(settleStaleState).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 38c04d2a597..1b5d8864ae5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -30,10 +30,11 @@ export type StructuredAgentSessionReadRestoreDeps = { serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void - retrySettlement: ( + /** Settles what a previous generation left running. Best effort: the next acquire re-derives it. */ + settleStaleState: ( sessionId: string, - params: RestoredStructuredAgentSessionRead['params'] - ) => Promise + restored: RestoredStructuredAgentSessionRead + ) => Promise } /** @@ -62,7 +63,7 @@ export async function restoreOneStructuredAgentSessionRead( export async function restoreOneStructuredAgentSessionReadUnderSerialize( input: Pick< StructuredAgentSessionReadRestoreDeps, - 'store' | 'journalRoot' | 'hasSession' | 'onReadable' | 'retrySettlement' + 'store' | 'journalRoot' | 'hasSession' | 'onReadable' | 'settleStaleState' >, sessionId: string ): Promise { @@ -78,8 +79,10 @@ export async function restoreOneStructuredAgentSessionReadUnderSerialize( if (!restored) { return } + // No child in this process writes to a journal with no map entry, so anything it shows running + // belongs to a generation that is gone. Settled before it is published, so no reader sees it run. + await input.settleStaleState(sessionId, restored) input.onReadable(sessionId, restored) - await input.retrySettlement(sessionId, restored.params) } export async function restoreStructuredAgentSessionsOnRestart( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts index 1b054018371..2e031b9f071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts @@ -7,17 +7,14 @@ // it answers "may this be resumed", not "should it be" — so it lives here now and the caller that // knows a surface is asking is the only one that acts on it. +import { agentSessionLeaseIsReleased } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { randomUUID } from 'node:crypto' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-read-restore' export function isResumableStructuredAgentSessionRecord(record: AgentSessionRecord): boolean { - return ( - !record.lease.unreconciled && - record.lease.claimStatus === 'released' && - record.lease.handoffStage === null - ) + return agentSessionLeaseIsReleased(record.lease) } /** Attach params for a resume, or null when this record's lease is somebody else's problem. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 291a6e0c53e..1172e993b96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -57,7 +57,7 @@ function harness( serialize, hasSession: (sessionId) => live.has(sessionId), onReadable: (sessionId, restored) => live.set(sessionId, restored), - retrySettlement: async () => true + settleStaleState: async () => undefined }) return { restorer, live, serializedIds } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index bc36df76df8..097cd6b479a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -16,10 +16,9 @@ import { StructuredAgentSessionReadableRestorer } from './structured-agent-sessi import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' /** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ export async function revealStructuredAgentSession( @@ -57,11 +56,9 @@ export async function revealStructuredAgentSession( */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, - sessions: Map, - now: () => number, wiring: Omit< ConstructorParameters[0], - 'store' | 'journalRoot' | 'supportsRecord' | 'retrySettlement' + 'store' | 'journalRoot' | 'supportsRecord' | 'settleStaleState' > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise @@ -73,8 +70,19 @@ export function createStructuredAgentSessionHostRestore( store: deps.store, journalRoot: deps.journalRoot, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - retrySettlement: (sessionId, params) => - retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }), + settleStaleState: async (sessionId, restored) => { + try { + await settleStaleStructuredAgentSessionState({ + journal: restored.journal, + sessionId, + fence: restored.fence, + acquisitionGeneration: null, + deathEvidence: deps.store.getRecord(sessionId)?.lease.deathEvidence ?? null + }) + } catch (error) { + deps.onEventSinkError?.({ sessionId, error }) + } + }, ...wiring }) const gate = new StructuredAgentSessionRestartRestoreGate() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index c23083fa687..a93a64b0dc2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -650,7 +650,7 @@ describe('a send with no live owner', () => { // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. await store.transitionHandoff(SESSION, (current) => ({ ...current, - lease: { ...current.lease, handoffStage: 'manual-recovery' } + lease: { ...current.lease, handoffStage: 'recovering' } })) host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 5780359a8ae..f0dd60965ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -207,7 +207,7 @@ describe('settled attach retry', () => { expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) - it('fences a crash-interrupted reservation replay until positive recovery', async () => { + it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { const spawnTokens: string[] = [] acquire.mockImplementation(async ({ fence, spawnToken }) => { spawnTokens.push(spawnToken) @@ -232,17 +232,17 @@ describe('settled attach retry', () => { }) let token = 0 const mintSpawnToken = vi.fn(() => `spawn-${++token}`) - let reservationUnused = false host = new StructuredAgentSessionHost({ store, adapter: adapter(), journalRoot: root, claimKeyId: 'key-1', mintSpawnToken, - probeOwner: async () => - reservationUnused - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + // A host that cannot read another process's environment, so no scan can prove anything. + probeOwner: async () => ({ + outcome: 'indeterminate', + reason: 'spawn token scan unavailable' + }), now: () => NOW }) const params = hostTestAttachParams(null) @@ -268,36 +268,28 @@ describe('settled attach retry', () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken, - probeOwner: async () => - reservationUnused - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + // A host that cannot read another process's environment, so no scan can prove anything. + probeOwner: async () => ({ + outcome: 'indeterminate', + reason: 'spawn token scan unavailable' + }), now: () => NOW }) - const refused = await host.attach(CALLER, params) - if (refused.ok) { - throw new Error('expected the replayed reservation to stay fenced') - } - expect(refused.refusal.code).toBe('agent_session_ownership_unknown') - expect(acquire).toHaveBeenCalledTimes(1) + await host.restoreReadableSessions() expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(mintSpawnToken).toHaveBeenCalledTimes(1) - expect(spawnTokens).toEqual(['spawn-1']) + // No owner was recorded: released at restart, with no evidence, since nothing proved one. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'manual-recovery', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + reservedSpawnToken: null, + ownerProcess: null, + deathEvidence: null }) - expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ?.outcome - ).toEqual({ status: 'pending' }) - reservationUnused = true - await host.hold(SESSION, 'desktop-chat:retry') + // The interrupted operation's own retry continues it as a fresh reservation. + await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) expect(mintSpawnToken).toHaveBeenCalledTimes(2) expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) expect(store.getRecord(SESSION)?.lease).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts deleted file mode 100644 index c0d29f6b9ef..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -// The settlement latch governs EVERY unclean restart — SIGKILL, force quit, OOM, a quit that blew -// its deadline — so the evidence it reads decides whether the user sees a failure notice at all. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentSessionDeathEvidence, - AgentSessionRecord -} from '../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' -import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' - -const SESSION = 'session-alpha-1' -const THREAD = 'thread-1' -const FENCE = 8 - -let root: string -let journal: AgentSessionJournal -let record: AgentSessionRecord - -function store(): StructuredAgentSessionLeaseStore { - return { - getRecord: () => record, - transitionHandoff: async (_sessionId, transition) => { - record = transition(record) - return record - } - } -} - -function retry(settlementId: string, deathEvidence: AgentSessionDeathEvidence) { - record = agentSessionRecordFixture({ - ...agentSessionRecordFixture().lease, - runtimeKind: 'native', - runtimeFence: FENCE, - deathEvidence, - settlementRetryRequired: true, - settlementRetryId: settlementId - }) - return retryLoadedStructuredAgentSessionSettlement({ - deps: { store: store() }, - sessionId: SESSION, - session: { journal, fence: FENCE, acquisitionGeneration: null }, - now: () => 2_000 - }) -} - -function statusTexts(): string[] { - return journal - .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settlement-retry-')) - journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: root, - now: () => 1_000 - }) -}) - -/** A turn the dead generation left running: work to settle either way. */ -async function seedRunningTurn(): Promise { - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: FENCE } - ) -} - -/** The provider died while the user, not the provider, held the conversation. */ -async function seedIdlePendingApproval(): Promise { - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { - kind: 'approval', - title: 'Run command?', - detail: null, - options: [{ id: 'yes', label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: FENCE } - ) -} - -afterEach(async () => { - await journal.close() - await rm(root, { recursive: true, force: true }) -}) - -describe('pending settlement retry', () => { - it('writes no status row when the death was only adjudicated, not witnessed', async () => { - await seedRunningTurn() - - await expect( - retry(`restart-eviction:${SESSION}:${FENCE}`, { - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( - expect.objectContaining({ kind: 'turn', state: 'unverifiable' }) - ) - expect(record.lease.settlementRetryRequired).toBeUndefined() - }) - - it('writes no status row for an identity mismatch either', async () => { - await seedRunningTurn() - - await expect( - retry(`restart-eviction:${SESSION}:${FENCE}`, { - kind: 'identity-mismatch', - detail: 'mismatched spawn-token', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - }) - - it('reads the evidence, not the settlement id, when deciding to speak', async () => { - // Pins the discriminator: the id shape that normally accompanies a witnessed exit must not - // earn the notice on its own. - await seedRunningTurn() - - await expect( - retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - }) - - it('writes user-facing copy carrying the cause when the exit was observed', async () => { - await seedRunningTurn() - - await expect( - retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { - kind: 'exit-observed', - detail: 'transport closed', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([ - 'The provider stopped while this response was in progress: transport closed. You can continue in this conversation.' - ]) - expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( - expect.objectContaining({ kind: 'turn', state: 'interrupted', completedAt: 1_500 }) - ) - }) - - it('stays silent about a witnessed exit that interrupted nothing but a waiting prompt', async () => { - await seedIdlePendingApproval() - - await expect( - retry(`provider-exit:${SESSION}:${FENCE}:generation-1`, { - kind: 'exit-observed', - detail: 'transport closed', - observedAt: 1_500 - }) - ).resolves.toBe(true) - - expect(statusTexts()).toEqual([]) - expect(journal.snapshot().items.map((item) => item.body)).toContainEqual( - expect.objectContaining({ - kind: 'approval', - resolution: expect.objectContaining({ state: 'cancelled' }) - }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts deleted file mode 100644 index 1000881220e..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { attachJournal } from './structured-agent-session-attach' -import type { - StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession -} from './structured-agent-session-host-types' -import type { StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' -import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' -import { - captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, - unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' - -export async function retryPendingStructuredAgentSessionSettlement(input: { - deps: StructuredAgentSessionHostDeps - sessions: Map - sessionId: string - params: AgentSessionAttachParams - now: () => number -}): Promise { - const record = input.deps.store.getRecord(input.sessionId) - if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { - return true - } - let journal = input.sessions.get(input.sessionId)?.journal - if (!journal) { - try { - journal = ( - await attachJournal({ - record, - params: input.params, - journalRoot: input.deps.journalRoot, - adapter: input.deps.adapter - }) - ).journal - } catch (error) { - input.deps.onEventSinkError?.({ sessionId: input.sessionId, error }) - return false - } - } - const current = input.sessions.get(input.sessionId) - const retrySession = - current ?? - ({ - journal, - params: input.params, - fence: record.lease.runtimeFence, - hasProviderChild: false, - acquisitionGeneration: null - } as StructuredAgentSessionHostSession) - return retryLoadedStructuredAgentSessionSettlement({ - deps: input.deps, - sessionId: input.sessionId, - session: retrySession, - now: input.now - }) -} - -export async function retryLoadedStructuredAgentSessionSettlement(input: { - deps: { - store: StructuredAgentSessionLeaseStore - onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] - } - sessionId: string - session: Pick - now: () => number -}): Promise { - const record = input.deps.store.getRecord(input.sessionId) - if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) { - return true - } - const retrySession = input.session - retrySession.fence = record.lease.runtimeFence - const onError = (id: string, error: unknown): void => - input.deps.onEventSinkError?.({ sessionId: id, error }) - // Only an observed exit earns an end time; a probe-proven death never saw one. - const verdict = turnVerdictFromDeathEvidence(record.lease.deathEvidence) - const ok = await settleStructuredAgentSessionDeadGeneration({ - journal: retrySession.journal, - sessionId: input.sessionId, - fence: retrySession.fence, - settlementId: record.lease.settlementRetryId, - pendingSubmissionReason: 'provider_exited_before_acknowledgement', - verdict, - // The same evidence decides the copy: only a witnessed death is worth telling the user - // about. An unverifiable one is a restart artefact, and the session stays sendable. The - // work check matches the live exit path — a provider that died waiting on a prompt - // interrupted no response, so it must not claim one was in progress. - showUnexpectedExitOutcome: - verdict.state === 'interrupted' && - unfinishedStructuredAgentSessionWorkWasInterrupted( - captureUnfinishedStructuredAgentSessionWork(retrySession.journal), - retrySession.journal, - verdict.completedAt - ), - ...(record.lease.deathEvidence?.detail - ? { unexpectedExitReason: record.lease.deathEvidence.detail } - : {}), - onError - }) - if (!ok) { - return false - } - try { - await input.deps.store.transitionHandoff(input.sessionId, (latest) => { - if ( - latest.lease.runtimeFence !== record.lease.runtimeFence || - !latest.lease.settlementRetryRequired - ) { - throw new Error('agent_session_checkpoint_stale') - } - return { - ...latest, - lease: { - ...latest.lease, - handoffStage: null, - handoffOperationId: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined, - lastRenewedAt: input.now() - } - } - }) - return true - } catch (error) { - input.deps.onEventSinkError?.({ sessionId: input.sessionId, error }) - return false - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 3264a55fff2..4ab7b3ad010 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -6,9 +6,9 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { runningTurnLifecycleRevisions, - settleStaleSessionStateOnAcquire, turnVerdictFromDeathEvidence, UNVERIFIABLE_TURN_VERDICT } from './structured-agent-session-stale-turn-verdict' @@ -198,11 +198,12 @@ describe('stale session state on a cold acquire', () => { ]) await expect( - settleStaleSessionStateOnAcquire({ + settleStaleStructuredAgentSessionState({ journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) ).resolves.toBe(1) @@ -226,11 +227,12 @@ describe('stale session state on a cold acquire', () => { const { journal, appendLifecycleBatch } = journalWith([pending, resolved]) await expect( - settleStaleSessionStateOnAcquire({ + settleStaleStructuredAgentSessionState({ journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) ).resolves.toBe(1) @@ -288,11 +290,12 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem(prompt('thread-child'), body, { fence: 1, ...child }) await journal.appendItem(prompt(THREAD), body, { fence: 1 }) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal, sessionId: 'session-1', fence: 2, - acquisitionGeneration: 'generation-2' + acquisitionGeneration: 'generation-2', + deathEvidence: null }) expect( @@ -312,21 +315,23 @@ describe('stale session state on a cold acquire', () => { lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) ]) await expect( - settleStaleSessionStateOnAcquire({ + settleStaleStructuredAgentSessionState({ journal: idle.journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: null + acquisitionGeneration: null, + deathEvidence: null }) ).resolves.toBe(0) expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() const running = journalWith([lifecycleItem('turn-2', 'running', 2)]) - await settleStaleSessionStateOnAcquire({ + await settleStaleStructuredAgentSessionState({ journal: running.journal, sessionId: 'session-1', fence: 14, - acquisitionGeneration: null + acquisitionGeneration: null, + deathEvidence: null }) expect(running.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ settlementId: 'stale-session:session-1:14:seq-8' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 784067b303c..b6e9b9bde5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -2,7 +2,7 @@ // // `interrupted` requires the host to have seen the child exit; that receipt is the only end time it // is allowed to record. Everything weaker — a pid probe, an identity mismatch, a journal found -// running on a cold acquire — is `unverifiable` and carries no end at all. +// running with no observed exit on the record — is `unverifiable` and carries no end at all. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -14,10 +14,7 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' -import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' export type StructuredAgentSessionTurnVerdict = | { state: 'interrupted'; completedAt: number } @@ -59,28 +56,6 @@ export function runningTurnLifecycleRevisions( return revisions } -function staleSessionLifecycleRevisions( - items: readonly AgentJournalRenderItem[] -): JournalLifecycleMutationInput[] { - const revisions: JournalLifecycleMutationInput[] = [] - for (const item of items) { - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - continue - } - const cancelled = - (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' - ? cancelledJournalPromptBody(item.body) - : null - if (cancelled) { - revisions.push({ kind: 'item', identity, body: cancelled }) - } - } - revisions.push(...runningTurnLifecycleRevisions(items, UNVERIFIABLE_TURN_VERDICT)) - return revisions -} - /** The verdict owns the turn's end and nothing else; every other field the row * carries, including ones this build does not know, stays as it was. */ function settledLifecycle( @@ -98,26 +73,3 @@ function settledLifecycle( ? { ...kept, state: verdict.state, completedAt: verdict.completedAt } : { ...kept, state: verdict.state } } - -/** A running row found when a NEW child is acquired belongs to a generation whose exit nobody - * observed. Must run before that child's buffered events land, or a live turn would be judged. */ -export async function settleStaleSessionStateOnAcquire(input: { - journal: AgentSessionJournal - sessionId: string - fence: number - acquisitionGeneration: string | null -}): Promise { - const { journal } = input - const revisions = staleSessionLifecycleRevisions(journal.snapshot().items) - const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` - for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) { - await journal.appendLifecycleBatch({ - settlementId: chunk.settlementId, - fence: input.fence, - recovered: true, - mutations: chunk.mutations - }) - } - return revisions.length -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 09dd5f82197..25c95b48dbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -25,6 +25,7 @@ import { import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { HOST_TEST_NOW as NOW, @@ -424,8 +425,7 @@ describe('startup', () => { ) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', - ownerProcess: null, - settlementRetryRequired: undefined + ownerProcess: null }) await host.hold(SESSION, SURFACE) @@ -710,7 +710,7 @@ describe('an unexpected provider exit', () => { expect(dispatch).toHaveBeenCalledTimes(2) }) - it('latches a failed exit settlement and blocks attach until the terminal batch is written', async () => { + it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { await attach() await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) @@ -733,9 +733,10 @@ describe('an unexpected provider exit', () => { } ).sessions.get(SESSION) expect(session).toBeDefined() - const appendSettlement = vi - .spyOn(session!.journal, 'appendLifecycleBatch') - .mockRejectedValue(new Error('settlement still unavailable')) + // The dead generation's handle never accepts its settlement. + vi.spyOn(session!.journal, 'appendLifecycleBatch').mockRejectedValue( + new Error('settlement still unavailable') + ) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -747,30 +748,34 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ + const released = store.getRecord(SESSION)?.lease + expect(released).toMatchObject({ claimStatus: 'released', - handoffStage: 'recovering', - settlementRetryRequired: true, - settlementRetryId: `provider-exit:${SESSION}:${exitedFence}:generation-1`, - ownerProcess: null, - runtimeFence: exitedFence + 1 - }) - expect(await host.attach(CALLER, hostTestAttachParams(exitedFence + 1))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - expect(acquire).toHaveBeenCalledOnce() - - appendSettlement.mockRestore() - expect(await host.attach(CALLER, hostTestAttachParams(exitedFence + 1))).toMatchObject({ - ok: true - }) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', handoffStage: null, - settlementRetryRequired: undefined + ownerProcess: null, + runtimeFence: exitedFence + 1, + deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } }) + + dispatch.mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } + }) + const body = hostTestMessage('sent after a settlement that never landed') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) expect(acquire).toHaveBeenCalledTimes(2) + // The new child's acquire settled the turn from the release's evidence: ended at the exit's + // receipt, with the exit's own reason in the row. + const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const items = history.ok ? history.page.items : [] + expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) + ) + expect( + items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + ).toContain(unexpectedProviderExitOutcome('provider exited')) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 4ba1a93169b..98901380aa3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -7,8 +7,10 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' -import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' +import { + settleStaleStructuredAgentSessionState, + unexpectedProviderExitOutcome +} from './structured-agent-session-dead-generation-settlement' import { isStructuredAgentSessionRecoveryTicketCurrent, settleUnexpectedStructuredAgentSessionExit, @@ -103,19 +105,11 @@ function mutableStore() { } describe('provider-exit recovery tickets', () => { - it.each([undefined, 2_000])('keeps exit receipt %s on retry', async (observedAt) => { + it.each([undefined, 2_000])('keeps exit receipt %s when settling fails', async (observedAt) => { let now = observedAt === undefined ? 2_000 : 30_000 - let record = { - lease: { - handoffStage: null, - runtimeFence: 7, - runtimeKind: 'native', - claimStatus: 'live', - ownerProcess: 'provider', - reservedSpawnToken: null, - processlessAt: null - } - } as unknown as AgentSessionRecord + let record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', reservedSpawnToken: null }) + ) const store = { getRecord: () => record, transitionHandoff: async ( @@ -163,20 +157,23 @@ describe('provider-exit recovery tickets', () => { observedAt } ) - expect(record.lease.settlementRetryRequired).toBe(true) - expect(record.lease.deathEvidence?.observedAt).toBe(2_000) + // Released, not latched: the next acquire or read restore settles what this write left. + expect(record.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: 2_000 } + }) expect(record.lease.lastRenewedAt).toBe(60_000) expect(record.updatedAt).toBe(60_000) now = 120_000 - await expect( - retryLoadedStructuredAgentSessionSettlement({ - deps: { store } as never, - sessionId: SESSION, - session: { journal: session.journal, fence: 8, acquisitionGeneration: null }, - now: () => now - }) - ).resolves.toBe(true) + await settleStaleStructuredAgentSessionState({ + journal: session.journal, + sessionId: SESSION, + fence: 8, + acquisitionGeneration: 'generation-2', + deathEvidence: record.lease.deathEvidence + }) expect(appendLifecycleBatch.mock.calls.at(-1)?.[0].mutations).toContainEqual( expect.objectContaining({ body: { @@ -188,7 +185,6 @@ describe('provider-exit recovery tickets', () => { } }) ) - expect(record.lease.settlementRetryRequired).toBeUndefined() }) it('uses the fallback when the one-shot translator admission was rejected, revising the running turn in place', async () => { @@ -215,8 +211,7 @@ describe('provider-exit recovery tickets', () => { runtimeKind: 'native', claimStatus: 'live', ownerProcess: 'provider', - reservedSpawnToken: null, - processlessAt: null + reservedSpawnToken: null } }), transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) @@ -238,8 +233,7 @@ describe('provider-exit recovery tickets', () => { reason: 'provider exited', cause: 'unexpected-exit', fence: 7, - acquisitionGeneration: GENERATION, - settlementRetryRequired: true + acquisitionGeneration: GENERATION } ) @@ -408,7 +402,7 @@ describe('provider-exit recovery tickets', () => { ) }) - it('does not release or reacquire while terminal settlement retry is still failing', async () => { + it('releases without offering a restart while terminal settlement is failing', async () => { const session: StructuredAgentSessionUnexpectedExitSession = { hasProviderChild: true, fence: 7, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 5363b8588e7..f21de56f2ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -126,15 +126,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< acquisitionGeneration: session.acquisitionGeneration, now: context.now(), exitObservedAt: observedAt, - ...(settlementFailed - ? { - settlementRetry: { - settlementId: stableSettlementId, - // Bare cause: the retry renders it, and `exit-observed` already says the rest. - detail: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - } - } - : {}) + // Bare cause: whatever this settlement could not write is settled from it later, by the + // next acquire or read restore, and `exit-observed` already says the rest. + exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) }) } catch (error) { context.onBarrierError?.(unexpectedEvent.sessionId, error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 21a5fe4e49d..0778d271129 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -14,8 +14,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication' +import { + evaluateAgentSessionAcquisition, + type AgentSessionOwnerProbe +} from '../../../shared/agent-session-lease-adjudication' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionClaimStatus, @@ -31,7 +35,7 @@ import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-recor import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -265,9 +269,7 @@ describe('already-wedged profiles become usable on load', () => { expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', - handoffStage: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined + handoffStage: null }) expect(acquire).not.toHaveBeenCalled() @@ -284,6 +286,49 @@ describe('already-wedged profiles become usable on load', () => { } ) + it.each([ + [ + 'an exit the host saw but could not settle before quitting', + wedgedRecord({ claimStatus: 'released', handoffStage: null }), + { + kind: 'exit-observed', + detail: 'provider exited: transport closed', + observedAt: NOW - 1_000 + } as const, + { state: 'interrupted', completedAt: NOW - 1_000 } + ], + [ + 'a quit that left the owner for a probe to prove gone', + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }), + null, + { state: 'unverifiable' } + ] + ] as const)( + 'reopens a chat that was mid-turn at %s with nothing running and no working status', + async (_quit, seeded, deathEvidence, verdict) => { + await seedStore({ ...seeded, lease: { ...seeded.lease, deathEvidence } }) + await seedRunningTurn() + const published: AgentSessionStatusSummary[] = [] + openHost({ + statusSink: { publish: (summary) => published.push(summary), forget: () => {} } + }) + + await host.restoreReadableSessions() + + expect(acquire).not.toHaveBeenCalled() + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + startedAt: NOW - 5_000, + recovered: true, + ...verdict + }) + expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + // What the sidebar reads: every status this restart published says the chat is not working. + expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) + expect(published.map((summary) => summary.status)).not.toContain('working') + } + ) + it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => { await seedStore( wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) @@ -304,20 +349,25 @@ describe('already-wedged profiles become usable on load', () => { }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', - handoffStage: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined + handoffStage: null }) }) it('settles an observed-exit latch through attach before the boot sweep', async () => { const record = wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' }) - record.lease.settlementRetryRequired = true - record.lease.settlementRetryId = `provider-exit:${SESSION}:12:generation-1` - record.lease.deathEvidence = { - kind: 'exit-observed', - detail: 'provider exited: transport closed', - observedAt: NOW - 1_000 + // The settlement latch an older build wrote; this build derives the settlement instead. + const olderBuildLatch = { + settlementRetryRequired: true, + settlementRetryId: `provider-exit:${SESSION}:12:generation-1` + } + record.lease = { + ...record.lease, + ...olderBuildLatch, + deathEvidence: { + kind: 'exit-observed', + detail: 'provider exited: transport closed', + observedAt: NOW - 1_000 + } } await seedStore(record) await seedRunningTurn() @@ -337,12 +387,115 @@ describe('already-wedged profiles become usable on load', () => { }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', - handoffStage: null, - settlementRetryRequired: undefined, - settlementRetryId: undefined + handoffStage: null }) + // The older build's latch is dropped at load, so a downgrade never sees it again. + expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryRequired') + expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryId') }) + it.each([ + ['a restart eviction', false], + ['a proven eviction by recovery', true] + ] as const)( + 'settles the turn %s left even when the handle it was restored with never writes', + async (_origin, ownerOutlivedRestart) => { + await seedStore( + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) + ) + await seedRunningTurn() + let ownerAlive = ownerOutlivedRestart + const stopOwnerProcess = vi.fn(() => { + ownerAlive = false + }) + openHost({ + probeOwner: async () => + ownerAlive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + }) + // The read restore's settlement fails, and the handle it restored with never writes again. + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch') + .mockRejectedValue(new Error('journal unavailable')) + await host.restoreReadableSessions() + failing.mockRestore() + vi.spyOn(restoredJournal(), 'appendLifecycleBatch').mockRejectedValue( + new Error('journal unavailable') + ) + expect(stopOwnerProcess).toHaveBeenCalledTimes(ownerOutlivedRestart ? 1 : 0) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: { kind: 'pid-absent' } + }) + + await host.hold(SESSION, 'desktop-chat:restart') + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) + // A pid probe proved the owner gone; nobody saw it exit, so the turn has no end. + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + state: 'unverifiable', + startedAt: NOW - 5_000, + recovered: true + }) + } + ) + + it('releases an owner that survives every stop signal, and the chat starts again', async () => { + await seedStore( + wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) + ) + const stopOwnerProcess = vi.fn() + openHost({ + probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), + stopOwnerProcess + }) + + await host.restoreReadableSessions() + + expect(stopOwnerProcess.mock.calls).toEqual([ + [DEAD_OWNER.pid, 'SIGTERM'], + [DEAD_OWNER.pid, 'SIGKILL'] + ]) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + deathEvidence: null + }) + expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledOnce() + }) + + it.each([ + ['a conflicted claim naming no process', { claimStatus: 'conflicted', ownerProcess: null }], + [ + 'an unproven reservation left in manual recovery', + { claimStatus: 'reserved', ownerProcess: null, reservedSpawnToken: 'spawn-lost' } + ] + ] as const)( + 'releases %s an older build left, and the chat starts again', + async (_legacyRecord, lease) => { + await seedStore(wedgedRecord({ handoffStage: 'manual-recovery', ...lease })) + openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no scan here' }) }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 14, + deathEvidence: null + }) + expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledOnce() + } + ) + it('marks a running turn left behind by a released lease unverifiable on a cold acquire', async () => { // No settlement latch: the record was released cleanly, but the journal still says a turn is // running. The child that wrote it is gone and nothing observed its exit. @@ -419,27 +572,6 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).not.toHaveBeenCalled() }) - it('leaves a conflicted record alone while its owner cannot be proven gone', async () => { - await seedStore( - wedgedRecord({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery', - ownerProcess: DEAD_OWNER - }) - ) - openHost({ - probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) - }) - - await host.restoreReadableSessions() - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery', - ownerProcess: { pid: DEAD_OWNER.pid } - }) - }) - it('unlatches a released record that reloaded into recovery with nothing outstanding', async () => { // An evicted lease has no owner and no token, so a restart has nothing to probe. Treating that // as an unproven reservation re-latched it to `recovering` on every single boot. @@ -528,7 +660,7 @@ describe('already-wedged profiles become usable on load', () => { expect(order).toEqual(['acquire']) }) - it('names the missing evidence when a latched record still cannot be freed', async () => { + it('waits out a conflicted owner it cannot verify, signalling nothing, until it is proven gone', async () => { await seedStore( wedgedRecord({ claimStatus: 'conflicted', @@ -536,14 +668,23 @@ describe('already-wedged profiles become usable on load', () => { ownerProcess: DEAD_OWNER }) ) - openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no answer' }) }) + const stopOwnerProcess = vi.fn() + let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + openHost({ probeOwner: async () => probe, stopOwnerProcess }) await host.restoreReadableSessions() - const refused = await host.attach(CALLER, hostTestAttachParams(13)) + // A terminal agent keeps its transport across a restart, so an unanswered probe is not a way in. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict' } + }) + expect(acquire).not.toHaveBeenCalled() - expect(refused.ok).toBe(false) - const message = refused.ok ? '' : refused.refusal.message - expect(message).toContain('process 12546 on local') - expect(message).not.toContain('The session store refused this call') + // The user quits that terminal: the next open proves it gone and the chat takes over. + probe = { outcome: 'pid-absent' } + await host.hold(SESSION, 'holder-1') + expect(acquire).toHaveBeenCalledOnce() + expect(stopOwnerProcess).not.toHaveBeenCalled() }) }) diff --git a/src/main/observability/agent-session-instrumentation.ts b/src/main/observability/agent-session-instrumentation.ts index dc57bd69d0e..693d96a28c2 100644 --- a/src/main/observability/agent-session-instrumentation.ts +++ b/src/main/observability/agent-session-instrumentation.ts @@ -3,7 +3,6 @@ import { withSpan, type ActiveSpan } from './tracer' export type AgentSessionCreatePhase = | 'reconcile_leases' | 'resolve_recovery' - | 'settlement_retry' | 'probe_owner' | 'reserve_owner' | 'acquire_owner' diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 9bdbfd91d02..139bb2494dd 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -261,8 +261,7 @@ describe('agentSession.create tracing', () => { totalDurationMs: 57, phases: [ { phase: 'reconcile_leases', startedAtMs: 0, durationMs: 1 }, - { phase: 'resolve_recovery', startedAtMs: 1, durationMs: 2 }, - { phase: 'settlement_retry', startedAtMs: 3, durationMs: 3 }, + { phase: 'resolve_recovery', startedAtMs: 1, durationMs: 5 }, { phase: 'probe_owner', startedAtMs: 6, durationMs: 4 }, { phase: 'reserve_owner', startedAtMs: 10, durationMs: 5 }, { phase: 'acquire_owner', startedAtMs: 15, durationMs: 6 }, diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index fea8d2e68f5..a3e11408118 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -18,7 +18,9 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' * identity this lease is keyed on is dead, but its descendants were not proven * gone. Releases the lease and says exactly that, claiming nothing more. * - `processless`: the attempt failed before a process existed. - * - `unproven`: nothing about the process was observed; the reservation latches. + * - `unproven`: nothing about the process was observed. A recorded owner goes to recovery, which + * concludes about it; a reservation that recorded none is released, since the adapter already + * closed the stdio of anything it spawned. */ export type AgentSessionAcquisitionExitProof = | 'exit-proven' @@ -96,7 +98,6 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, @@ -131,10 +132,10 @@ function settleFailedLease( ) { throw new Error('agent_session_ownership_unknown') } - if (args.exitProof === 'unproven') { + if (args.exitProof === 'unproven' && record.lease.ownerProcess) { return withLease(record, { ...record.lease, - handoffStage: record.lease.ownerProcess ? 'recovering' : 'manual-recovery', + handoffStage: 'recovering', // The operation is durably settled failed below; a lease still naming it would // read as an in-flight transfer to every consumer that keys on the stage + id pair. handoffOperationId: null, @@ -147,7 +148,6 @@ function settleFailedLease( handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, @@ -155,11 +155,15 @@ function settleFailedLease( }) } -/** Records only what was observed: never a tree claim the cleanup did not make. */ +/** Records only what was observed: never a tree claim the cleanup did not make, and nothing at all + * when nothing was. */ function acquisitionDeathEvidence( exitProof: AgentSessionAcquisitionExitProof, observedAt: number -): AgentSessionDeathEvidence { +): AgentSessionDeathEvidence | null { + if (exitProof === 'unproven') { + return null + } if (exitProof === 'processless') { return { kind: 'pid-absent', detail: 'reservation failed before spawn', observedAt } } diff --git a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts b/src/main/runtime/agent-session-eviction-settlement-latch.test.ts deleted file mode 100644 index ebb893ef7e5..00000000000 --- a/src/main/runtime/agent-session-eviction-settlement-latch.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { evictAgentSessionOwner } from './agent-session-lease-transitions' -import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' - -const NOW = 1_800_000_000_000 - -describe('proven-dead agent session eviction settlement', () => { - it('latches restart eviction with a stable id while keeping the lease resumable', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true }) - ) - - const evicted = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - - expect(evicted.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: 8, - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - deathEvidence: { kind: 'pid-absent', detail: 'recorded pid absent on host' } - }) - }) - - it('owes no settlement for a start that died before proving its handle', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ - runtimeKind: 'native', - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - handoffOperationId: 'attach-op-1', - unreconciled: true - }) - ) - - const evicted = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'pid-absent' }, - now: NOW - }) - - expect(evicted.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: 8, - handoffStage: null, - handoffOperationId: null - }) - expect(evicted.lease).not.toHaveProperty('settlementRetryRequired') - }) - - it('latches recovery eviction from the same evicted disposition', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) - ) - - const evicted = evictAgentSessionOwner({ - record, - expectedFence: 7, - probe: { outcome: 'identity-mismatch', field: 'process-start-time' }, - now: NOW, - journalSettlement: 'required' - }) - - expect(evicted.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: 8, - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - deathEvidence: { kind: 'identity-mismatch', detail: 'mismatched process-start-time' } - }) - }) - - it('never latches an indeterminate owner', () => { - const restartRecord = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true }) - ) - const recovered = applyAgentSessionRestartAdjudication({ - record: restartRecord, - probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, - now: NOW - }) - const recoveryRecord = agentSessionRecordFixture( - agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' }) - ) - - expect(recovered.lease).toMatchObject({ - handoffStage: 'recovering', - ownerProcess: { pid: 4242 } - }) - expect(recovered.lease).not.toHaveProperty('settlementRetryRequired') - expect(recovered.lease).not.toHaveProperty('settlementRetryId') - expect(() => - evictAgentSessionOwner({ - record: recoveryRecord, - expectedFence: 7, - probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, - now: NOW, - journalSettlement: 'required' - }) - ).toThrow('agent_session_ownership_unknown') - expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryRequired') - expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryId') - }) - - it('preserves a null handoff stage when the latch survives another restart', () => { - const record = agentSessionRecordFixture( - agentSessionLeaseFixture({ - runtimeKind: 'native', - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - unreconciled: true - }) - ) - - const restored = applyAgentSessionRestartAdjudication({ - record, - probe: { outcome: 'indeterminate', reason: 'remote host unavailable' }, - now: NOW - }) - - expect(restored.lease).toMatchObject({ - handoffStage: null, - settlementRetryRequired: true, - settlementRetryId: 'restart-eviction:session-alpha-1:8', - unreconciled: false - }) - }) -}) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 580fbeda255..12c28345479 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -8,7 +8,6 @@ import { adjudicateAgentSessionRestart, - agentSessionRestartEvictionSettlementId, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -16,7 +15,9 @@ import { appendAgentSessionProviderHandleLink, type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { + AgentSessionDeathEvidence, AgentSessionJournalCheckpoint, AgentSessionLease, AgentSessionProcessIdentity, @@ -81,14 +82,11 @@ export function reserveAgentSessionOwner(args: { provenHandleLinkId: null, ownerProcess: null, reservedSpawnToken: reservation.spawnToken, - processlessAt: null, leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, lastRenewedAt: reservation.now, handoffOperationId: reservation.handoffOperationId, claimKeyId: reservation.claimKeyId, claimStatus: 'reserved', - settlementRetryRequired: undefined, - settlementRetryId: undefined, deathEvidence: null }) } @@ -117,7 +115,6 @@ export function commitAgentSessionProcessIdentity( return withLease(record, { ...record.lease, ownerProcess: args.process, - processlessAt: null, lastRenewedAt: args.now }) } @@ -205,13 +202,9 @@ export function evictAgentSessionOwner(args: { expectedFence: number probe: AgentSessionOwnerProbe now: number - journalSettlement: 'required' | 'not-required' }): AgentSessionRecord { const { record } = args assertFence(record.lease, args.expectedFence) - if (record.lease.settlementRetryRequired) { - throw new Error('agent_session_ownership_unknown') - } const adjudication = adjudicateAgentSessionRestart({ lease: record.lease, probe: args.probe, @@ -224,29 +217,50 @@ export function evictAgentSessionOwner(args: { ...record.lease, handoffStage: null, handoffOperationId: null, - processlessAt: null, lastRenewedAt: args.now }) } if (adjudication.disposition !== 'evicted') { throw new Error('agent_session_ownership_unknown') } - const settlementRequired = args.journalSettlement === 'required' + return releasedAgentSessionLease(record, adjudication.nextFence, adjudication.evidence, args.now) +} + +/** + * Recovery's conclusion when proof never came: a recorded owner whose identity cannot be verified, + * or that survived the stop ladder. Its transport died with the runtime that held it, so nothing + * can drive it, and a verdict that never arrives must not hold the conversation. Nothing proved it + * gone, so no death evidence is written. + */ +export function releaseUnprovenAgentSessionOwner(args: { + record: AgentSessionRecord + expectedFence: number + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if (record.lease.handoffStage !== 'recovering') { + throw new Error('agent_session_ownership_unknown') + } + return releasedAgentSessionLease(record, nextAgentSessionFence(record.lease), null, args.now) +} + +function releasedAgentSessionLease( + record: AgentSessionRecord, + runtimeFence: number, + deathEvidence: AgentSessionDeathEvidence | null, + now: number +): AgentSessionRecord { return withLease(record, { ...record.lease, - runtimeFence: adjudication.nextFence, + runtimeFence, handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', - lastRenewedAt: args.now, + lastRenewedAt: now, handoffOperationId: null, - deathEvidence: adjudication.evidence, - settlementRetryRequired: settlementRequired ? true : undefined, - settlementRetryId: settlementRequired - ? agentSessionRestartEvictionSettlementId(record.lease, adjudication) - : undefined + deathEvidence }) } diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 3fa1971b830..0fe0a16f4ee 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -4,8 +4,8 @@ * Pids are reused within minutes on a busy host, and reuse happens precisely in the recovery * case, so a bare pid match is never proof. Every element of the identity tuple is unavailable * somewhere — start time costs a CIM query on Windows and is missing in some containers, /proc - * does not exist on macOS — so an exact but unanswerable identity stays fenced in `recovering`; - * an ownerless, unattributable reservation enters `manual-recovery`. + * does not exist on macOS — so an unanswerable identity reports `indeterminate` and is never read + * as alive or dead. */ import { readFile } from 'node:fs/promises' diff --git a/src/main/runtime/agent-session-processless-reservation.ts b/src/main/runtime/agent-session-processless-reservation.ts deleted file mode 100644 index 9415ba8fcb7..00000000000 --- a/src/main/runtime/agent-session-processless-reservation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AgentSessionRecord } from '../../shared/agent-session-record' - -export type AgentSessionReservationProcesslessProof = { - sessionId: string - fence: number - spawnToken: string - now: number -} - -function assertReservation( - record: AgentSessionRecord, - args: AgentSessionReservationProcesslessProof -): void { - if (record.lease.runtimeFence !== args.fence || record.lease.unreconciled) { - throw new Error('agent_session_checkpoint_stale') - } - if ( - record.lease.claimStatus !== 'reserved' || - record.lease.reservedSpawnToken !== args.spawnToken - ) { - throw new Error('agent_session_ownership_unknown') - } -} - -export function setAgentSessionReservationProcesslessProof( - args: AgentSessionReservationProcesslessProof & { - record: AgentSessionRecord - processlessAt: number | null - } -): AgentSessionRecord { - const { record } = args - assertReservation(record, args) - if (args.processlessAt === null && record.lease.processlessAt == null) { - return record - } - if (record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') - } - return { - ...record, - lease: { ...record.lease, processlessAt: args.processlessAt }, - updatedAt: args.now - } -} diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index 3b92eeb5fe7..c614cfa5515 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -125,7 +125,7 @@ describe('a lease the removed terminal handoff wrote', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + handoffStage: 'recovering' }) const settled = await readFile(agentSessionStorePath(directory), 'utf-8') await open() diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 65d689503c2..ce38e43d0ea 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation, + AgentSessionLease, AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' @@ -121,6 +122,26 @@ async function establishOwner( }) } +/** The shape the removed conflict marker wrote, as it decodes. No shipped build called it; a record + * may carry it. */ +async function markLegacyConflicted( + store: AgentSessionRecordStore, + lease: Partial = {} +): Promise { + if (!store.getRecord('session-alpha')) { + await store.reserveOwner(reserveRequest()) + } + await store.transitionHandoff('session-alpha', (record) => ({ + ...record, + lease: { + ...record.lease, + claimStatus: 'conflicted', + handoffStage: 'recovering', + ...lease + } + })) +} + beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-')) }) @@ -596,10 +617,26 @@ describe('restart reconciliation', () => { ).rejects.toThrow('agent_session_ownership_unknown') }) - it('keeps a conflict conflicted across a restart that proves nothing', async () => { + it('re-adjudicates a claim an older record marked conflicted by the owner it names', async () => { const first = await open() await establishOwner(first) - await first.markClaimConflicted('session-alpha', NOW) + await markLegacyConflicted(first) + + const reopened = await open() + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no answer' }), + now: NOW + }) + // An unverifiable owner goes to recovery like any other; resolution concludes about it. + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + handoffStage: 'recovering', + ownerProcess: { pid: expect.any(Number) } + }) + }) + + it('releases a claim an older record marked conflicted that names no process', async () => { + const first = await open() + await markLegacyConflicted(first, { ownerProcess: null }) const reopened = await open() await reopened.reconcileOnRestart({ @@ -607,18 +644,10 @@ describe('restart reconciliation', () => { now: NOW }) expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'manual-recovery' + claimStatus: 'released', + handoffStage: null, + deathEvidence: null }) - await expect( - reopened.reserveOwner( - reserveRequest({ - expectedFence: 1, - probe: { outcome: 'pid-absent' }, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - ).rejects.toThrow('agent_session_conflict') }) it('releases a conflict whose named owner is proven gone at restart', async () => { @@ -626,7 +655,7 @@ describe('restart reconciliation', () => { // the process the conflict names has exited leaves no claimant left to protect. const first = await open() await establishOwner(first) - await first.markClaimConflicted('session-alpha', NOW) + await markLegacyConflicted(first) const reopened = await open() await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 445260cc47b..b2bf3632301 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -56,10 +56,6 @@ import { type AgentSessionRestartProbeArgs } from './agent-session-restart-reconciliation' import { replaceAgentSessionRecordOptions } from './agent-session-record-options' -import { - setAgentSessionReservationProcesslessProof, - type AgentSessionReservationProcesslessProof -} from './agent-session-processless-reservation' import { commitAgentSessionReservation, type AgentSessionReserveRequest, @@ -205,13 +201,6 @@ export class AgentSessionRecordStore { ) } - setReservationProcesslessProof = ( - args: AgentSessionReservationProcesslessProof & { processlessAt: number | null } - ): Promise => - this.mutate(args.sessionId, (record) => - setAgentSessionReservationProcesslessProof({ ...args, record }) - ) - async proveOwner(args: { sessionId: string fence: number @@ -259,9 +248,7 @@ export class AgentSessionRecordStore { probe: AgentSessionOwnerProbe now: number }): Promise { - return this.mutate(args.sessionId, (record) => - evictAgentSessionOwner({ ...args, record, journalSettlement: 'required' }) - ) + return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) } async transitionHandoff( @@ -325,16 +312,6 @@ export class AgentSessionRecordStore { await this.transact(() => settleAgentSessionOperationInto(this.state, args)) } - async markClaimConflicted(sessionId: string, now: number): Promise { - return this.mutate(sessionId, (record) => ({ - ...record, - updatedAt: now, - // Why: a conflicted key must stay conflicted across a restart; it cannot resolve to free - // merely because the process that observed the conflict is gone. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - })) - } - replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts new file mode 100644 index 00000000000..e3f9bcff74b --- /dev/null +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -0,0 +1,171 @@ +// A create the host was running when it died is retried under the same operation id. Recovery has +// released its reservation by then, so the retry continues it at the next fence; a reservation that +// is still held keeps answering exactly as it did. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../shared/agent-session-host-authority' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionLease } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +const PAST_EXPIRY = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-released-reservation-replay-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +function createRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: OPERATION, fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +/** The create reserved, then a restart that could prove nothing released it at fence 2. */ +async function releasedByRestart(now: number): Promise { + const first = await open() + await first.reserveOwner(createRequest()) + const store = await open() + await store.reconcileOnRestart({ probe: async () => INDETERMINATE, now }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2 + }) + return store +} + +describe('a create retried after recovery released its reservation', () => { + it.each([ + ['its operation row is still pending', 1_000], + ['its operation row has expired', PAST_EXPIRY] + ])('continues when %s, and the old reservation can never commit', async (_case, elapsed) => { + const now = NOW + elapsed + const store = await releasedByRestart(now) + + const continued = await store.reserveOwner(createRequest({ spawnToken: () => 'spawn-b', now })) + expect(continued.disposition).toBe('reserved') + expect(continued.record.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + runtimeFence: 3, + reservedSpawnToken: 'spawn-b', + handoffOperationId: OPERATION + }) + expect(store.listOperationRows()).toEqual([ + expect.objectContaining({ operationId: OPERATION, outcome: { status: 'pending' } }) + ]) + + // A spawn from the first reservation reports in late: refused at its fence, and by token. + const lateSpawn = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' } + await expect( + store.commitProcessIdentity({ sessionId: SESSION, fence: 1, process: lateSpawn, now }) + ).rejects.toThrow('agent_session_checkpoint_stale') + await expect( + store.commitProcessIdentity({ sessionId: SESSION, fence: 3, process: lateSpawn, now }) + ).rejects.toThrow('agent_session_ownership_unknown') + + // Retried again while that reservation stands: the same reservation, never a second spawn. + const mint = vi.fn(() => 'spawn-c') + const retried = await store.reserveOwner(createRequest({ spawnToken: mint, now })) + expect(retried.disposition).toBe('replayed') + expect(retried.record.lease).toMatchObject({ runtimeFence: 3, reservedSpawnToken: 'spawn-b' }) + expect(mint).not.toHaveBeenCalled() + }) + + it('refuses an expired retry of a session that has no record', async () => { + const store = await open() + await expect(store.reserveOwner(createRequest({ now: NOW + PAST_EXPIRY }))).rejects.toThrow( + 'agent_session_operation_expired' + ) + }) + + it.each([ + ['proven alive', { outcome: 'identity-matched', matchedOn: ['spawn-token'] }, 'conflict'], + ['one nothing could verify', INDETERMINATE, 'ownership_unknown'] + ] as const)('still refuses a retry once its child is live and %s', async (_case, probe, code) => { + const store = await open() + await store.reserveOwner(createRequest()) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: 1, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence: 1, + link: { + linkId: 'link-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW + }, + now: NOW + }) + + await expect(store.reserveOwner(createRequest({ probe }))).rejects.toThrow( + `agent_session_${code}` + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeFence: 1 }) + }) + + it.each<[string, Partial, string]>([ + ['in recovery', { handoffStage: 'recovering' }, 'agent_session_ownership_unknown'], + [ + 'held by a terminal agent', + { claimStatus: 'conflicted', handoffStage: 'recovering' }, + 'agent_session_conflict' + ] + ])('still refuses a retry whose reservation is %s', async (_case, lease, code) => { + const store = await open() + await store.reserveOwner(createRequest()) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, ...lease } + })) + + await expect(store.reserveOwner(createRequest())).rejects.toThrow(code) + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(1) + }) +}) diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 1c1872c2469..cad534931e6 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -204,7 +204,8 @@ describe('re-create over a failed create', () => { provenHandleLinkId: null, ownerProcess: null, reservedSpawnToken: null, - claimStatus: 'released' + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'the create failed', observedAt: 1 } }) function failedCreate(overrides: Partial = {}): AgentSessionRecord { return { @@ -230,10 +231,12 @@ describe('re-create over a failed create', () => { it('refuses when the record bound a conversation, or its attempt may still run', () => { const bound = failedCreate({ providerHandleChain: [adoptedLink()] }) const unproven = failedCreate({ - lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'manual-recovery' } + lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'recovering' } }) + // Released so a send can start over, but nothing proved the attempt gone. + const releasedUnproven = failedCreate({ lease: { ...EXITED, deathEvidence: null } }) - for (const record of [bound, unproven]) { + for (const record of [bound, unproven, releasedUnproven]) { expect(() => applyAgentSessionReservation(storeState([record]), reserveRequest(), LEASE_TTL_MS) ).toThrow('agent_session_conflict') diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index e838bb63c56..9b74186ffbe 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -12,11 +12,13 @@ import { agentSessionOperationKey, evaluateAgentSessionOperation, + pendingAgentSessionOperationRow, pruneAgentSessionOperationRows, type AgentSessionOperationDecision, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { + agentSessionLeaseIsReleased, agentSessionLeaseOwnerVerdict, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe @@ -129,7 +131,7 @@ export function admitPendingAgentSessionReservationReplay( throw new Error(decision.code) } if (decision.decision !== 'retry-reservation') { - // A replay may continue only its still-present reservation; recovery requires a fresh intent. + // A replay may continue only its still-present reservation. throw new Error('agent_session_ownership_unknown') } return record @@ -312,21 +314,43 @@ export function commitAgentSessionReservation( leaseTtlMs: number ): AgentSessionReserveResult { const decision = evaluateAgentSessionReserveOperation(state, request) + const existing = state.records.get(request.sessionId) + // An unfinished operation whose reservation recovery released continues under its own id at the + // next fence, as a resume does under a new id; the fence move stops the old spawn committing. + const continued = + existing && agentSessionLeaseIsReleased(existing.lease) + ? { ...request, expectedFence: existing.lease.runtimeFence } + : null if (decision.decision === 'refused') { - throw new Error(decision.code) + // An aged-out row proves nothing more: a released reservation runs no effect. + if (decision.code !== 'agent_session_operation_expired' || !continued) { + throw new Error(decision.code) + } + const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) + return reserveWithOperationRow(state, continued, row, leaseTtlMs) } if (decision.decision === 'replay') { - let record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) - if (decision.row.outcome.status === 'pending' && request.handoffOperationId !== null) { - record = admitPendingAgentSessionReservationReplay(record, request) + const record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) + if (decision.row.outcome.status !== 'pending' || request.handoffOperationId === null) { + return { record, disposition: 'replayed', operationRow: decision.row } } - return { record, disposition: 'replayed' as const, operationRow: decision.row } + if (continued) { + return reserveWithOperationRow(state, continued, decision.row, leaseTtlMs) + } + const retried = admitPendingAgentSessionReservationReplay(record, request) + return { record: retried, disposition: 'replayed', operationRow: decision.row } } + return reserveWithOperationRow(state, request, decision.row, leaseTtlMs) +} + +function reserveWithOperationRow( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + row: AgentSessionOperationRow, + leaseTtlMs: number +): AgentSessionReserveResult { const result = applyAgentSessionReservation(state, request, leaseTtlMs) - state.operations.set( - agentSessionOperationKey(request.operation.callerKey, request.operation.operationId), - decision.row - ) + state.operations.set(agentSessionOperationKey(row.callerKey, row.operationId), row) state.records.set(result.record.sessionId, result.record) - return { ...result, operationRow: decision.row } + return { ...result, operationRow: row } } diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index 45c59e02744..70104588a58 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -8,13 +8,9 @@ import { adjudicateAgentSessionRestart, - agentSessionRestartEvictionSettlementId, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { - AgentSessionHandoffStage, - AgentSessionRecord -} from '../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import { withLease } from './agent-session-lease-transitions' /** Apply one restart adjudication. Never consults deadlines — only proof moves a lease. */ @@ -29,9 +25,6 @@ export function applyAgentSessionRestartAdjudication(args: { probe: args.probe, observedAt: args.now }) - if (adjudication.disposition === 'settlement-pending') { - return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) - } if (adjudication.disposition === 'free') { // Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing // the stage restores it without moving the fence or touching the recorded death evidence. @@ -39,41 +32,29 @@ export function applyAgentSessionRestartAdjudication(args: { ...record.lease, handoffStage: null, handoffOperationId: null, - processlessAt: null, unreconciled: false, lastRenewedAt: args.now }) } if (adjudication.disposition === 'evicted') { - // A reservation that never proved its handle ran no turn, so no journal settlement is owed. - const settlementOwed = record.lease.handoffStage !== 'new-owner-proving' + // What the dead generation left running is settled from `deathEvidence` when the journal is + // next opened, so nothing about it is owed here. return withLease(record, { ...record.lease, runtimeFence: adjudication.nextFence, handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', unreconciled: false, lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: adjudication.evidence, - ...(settlementOwed - ? { - settlementRetryRequired: true, - settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication) - } - : {}) + deathEvidence: adjudication.evidence }) } - const stage: AgentSessionHandoffStage = - adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage return withLease(record, { ...record.lease, - handoffStage: stage, - claimStatus: - adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + handoffStage: adjudication.stage, unreconciled: false, lastRenewedAt: args.now }) diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts index 38b65ef6249..1a4c78e4672 100644 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -1,5 +1,5 @@ import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' import { AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -11,11 +11,26 @@ import { } from './agent-session-record-store-file' import { withFileTransactionLock } from '../file-transaction-lock' +/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build + * writes back from carrying a stale latch to an older build after a downgrade. */ +type RetiredAgentSessionLeaseFields = { + processlessAt?: unknown + settlementRetryRequired?: unknown + settlementRetryId?: unknown +} + function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { for (const [sessionId, record] of state.records) { + const lease: AgentSessionLease & RetiredAgentSessionLeaseFields = record.lease + const { + processlessAt: _processlessAt, + settlementRetryRequired: _settlementRetryRequired, + settlementRetryId: _settlementRetryId, + ...current + } = lease state.records.set(sessionId, { ...record, - lease: { ...record.lease, unreconciled: true } + lease: { ...current, unreconciled: true } }) } } diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index b7851709586..5c57c18ba41 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -31,7 +31,8 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { now: number /** Exit receipt can precede a delayed journal settlement and lease release. */ exitObservedAt?: number - settlementRetry?: { settlementId: string; detail: string } + /** Why the provider exited, when the host saw it die on its own. */ + exitReason?: string }): AgentSessionRecord { const { record } = args assertFence(record.lease, args.expectedFence) @@ -43,15 +44,12 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { runtimeFence: nextAgentSessionFence(record.lease), ownerProcess: null, reservedSpawnToken: null, - processlessAt: null, claimStatus: 'released', - handoffStage: args.settlementRetry ? 'recovering' : null, - settlementRetryRequired: args.settlementRetry ? true : undefined, - settlementRetryId: args.settlementRetry?.settlementId, + handoffStage: null, lastRenewedAt: args.now, deathEvidence: { kind: 'exit-observed', - detail: args.settlementRetry?.detail ?? 'the last surface holding this session released it', + detail: args.exitReason ?? 'the last surface holding this session released it', observedAt: args.exitObservedAt ?? args.now } }) @@ -65,7 +63,7 @@ export function releaseStoredAgentSessionOwnerAfterSurfaceClose( expectedFence: number now: number exitObservedAt?: number - settlementRetry?: { settlementId: string; detail: string } + exitReason?: string } ): Promise { return store.transitionHandoff(args.sessionId, (record) => diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 6c2555e9c76..16a0844f621 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -452,6 +452,25 @@ describe('a structured Claude session over agentSession.*', () => { claude.setSelfExit(null) }) + it('releases a failed start that recorded no owner without claiming it exited', async () => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + // The root was never seen to exit, so nothing proves this start's process gone. + exitVerdict: { root: 'live', tree: 'unverifiable' } + }) + + await call('agentSession.create', createIntentParams()) + claude.setSelfExit(null) + + // The adapter closed the stdio of what it spawned, and no owner was recorded to stop. The next + // start goes ahead; with no death evidence nothing reads the failed start as exited. + expect(leaseOf(SESSION)).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: null + }) + }) + it.each(['unverifiable', 'live'] as const)( 'reopens a chat whose stop saw the Claude root exit with its tree %s', async (tree) => { diff --git a/src/main/runtime/structured-agent-session-owner-probe.ts b/src/main/runtime/structured-agent-session-owner-probe.ts index 47f92a08ca6..d42959bcd28 100644 --- a/src/main/runtime/structured-agent-session-owner-probe.ts +++ b/src/main/runtime/structured-agent-session-owner-probe.ts @@ -10,8 +10,7 @@ import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-re /** * The lease's only source of truth about a previous owner. Everything it cannot - * answer PID-reuse-safely reports `indeterminate`. An exact owner stays fenced in `recovering`; - * an ownerless, unattributable reservation enters `manual-recovery`. + * answer PID-reuse-safely reports `indeterminate`, and recovery resolution concludes from there. */ export function createStructuredAgentSessionOwnerProbe( hostId: string, @@ -21,9 +20,6 @@ export function createStructuredAgentSessionOwnerProbe( return async (record) => { const owner = record.lease.ownerProcess if (!owner) { - if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { - return { outcome: 'reservation-unused' } - } const spawnToken = record.lease.reservedSpawnToken if (spawnToken === null) { if (record.lease.claimStatus === 'reserved') { diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 6b612c73d46..7749fc3c25b 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -191,17 +191,6 @@ describe('structured agent-session owner probe', () => { expect(result.outcome).toBe('indeterminate') }) - - it('releases only a reservation carrying durable pre-spawn proof', async () => { - const probe = deadProbe() - const result = await createStructuredAgentSessionOwnerProbe( - HOST_ID, - probe - )(record(null, { processlessAt: 1_800_000_000_000, claimStatus: 'reserved' })) - - expect(probe).not.toHaveBeenCalled() - expect(result).toEqual({ outcome: 'reservation-unused' }) - }) }) describe('structured agent-session runtime install', () => { diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index b0cb51eb183..71854f903cd 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -63,9 +63,6 @@ export function structuredClaudeLifecycleEvent( cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, - ...(event.settlementRetryRequired - ? { settlementRetryRequired: event.settlementRetryRequired } - : {}), ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) } } diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index ff1020ece74..2216782b5e5 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -136,19 +136,17 @@ describe('acquisition compare-and-swap', () => { }) it('keeps a conflicted claim conflicted regardless of proof', () => { + // Restart adjudication and recovery resolution are what retire it, once its owner is gone. expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ decision: 'refused', code: 'agent_session_conflict' }) }) - it.each([ - ['recovering', 'agent_session_ownership_unknown'], - ['manual-recovery', 'agent_session_ownership_unknown'] - ] as const)('refuses acquisition in stage %s', (handoffStage, code) => { - expect(acquire(lease({ handoffStage }), { outcome: 'pid-absent' })).toEqual({ + it('refuses acquisition in the recovering stage', () => { + expect(acquire(lease({ handoffStage: 'recovering' }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code + code: 'agent_session_ownership_unknown' }) }) @@ -190,7 +188,7 @@ describe('restart reconciliation', () => { probe: MATCHED, observedAt: 9_000 }) - ).toMatchObject({ disposition: 'conflicted' }) + ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) }) it('routes a surviving native owner to recovery instead of readopting a dead transport', () => { @@ -218,9 +216,7 @@ describe('restart reconciliation', () => { }) }) - it('keeps re-asking about an unverifiable owner instead of evicting it', () => { - // A recorded exact identity can still be probed later; manual recovery is reserved - // for leases that name no process at all. + it('hands an unverifiable owner to recovery resolution instead of evicting it', () => { expect( adjudicateAgentSessionRestart({ lease: lease({ leaseDeadlineAt: 1 }), @@ -230,24 +226,21 @@ describe('restart reconciliation', () => { ).toEqual({ disposition: 'recovering', stage: 'recovering', reason: 'no answer' }) }) - it('keeps a pre-restart conflict conflicted while its owner cannot be proven gone', () => { + it('re-adjudicates a claim an older record marked conflicted by the owner it names', () => { expect( adjudicateAgentSessionRestart({ lease: lease({ claimStatus: 'conflicted' }), probe: INDETERMINATE, observedAt: 9_000 }) - ).toEqual({ disposition: 'conflicted', reason: 'claim conflicted before restart' }) - }) - - it('keeps a conflict conflicted when it names no process to prove anything about', () => { + ).toEqual({ disposition: 'recovering', stage: 'recovering', reason: 'no answer' }) expect( adjudicateAgentSessionRestart({ lease: lease({ claimStatus: 'conflicted', ownerProcess: null }), - probe: { outcome: 'pid-absent' }, + probe: INDETERMINATE, observedAt: 9_000 }) - ).toEqual({ disposition: 'conflicted', reason: 'claim conflicted before restart' }) + ).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null }) }) it('frees a conflict whose named owner is proven gone', () => { @@ -283,31 +276,28 @@ describe('restart reconciliation', () => { ).toEqual({ disposition: 'free', reason: 'lease has no owner and no reservation' }) }) - it('does not infer an ownerless native reservation is unused from restart alone', () => { - const reserved = lease({ ownerProcess: null, claimStatus: 'reserved' }) - expect( - adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) - ).toEqual({ - disposition: 'recovering', - stage: 'manual-recovery', - reason: 'reservation with no proven process' - }) - }) - - it('frees an ownerless reservation only when a probe proves nothing ever spawned', () => { - // A child can outlive the runtime that reserved it, so absence needs proof. - const reserved = lease({ ownerProcess: null, claimStatus: 'reserved' }) - expect( - adjudicateAgentSessionRestart({ - lease: reserved, - probe: { outcome: 'reservation-unused' }, - observedAt: 9_000 + it.each([null, 'recovering'] as const)( + 'releases an ownerless reservation at stage %s, with evidence only when a scan proved nothing spawned', + (handoffStage) => { + // A child spawned before its identity was recorded lost its stdio with the runtime that + // crashed, and a token scan is the only proof there can be. + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage }) + expect( + adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) + ).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null }) + expect( + adjudicateAgentSessionRestart({ + lease: reserved, + probe: { outcome: 'reservation-unused' }, + observedAt: 9_000 + }) + ).toEqual({ + disposition: 'evicted', + nextFence: 8, + evidence: { kind: 'pid-absent', detail: 'reservation never spawned', observedAt: 9_000 } }) - ).toMatchObject({ disposition: 'evicted', nextFence: 8 }) - expect( - adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) - ).toMatchObject({ disposition: 'recovering', stage: 'manual-recovery' }) - }) + } + ) }) describe('writer admission and orphan spawn tokens', () => { diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 6c1d07820ad..ecdb1cecc3a 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -1,10 +1,10 @@ /** * Single-writer lease adjudication. * - * Every decision here fails closed: expiry alone never grants a second owner, an unverifiable - * process counts as possibly alive, and a stage that cannot prove an owner keeps re-asking — - * or, when it names no process at all, ends in manual recovery — rather than handing the - * session to the other runtime. This is the opposite polarity + * Every decision here fails closed: expiry alone never grants a second owner, and an + * unverifiable process counts as possibly alive until recovery resolution concludes about it. A + * lease that names no process at all is released: nothing was recorded that could be holding it. + * This is the opposite polarity * from daemon adoption checks, which fail open on a missing start time — a wrong answer there * refuses an adoption, a wrong answer here creates two writers on one provider session. */ @@ -47,20 +47,11 @@ export type AgentSessionAcquisitionDecision = | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } export type AgentSessionRestartAdjudication = - /** A journal settlement latch survives restart without changing its handoff stage. */ - | { disposition: 'settlement-pending' } /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ | { disposition: 'free'; reason: string } - | { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence } + /** `evidence` is null when nothing proved the owner gone: it was never recorded. */ + | { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence | null } | { disposition: 'recovering'; stage: AgentSessionHandoffStage; reason: string } - | { disposition: 'conflicted'; reason: string } - -export function agentSessionRestartEvictionSettlementId( - lease: Pick, - eviction: Extract -): string { - return `restart-eviction:${lease.sessionId}:${eviction.nextFence}` -} export function isProvenDeadProbe(probe: AgentSessionOwnerProbe): boolean { return ( @@ -94,7 +85,8 @@ function deathEvidenceFor( return null } -/** The store releases a lease only on proven exit or eviction; anything held or mid-handoff may run. */ +/** `exited` only for a lease released on death evidence; one recovery released without proof, like + * anything held or mid-handoff, may still be running. */ export function agentSessionLeaseOwnerVerdict(lease: AgentSessionLease): AgentSessionOwnerVerdict { if (agentSessionLeaseAdmitsWriter(lease)) { return 'live' @@ -102,11 +94,17 @@ export function agentSessionLeaseOwnerVerdict(lease: AgentSessionLease): AgentSe return lease.claimStatus === 'released' && lease.handoffStage === null && lease.ownerProcess === null && - lease.reservedSpawnToken === null + lease.reservedSpawnToken === null && + lease.deathEvidence !== null ? 'exited' : 'unverifiable' } +/** Nothing holds this lease: released, no handoff in flight, and reconciled since the last restart. */ +export function agentSessionLeaseIsReleased(lease: AgentSessionLease): boolean { + return !lease.unreconciled && lease.claimStatus === 'released' && lease.handoffStage === null +} + /** True when the recorded owner may write right now. Used by every mutating path in later parts. */ export function agentSessionLeaseAdmitsWriter(lease: AgentSessionLease): boolean { return ( @@ -139,10 +137,11 @@ export function evaluateAgentSessionAcquisition(args: { return { decision: 'refused', code: 'agent_session_checkpoint_stale' } } if (lease.claimStatus === 'conflicted') { + // Why: the user's own terminal agent; restart adjudication and recovery retire it once gone. return { decision: 'refused', code: 'agent_session_conflict' } } - if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { - // Why: no stage expires into an owner; recovery is resolved by proof or by the user. + if (lease.handoffStage === 'recovering') { + // Why: no stage expires into an owner; recovery resolution concludes about it first. return { decision: 'refused', code: 'agent_session_ownership_unknown' } } if (lease.handoffStage !== null && lease.handoffOperationId !== null) { @@ -190,44 +189,25 @@ export function adjudicateAgentSessionRestart(args: { observedAt: number }): AgentSessionRestartAdjudication { const { lease, probe, observedAt } = args - if (lease.claimStatus === 'conflicted') { - const conflictedOwnerDeath = - lease.ownerProcess === null ? null : deathEvidenceFor(probe, observedAt) - if (conflictedOwnerDeath) { - // Why: the conflict names one specific process. Present-time proof that THAT process is gone - // leaves no claimant to protect, and a conflict with no exit is a session the user can never - // open again. Without such proof the conflict still outlives the process that observed it. - return { - disposition: 'evicted', - nextFence: nextAgentSessionFence(lease), - evidence: conflictedOwnerDeath - } - } - return { disposition: 'conflicted', reason: 'claim conflicted before restart' } - } if (lease.ownerProcess === null) { - if (lease.settlementRetryRequired) { - // A watched provider death can leave terminal rows unsettled. This latch is not owner - // uncertainty and must survive restart until the journal settlement is durably accepted. - return { disposition: 'settlement-pending' } - } - if (lease.reservedSpawnToken === null && lease.claimStatus !== 'reserved') { + if (lease.reservedSpawnToken === null && lease.claimStatus === 'released') { // Why: the spawn token is minted before the child and is the only thing a child could be // carrying. With no owner and no token nothing can hold this lease, so it is already free — // treating it as an unproven reservation is what re-latches every released record on restart. return { disposition: 'free', reason: 'lease has no owner and no reservation' } } - if (probe.outcome === 'reservation-unused') { - return { - disposition: 'evicted', - nextFence: nextAgentSessionFence(lease), - evidence: { kind: 'pid-absent', detail: 'reservation never spawned', observedAt } - } - } + // Why: a child spawned before its identity was recorded lost its stdio with the runtime that + // crashed, so nothing can drive it. A token scan that proves no child is the only evidence there + // can be; without it the lease is released anyway. A live child still carrying the token is + // not signalled here, and the orphan reaper, which runs once at store open, may have seen this + // lease still claiming it. return { - disposition: 'recovering', - stage: 'manual-recovery', - reason: 'reservation with no proven process' + disposition: 'evicted', + nextFence: nextAgentSessionFence(lease), + evidence: + probe.outcome === 'reservation-unused' + ? { kind: 'pid-absent', detail: 'reservation never spawned', observedAt } + : null } } if (isProvenAliveProbe(probe)) { @@ -244,8 +224,7 @@ export function adjudicateAgentSessionRestart(args: { return { disposition: 'evicted', nextFence: nextAgentSessionFence(lease), evidence } } return { - // Why: an exact recorded identity can still be probed later, so the system keeps - // re-asking; only a record naming nobody (above) needs the user to decide. + // Why: recovery resolution, which runs next, owns the verdict on a recorded identity. disposition: 'recovering', stage: 'recovering', reason: diff --git a/src/shared/agent-session-legacy-handoff-lease.test.ts b/src/shared/agent-session-legacy-handoff-lease.test.ts index b38c411fc6d..49a10558b81 100644 --- a/src/shared/agent-session-legacy-handoff-lease.test.ts +++ b/src/shared/agent-session-legacy-handoff-lease.test.ts @@ -3,6 +3,7 @@ import { leaseCarriesLegacyHandoffValues, normalizeLegacyHandoffLease, normalizeLegacyHandoffRecord, + terminalOwnerRefusalMessage, type PersistedAgentSessionLease } from './agent-session-legacy-handoff-lease' import type { AgentSessionClaimStatus, AgentSessionHandoffStage } from './agent-session-record' @@ -12,12 +13,7 @@ import { } from './agent-session-record.test-fixture' const CLAIMS: AgentSessionClaimStatus[] = ['reserved', 'live', 'conflicted', 'released'] -const STAGES: (AgentSessionHandoffStage | null)[] = [ - null, - 'new-owner-proving', - 'recovering', - 'manual-recovery' -] +const STAGES: (AgentSessionHandoffStage | null)[] = [null, 'new-owner-proving', 'recovering'] function persisted(overrides: Partial): PersistedAgentSessionLease { return { ...agentSessionLeaseFixture(), ...overrides } @@ -36,7 +32,7 @@ describe('normalizing a lease the removed terminal handoff wrote', () => { } }) - it.each(['preparing', 'old-owner-stopped'] as const)( + it.each(['preparing', 'old-owner-stopped', 'manual-recovery'] as const)( 'maps the %s stage to recovering and keeps its operation id', (handoffStage) => { const lease = persisted({ handoffStage, handoffOperationId: 'op-handoff' }) @@ -85,3 +81,24 @@ describe('normalizing a lease the removed terminal handoff wrote', () => { }) }) }) + +describe('the refusal for a chat a terminal agent holds', () => { + const owner = { hostId: 'local', pid: 4242, spawnToken: 'token' } + + it('names the process only when its start time can tell it from a reused pid', () => { + const verifiable = agentSessionLeaseFixture({ + claimStatus: 'conflicted', + ownerProcess: { ...owner, processStartTimeMs: 1_000 } + }) + const reusable = agentSessionLeaseFixture({ + claimStatus: 'conflicted', + ownerProcess: { ...owner, processStartTimeMs: null } + }) + expect(terminalOwnerRefusalMessage(verifiable)).toBe( + 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' + ) + expect(terminalOwnerRefusalMessage(reusable)).toBe( + 'This chat is still open in a terminal agent. Quit that agent to continue the chat here.' + ) + }) +}) diff --git a/src/shared/agent-session-legacy-handoff-lease.ts b/src/shared/agent-session-legacy-handoff-lease.ts index af2216d0ab3..ea4ef2909e8 100644 --- a/src/shared/agent-session-legacy-handoff-lease.ts +++ b/src/shared/agent-session-legacy-handoff-lease.ts @@ -1,9 +1,10 @@ /** - * Lease values only the removed terminal handoff wrote. + * Lease values only older builds wrote. * - * Records an older build persisted can still carry a terminal owner (`runtimeKind: 'tui'`) or a - * handoff stage (`preparing`, `old-owner-stopped`). They are accepted on disk and mapped here, once, - * at decode, so no in-memory lease holds a value nothing in this build produces. + * Records an older build persisted can still carry a terminal owner (`runtimeKind: 'tui'`), a + * handoff stage (`preparing`, `old-owner-stopped`), or the removed ownerless-reservation latch + * (`manual-recovery`). They are accepted on disk and mapped here, once, at decode, so no in-memory + * lease holds a value nothing in this build produces. */ import type { @@ -14,7 +15,7 @@ import type { } from './agent-session-record' type LegacyHandoffRuntimeKind = 'tui' -type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' +type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' | 'manual-recovery' export type PersistedAgentSessionRuntimeKind = | AgentSessionOwnerRuntimeKind @@ -49,21 +50,21 @@ export function isPersistedAgentSessionHandoffStage( ) } +function isLegacyHandoffStage( + stage: PersistedAgentSessionHandoffStage | null +): stage is LegacyHandoffStage { + return stage === 'preparing' || stage === 'old-owner-stopped' || stage === 'manual-recovery' +} + export function leaseCarriesLegacyHandoffValues(lease: PersistedAgentSessionLease): boolean { - return ( - lease.runtimeKind === 'tui' || - lease.handoffStage === 'preparing' || - lease.handoffStage === 'old-owner-stopped' - ) + return lease.runtimeKind === 'tui' || isLegacyHandoffStage(lease.handoffStage) } /** Identity for every lease this build writes. */ export function normalizeLegacyHandoffLease(lease: PersistedAgentSessionLease): AgentSessionLease { const { runtimeKind, handoffStage } = lease - const stage = - handoffStage === 'preparing' || handoffStage === 'old-owner-stopped' - ? 'recovering' - : handoffStage + // Why: every one of these awaited proof about an owner, which is what `recovering` resolves. + const stage = isLegacyHandoffStage(handoffStage) ? 'recovering' : handoffStage if (runtimeKind === 'native') { return { ...lease, runtimeKind, handoffStage: stage } } @@ -77,6 +78,14 @@ export function normalizeLegacyHandoffLease(lease: PersistedAgentSessionLease): } } +/** A `conflicted` claim is a terminal agent an older build recorded, and only its exit frees the chat. */ +export function terminalOwnerRefusalMessage(lease: AgentSessionLease): string { + // Why: without a start time the pid may since belong to an unrelated process. + const owner = lease.ownerProcess + const process = owner?.processStartTimeMs != null ? ` (process ${owner.pid})` : '' + return `This chat is still open in a terminal agent${process}. Quit that agent to continue the chat here.` +} + /** The in-memory record, plus whether decode changed anything the store must write back. */ export function normalizeLegacyHandoffRecord(record: PersistedAgentSessionRecord): { record: AgentSessionRecord diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index fe439c20b08..b591d82af2e 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -13,6 +13,7 @@ import type { } from './agent-session-operation-ledger' import { agentSessionLeaseAdmitsWriter } from './agent-session-lease-adjudication' import type { AgentSessionLease } from './agent-session-record' +import { terminalOwnerRefusalMessage } from './agent-session-legacy-handoff-lease' import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' /** @@ -132,9 +133,11 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR return { code: 'agent_session_conflict', message: - lease.handoffStage === 'new-owner-proving' - ? 'The chat is still starting.' - : "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + lease.claimStatus === 'conflicted' + ? terminalOwnerRefusalMessage(lease) + : lease.handoffStage === 'new-owner-proving' + ? 'The chat is still starting.' + : "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." } } return { diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 765f8e51142..18d0f18754b 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -257,15 +257,29 @@ export function evaluateAgentSessionOperation(args: { } return { decision: 'admit', - row: { - callerKey, - operationId, - fingerprint, - operationTimestamp, - recordedAt: now, - expiresAt: agentSessionOperationExpiry(operationTimestamp, now), - outcome: { status: 'pending' } - } + row: pendingAgentSessionOperationRow({ callerKey, operationId, fingerprint, now }) + } +} + +/** A `pending` row for this id, retained for the full replay window from `now`. */ +export function pendingAgentSessionOperationRow(args: { + callerKey: string + operationId: string + fingerprint: string + now: number +}): AgentSessionOperationRow { + const operationTimestamp = parseAgentSessionOperationTimestamp(args.operationId) + if (operationTimestamp === null) { + throw new Error('agent_session_operation_invalid') + } + return { + callerKey: args.callerKey, + operationId: args.operationId, + fingerprint: args.fingerprint, + operationTimestamp, + recordedAt: args.now, + expiresAt: agentSessionOperationExpiry(operationTimestamp, args.now), + outcome: { status: 'pending' } } } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 0be26cb40f9..2b85695a845 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -60,13 +60,12 @@ export type AgentSessionLaunchArgs = string[] * away at decode (agent-session-legacy-handoff-lease). */ export type AgentSessionOwnerRuntimeKind = 'native' -/** The acquisition stage. The removed terminal handoff's stages are mapped away at decode. */ -export type AgentSessionHandoffStage = 'new-owner-proving' | 'recovering' | 'manual-recovery' +/** The acquisition stage. Stages only older builds wrote are mapped away at decode. */ +export type AgentSessionHandoffStage = 'new-owner-proving' | 'recovering' /** * PID-reuse-safe process identity. `spawnToken` is the only element available on every platform: - * process start time costs a CIM query on Windows and is absent in some containers. An exact - * identity stays in `recovering`; an ownerless, unattributable reservation uses `manual-recovery`. + * process start time costs a CIM query on Windows and is absent in some containers. */ export type AgentSessionProcessIdentity = { hostId: string @@ -78,9 +77,9 @@ export type AgentSessionProcessIdentity = { export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number } /** - * Mirrors the in-memory claim registry's reserved / live / conflicted states so a conflict - * survives a restart. `released` has no registry equivalent: the registry expresses "no owner" by - * deleting the entry, and a durable record that outlives its owner needs a name for that. + * `released` means no owner: a durable record that outlives its owner needs a name for that. + * `conflicted` is how a terminal owner an older build recorded loads: recovery waits it out and + * never stops it, because it is the user's own agent. */ export type AgentSessionClaimStatus = 'reserved' | 'live' | 'conflicted' | 'released' @@ -102,8 +101,6 @@ export type AgentSessionLease = { ownerProcess: AgentSessionProcessIdentity | null /** Reserved before any process exists, then matched against the child's environment. */ reservedSpawnToken: string | null - /** Set only when acquisition failed before any spawn attempt. */ - processlessAt?: number | null leaseDeadlineAt: number lastRenewedAt: number handoffOperationId: string | null @@ -120,11 +117,8 @@ export type AgentSessionLease = { * so rewriting it would invalidate the record it is trying to save. */ minimumNextFence?: number + /** Null on a released lease when nothing proved its owner gone. */ deathEvidence: AgentSessionDeathEvidence | null - /** A positively observed provider exit whose terminal journal settlement still needs retry. */ - settlementRetryRequired?: boolean - /** Stable lifecycle batch id used when retrying the terminal settlement. */ - settlementRetryId?: string } export type AgentSessionRecord = { @@ -307,9 +301,6 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe (lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) && (lease.reservedSpawnToken === null || isBoundedString(lease.reservedSpawnToken, MAX_ID_LENGTH)) && - (lease.processlessAt === undefined || - lease.processlessAt === null || - (Number.isSafeInteger(lease.processlessAt) && (lease.processlessAt as number) >= 0)) && Number.isSafeInteger(lease.leaseDeadlineAt) && Number.isSafeInteger(lease.lastRenewedAt) && (lease.handoffOperationId === null || @@ -322,10 +313,6 @@ function isPersistedAgentSessionLease(value: unknown): value is PersistedAgentSe lease.claimStatus === 'conflicted' || lease.claimStatus === 'released') && typeof lease.unreconciled === 'boolean' && - (lease.settlementRetryRequired === undefined || - typeof lease.settlementRetryRequired === 'boolean') && - (lease.settlementRetryId === undefined || - isBoundedString(lease.settlementRetryId, MAX_ID_LENGTH)) && (lease.deathEvidence === null || isAgentSessionDeathEvidence(lease.deathEvidence)) ) } diff --git a/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts new file mode 100644 index 00000000000..80626f79ae3 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { resolveStructuredSessionRecovery } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution' +import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// The last release before recovery released an owner it could not prove gone. +const BASELINE_REF = 'v1.4.211' +const SESSION = 'session-unproven' +const NOW = 1_800_000_000_000 +const LOCATION = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' as const +} + +function reserveRequest(expectedFence: number | null, spawnToken: string, operation: number) { + return { + sessionId: SESSION, + location: LOCATION, + provider: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: '/tmp/codex' }, + // Older builds still read the owner kind from the request. + runtimeKind: 'native' as const, + expectedFence, + spawnToken, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' as const }, + operation: { + callerKey: 'test', + operationId: `${NOW}-${String(operation).padStart(32, '0')}`, + fingerprint: 'create' + }, + now: NOW + } +} + +test('an older build loads, and starts over, a lease released with no death evidence', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-unproven-release-downgrade-')) + try { + // This build: an owner whose identity can never be verified is released, with no evidence. + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reserved = await store.reserveOwner(reserveRequest(null, 'spawn-a', 1)) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, + now: NOW + }) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: 'recovering' } + })) + await expect( + resolveStructuredSessionRecovery( + { + store, + probeRecord: async () => ({ outcome: 'indeterminate', reason: 'no start time' }), + now: () => NOW + }, + SESSION + ) + ).resolves.toBe('resolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: null + }) + + // The older build reads exactly what this one wrote. + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-record-store.ts' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read/reconcile/reserve members called below; a missing one fails the test. + const OldStore = baseline.AgentSessionRecordStore as { + open: (args: { directory: string; hostId: string }) => Promise + } + const old = await OldStore.open({ directory, hostId: 'local' }) + expect(old.isSessionUnreadable(SESSION)).toBe(false) + expect(old.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + deathEvidence: null + }) + await old.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), + now: NOW + 1 + }) + const released = old.getRecord(SESSION)?.lease + expect(released).toMatchObject({ claimStatus: 'released', handoffStage: null }) + // And it can start a new owner over it. + const restarted = await old.reserveOwner( + reserveRequest(released?.runtimeFence ?? null, 'spawn-b', 2) + ) + expect(restarted.record.lease).toMatchObject({ claimStatus: 'reserved' }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) From f6eab381ce3602d95546877d94d038ac6988115f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 21:22:29 -0700 Subject: [PATCH 039/387] fix(types): describe command environments independently of Expo globals (#23073) Co-authored-by: m4air --- src/shared/command-environment.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/command-environment.ts b/src/shared/command-environment.ts index 6a1f6d337a2..971e0fc61b9 100644 --- a/src/shared/command-environment.ts +++ b/src/shared/command-environment.ts @@ -23,10 +23,10 @@ export function extractLeadingEnvAssignments(tokens: string[]): { } export function mergeCommandEnvironment( - base: NodeJS.ProcessEnv | undefined, + base: Record | undefined, overrides: Record | undefined, platform: NodeJS.Platform -): NodeJS.ProcessEnv | undefined { +): Record | undefined { if (!overrides) { return base } From e13631ee533dd1fea34c0e644c53e4e803b0b768 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:31:25 -0700 Subject: [PATCH 040/387] Prioritize workspace opening over replacement checkout preparation (#23013) * Prioritize workspace opening over replacement checkout preparation * Preserve Git hook semantics and exercise preparation edge cases --- ...rktree-create-preparation-real-git.test.ts | 138 ++++++- src/main/ipc/worktree-remote.ts | 6 +- .../runtime-local-git-worktree-create.ts | 3 + .../runtime-local-worktree-create.test.ts | 44 ++- ...tree-create-preparation-contention.test.ts | 369 ++++++++++++++++++ src/main/worktree-create-preparation-pool.ts | 89 ++++- ...worktree-create-preparation-timing.test.ts | 168 ++++++++ src/main/worktree-create-preparation.test.ts | 9 +- src/main/worktree-create-preparation.ts | 130 +++--- src/shared/git-binary-compatibility.test.ts | 6 +- 10 files changed, 892 insertions(+), 70 deletions(-) create mode 100644 src/main/worktree-create-preparation-contention.test.ts create mode 100644 src/main/worktree-create-preparation-timing.test.ts diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index 19c023a2403..08795fcab1f 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -55,6 +55,137 @@ afterEach(async () => { }) describe('prepared worktree creation with real Git', () => { + it.each([false, true])( + 'attaches the prepared HEAD and runs the hook (base advanced: %s)', + async (advanceBase) => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared checkout') + const finalPath = join(root, 'final checkout') + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile( + join(hooksPath, 'post-checkout'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" >> checkout-hook.txt\ngit symbolic-ref --short HEAD >> checkout-hook.txt\n', + { mode: 0o755 } + ) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + git(repoPath, ['config', 'branch.autoSetupMerge', 'always']) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('attach-with-hook') + ) + expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false) + if (advanceBase) { + await writeFile(join(repoPath, 'version.txt'), 'advanced\n') + git(repoPath, ['commit', '--quiet', '-am', 'advance base']) + } + const targetHead = git(repoPath, ['rev-parse', 'HEAD']) + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/attached', 'main') + + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(targetHead) + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/attached') + expect( + git(finalPath, ['for-each-ref', '--format=%(upstream)', 'refs/heads/feature/attached']) + ).toBe('') + expect(await readFile(join(finalPath, 'checkout-hook.txt'), 'utf8')).toBe( + `${targetHead}\n${targetHead}\n1\nfeature/attached\n` + ) + await rm(join(finalPath, 'checkout-hook.txt')) + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe( + advanceBase ? 'advanced\n' : 'one\n' + ) + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + } + ) + + it('never publishes a branch at a HEAD changed before attachment', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-race') + const finalPath = join(root, 'final-race') + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('head-race') + ) + const expectedHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', '-b', 'other']) + await writeFile(join(repoPath, 'version.txt'), 'other\n') + git(repoPath, ['commit', '--quiet', '-am', 'other commit']) + const otherHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + + const original = gitRunner.gitExecFileAsync + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { + if (args.includes('checkout') || args.includes('switch')) { + git(finalPath, ['reset', '--hard', otherHead]) + } + return original(args, options) + }) + try { + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/race', 'main') + } finally { + spy.mockRestore() + } + expect(git(repoPath, ['rev-parse', 'main'])).toBe(expectedHead) + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(expectedHead) + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/race') + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n') + }) + + it('accepts a commit made by the post-checkout hook during attachment', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-hook-commit') + const finalPath = join(root, 'final-hook-commit') + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile( + join(hooksPath, 'post-checkout'), + '#!/bin/sh\nprintf "invoked\\n" >> hook-invocations.txt\ngit add hook-invocations.txt\ngit commit --quiet -m "hook commit"\n', + { mode: 0o755 } + ) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('hook-commit') + ) + const baseHead = git(repoPath, ['rev-parse', 'HEAD']) + + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-commit', 'main') + + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/hook-commit') + expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead) + expect(git(finalPath, ['show', '-s', '--format=%s', 'HEAD'])).toBe('hook commit') + expect(await readFile(join(finalPath, 'hook-invocations.txt'), 'utf8')).toBe('invoked\n') + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + }) + + it('cleans up a branch when post-checkout rejects the attachment', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-hook-failure') + const finalPath = join(root, 'final-hook-failure') + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('hook-failure') + ) + + await expect( + finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-failure', 'main') + ).rejects.toThrow() + expect(existsSync(finalPath)).toBe(false) + expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('') + }) + it('retains preparation ownership when the removal command cannot start', async () => { const fixture = await createRepo() const repoPath = await realpath(fixture.repoPath) @@ -65,7 +196,7 @@ describe('prepared worktree creation with real Git', () => { await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const original = gitRunner.gitExecFileAsync const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { - if (args.includes('remove') && args.includes(preparedPath)) { + if (args.includes('remove') && args.some((arg) => areWorktreePathsEqual(arg, preparedPath))) { return Promise.reject(new Error('injected removal launch failure')) } return original(args, options) @@ -113,7 +244,7 @@ describe('prepared worktree creation with real Git', () => { const spy = vi .spyOn(gitRunner, 'gitExecFileAsync') .mockImplementation(async (args, options) => { - if (args.includes('remove') && args.includes(stalePath)) { + if (args.includes('remove') && args.some((arg) => areWorktreePathsEqual(arg, stalePath))) { markRemovalStarted() await removalGate } @@ -145,7 +276,8 @@ describe('prepared worktree creation with real Git', () => { expect(existsSync(stalePath)).toBe(false) const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true }) expect(remaining).toHaveLength(2) - expect(remaining.map((w) => w.path)).toEqual(expect.arrayContaining([repoPath, finalPath])) + expect(remaining.some((w) => areWorktreePathsEqual(w.path, repoPath))).toBe(true) + expect(remaining.some((w) => areWorktreePathsEqual(w.path, finalPath))).toBe(true) expect(hasPendingStalePreparationCleanup()).toBe(false) } finally { releaseRemoval() diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index ec77b7e900f..4f066027efa 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -2741,7 +2741,8 @@ async function performLocalWorktreeCreate( branch: branchName, baseBranch, refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, - options: preparedWorktreeOptions + options: preparedWorktreeOptions, + timing }) timing.recordPreparedCheckout( prepared.status === 'hit' @@ -2754,6 +2755,9 @@ async function performLocalWorktreeCreate( rearm.fire = prepared.rearm return prepared.result } + if (prepared.rearm) { + rearm.fire = prepared.rearm + } } else { timing.recordPreparedCheckout({ status: 'miss', diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 36731ac3abb..ccefaa82255 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -152,6 +152,9 @@ export async function createRuntimeLocalGitWorktree(args: { }) : null // This path has no create-span recorder, so the miss reason is only observable on the IPC path. + if (preparedAttempt?.status === 'miss' && preparedAttempt.rearm) { + args.rearm.fire = preparedAttempt.rearm + } if (preparedAttempt?.status === 'hit') { addResult = preparedAttempt.result // Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 4ad713111f5..c673da99640 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' import type { Store } from '../persistence' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' @@ -32,7 +33,7 @@ const mocks = vi.hoisted(() => ({ resolveInclude: vi.fn<() => Promise>(), copyPaths: vi.fn<() => Promise>(), created: { - path: '/worktrees/app', + path: '', head: 'abc123', branch: 'app', isBare: false, @@ -82,6 +83,8 @@ vi.mock('../ipc/worktree-symlinks', () => ({ import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' +const worktreePath = resolve('/worktrees', 'app') + function createWorktree( request: Partial = {}, rearm: PreparationRearmHolder = { fire: () => {} } @@ -112,6 +115,7 @@ function createWorktree( beforeEach(() => { vi.resetAllMocks() + mocks.created.path = worktreePath mocks.routing.mockReturnValue({}) mocks.defaultBase.mockImplementation(async () => { expect(resolveGitAdmissionTier()).toBe('interactive') @@ -142,6 +146,36 @@ beforeEach(() => { }) describe('runtime prepared-worktree replenishment', () => { + it('keeps a failed claim reserved through the normal-add fallback', async () => { + mocks.consume.mockResolvedValue({ + status: 'miss', + reason: 'finalize_failed', + rearm: mocks.rearm + }) + const rearm: PreparationRearmHolder = { fire: () => {} } + await createWorktree({}, rearm) + + expect(mocks.add).toHaveBeenCalledOnce() + expect(mocks.rearm).not.toHaveBeenCalled() + rearm.fire() + expect(mocks.rearm).toHaveBeenCalledOnce() + }) + + it('keeps the failed claim release available when the fallback also fails', async () => { + mocks.consume.mockResolvedValue({ + status: 'miss', + reason: 'prepare_failed', + rearm: mocks.rearm + }) + mocks.add.mockRejectedValue(new Error('normal add failed')) + const rearm: PreparationRearmHolder = { fire: () => {} } + await expect(createWorktree({}, rearm)).rejects.toThrow('normal add failed') + + expect(mocks.rearm).not.toHaveBeenCalled() + rearm.fire() + expect(mocks.rearm).toHaveBeenCalledOnce() + }) + it('leaves the re-arm holder armed but unfired once probes and include copies finish', async () => { const rearm: PreparationRearmHolder = { fire: () => {} } let finishProbe!: (paths: string[]) => void @@ -207,8 +241,8 @@ describe('runtime create Git priority', () => { expect(mocks.remoteBase).toHaveBeenCalledWith('/repo', 'main', options) expect(mocks.hasBase).toHaveBeenCalledWith('/repo', 'main', options) expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ options })) - expect(mocks.pushTarget).toHaveBeenCalledWith('/worktrees/app', 'app', target, options) - expect(mocks.listing).toHaveBeenCalledWith('/repo', '/worktrees/app', 'app', options) + expect(mocks.pushTarget).toHaveBeenCalledWith(worktreePath, 'app', target, options) + expect(mocks.listing).toHaveBeenCalledWith('/repo', worktreePath, 'app', options) expect(mocks.resolveShared).toHaveBeenCalledWith('/repo', options) expect(mocks.resolveInclude).toHaveBeenCalledWith('/repo', options) } @@ -240,7 +274,7 @@ describe('runtime create Git priority', () => { } ) try { - await expect(createWorktree()).resolves.toHaveProperty('worktreePath', '/worktrees/app') + await expect(createWorktree()).resolves.toHaveProperty('worktreePath', worktreePath) expect(mocks.add).toHaveBeenCalledOnce() } finally { blocker.release() @@ -262,7 +296,7 @@ describe('runtime create Git priority', () => { expect(mocks.refresh).toHaveBeenCalledWith('/repo', base, options) expect(mocks.addSparse).toHaveBeenCalledWith( '/repo', - '/worktrees/app', + worktreePath, 'app', ['src'], 'origin/main', diff --git a/src/main/worktree-create-preparation-contention.test.ts b/src/main/worktree-create-preparation-contention.test.ts new file mode 100644 index 00000000000..9535d63cdee --- /dev/null +++ b/src/main/worktree-create-preparation-contention.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' +import type { AddWorktreeResult } from './git/worktree' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + listWorktreeGraph: vi.fn(), + prepare: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + computeWorkspaceRootAsync: vi.fn(), + getWorktreeOptions: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepare, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: vi.fn() +})) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('./git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('./ipc/worktree-logic', () => ({ + computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, + getWorktreePathSettings: () => ({ workspaceDir: '/workspace', nestWorkspaces: false }) +})) + +import { + _resetWorktreeCreatePreparationsForTests, + consumePreparedWorktreeCreate, + hasPendingWorktreeCreatePreparations, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' +import { + listPreparations, + releasePreparationClaim, + startPreparation, + takePreparation +} from './worktree-create-preparation-pool' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 0 +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The tested path reads only getSettings from Store. +const store = { getSettings: () => ({}) } as unknown as Store +const flushBackgroundWork = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +function consume(baseBranch = 'origin/main') { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/new-worktree', + branch: 'feature/new-worktree', + baseBranch + }) +} + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.prepare.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.computeWorkspaceRootAsync.mockReset().mockResolvedValue('/workspace') + mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.resolveBaseRef + .mockReset() + .mockImplementation(async (_path: string, base: string) => + base === 'main' + ? 'refs/heads/main' + : base === 'other/main' + ? 'refs/remotes/other/main' + : 'refs/remotes/origin/main' + ) + mocks.measureDivergence.mockReset().mockResolvedValue('within') +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() +}) + +describe('claimed worktree preparation', () => { + it('defers prefetch through checkout, finalization, and the remaining create work', async () => { + const checkout = Promise.withResolvers() + const checkoutStarted = Promise.withResolvers() + const finalize = Promise.withResolvers() + const finalizeStarted = Promise.withResolvers() + mocks.prepare.mockImplementationOnce(() => { + checkoutStarted.resolve() + return checkout.promise + }) + mocks.finalize.mockImplementationOnce(() => { + finalizeStarted.resolve() + return finalize.promise + }) + const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await checkoutStarted.promise + const create = consume() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + checkout.resolve() + await preparation + await finalizeStarted.promise + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + finalize.resolve({}) + const result = await create + expect(result.status).toBe('hit') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + result.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) + + it('coalesces mid-create prefetches and releases once at create completion', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume() + expect(result.status).toBe('hit') + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + + await Promise.all([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main'), + prepareWorktreeCreateForRepo(store, repo, 'origin/main') + ]) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + if (result.status === 'hit') { + result.rearm() + result.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + }) + + it('does not let two creates claim the same prepared checkout', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const [first, second] = await Promise.all([consume(), consume()]) + + expect([first.status, second.status].sort()).toEqual(['hit', 'miss']) + expect(mocks.finalize).toHaveBeenCalledOnce() + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + first.rearm?.() + second.rearm?.() + expect(hasPendingWorktreeCreatePreparations()).toBe(false) + }) + + it('keeps an explicit prefetch ahead of an automatic replacement for the same key', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0] + if (!entry) { + throw new Error('expected a prepared checkout') + } + const claim = takePreparation(entry) + const base = { + repoPath: repo.path, + workspaceRoot: '/workspace', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main' + } + await startPreparation({ ...base, options: {} }, 'automatic') + await startPreparation({ ...base, options: { admissionTier: 'background' } }) + await startPreparation({ ...base, options: {} }, 'automatic') + + const released = releasePreparationClaim(claim) + expect(released.pendingPreparations).toEqual([ + { + kind: 'explicit', + args: { ...base, options: { admissionTier: 'background' } } + } + ]) + expect(releasePreparationClaim(claim)).toEqual({ released: false, pendingPreparations: [] }) + }) + + it('allows a fresh prefetch after an isolated create completes', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume() + expect(result.status).toBe('hit') + if (result.status === 'hit') { + result.rearm() + } + expect(hasPendingWorktreeCreatePreparations()).toBe(false) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) + + it('does not repeat a burst replacement when release runs twice', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const first = await consume() + if (first.status === 'hit') { + first.rearm() + } + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const second = await consume() + expect(second.status).toBe('hit') + if (second.status === 'hit') { + second.rearm() + second.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + }) + + it('reserves both the prepared and requested canonical bases on a retarget', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume('main') + expect(result).toMatchObject({ status: 'hit', retargeted: true }) + + await prepareWorktreeCreateForRepo(store, repo, 'main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + if (result.status === 'hit') { + result.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + expect(mocks.prepare.mock.calls[1]?.[2]).toBe('refs/heads/main') + }) + + it('preserves distinct prefetch bases while a retargeted create finishes', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume('main') + expect(result.status).toBe('hit') + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + if (result.status === 'hit') { + result.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + }) + + it('passes a pending prefetch to another create claiming the same requested base', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'other/main') + const first = await consume('main') + const second = await consume('main') + expect(first.status).toBe('hit') + expect(second.status).toBe('hit') + + await prepareWorktreeCreateForRepo(store, repo, 'main') + expect(mocks.prepare).toHaveBeenCalledTimes(2) + if (second.status === 'hit') { + second.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + if (first.status === 'hit') { + first.rearm() + } + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + }) + + it('keeps a burst replacement when the remaining claim is isolated', async () => { + let timestamp = 1_000 + const now = vi.spyOn(Date, 'now').mockImplementation(() => timestamp++) + try { + await prepareWorktreeCreateForRepo(store, repo, 'other/main') + const seed = await consume('other/main') + expect(seed.status).toBe('hit') + seed.rearm?.() + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'other/main') + const burst = await consume('main') + const isolated = await consume('main') + expect(burst).toMatchObject({ status: 'hit', retargeted: true }) + expect(isolated).toMatchObject({ status: 'hit', retargeted: true }) + expect(mocks.prepare).toHaveBeenCalledTimes(3) + + burst.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(3) + isolated.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(4) + expect(mocks.prepare.mock.calls[3]?.[2]).toBe('refs/heads/main') + } finally { + now.mockRestore() + } + }) + + it('does not hold another repo, workspace root, or Git host behind the claim', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const result = await consume() + expect(result.status).toBe('hit') + + await prepareWorktreeCreateForRepo(store, { ...repo, path: '/other-repo' }, 'origin/main') + mocks.computeWorkspaceRootAsync.mockResolvedValueOnce('/other-workspace') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(4) + if (result.status === 'hit') { + result.rearm() + } + }) + + it('releases after failed finalization has discarded the claimed checkout', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + let failFinalization!: (error: Error) => void + mocks.finalize.mockReturnValueOnce( + new Promise((_resolve, reject) => { + failFinalization = reject + }) + ) + const create = consume() + await flushBackgroundWork() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + failFinalization(new Error('finalize failed')) + const result = await create + expect(result).toMatchObject({ status: 'miss', reason: 'finalize_failed' }) + expect(mocks.discard).toHaveBeenCalledTimes(1) + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(1) + result.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) + + it('holds an explicit prefetch through a claimed checkout failure', async () => { + let failPreparation!: (error: Error) => void + mocks.prepare.mockReturnValueOnce( + new Promise((_resolve, reject) => { + failPreparation = reject + }) + ) + const initialPreparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const preparationFailure = initialPreparation.catch(() => {}) + await flushBackgroundWork() + const create = consume() + await flushBackgroundWork() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepare).toHaveBeenCalledTimes(1) + + failPreparation(new Error('checkout failed')) + const result = await create + await preparationFailure + expect(result).toMatchObject({ status: 'miss', reason: 'prepare_failed' }) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + result.rearm?.() + await flushBackgroundWork() + expect(mocks.prepare).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index 7a040373c57..a72972c9d20 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -51,7 +51,18 @@ export type StartPreparationArgs = { options: AddWorktreeOptions } +export type DeferredPreparation = { + args: StartPreparationArgs + kind: 'explicit' | 'automatic' +} + const preparations = new Map() +export type PreparationClaim = { + entry: PreparationEntry + requestedKey: string + pendingPreparations: Map +} +const claims = new Set() /** One repo on one Git host: the scope a stranded discard is retried under. */ function preparationHostKey(repoPathKey: string, wslDistro: string): string { @@ -60,7 +71,7 @@ function preparationHostKey(repoPathKey: string, wslDistro: string): string { /** A prepared checkout is a create that is either in flight or imminent. */ export function hasPendingPreparations(): boolean { - return preparations.size > 0 || hasPendingStalePreparationCleanup() + return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup() } function pathOps(path: string): Pick { @@ -147,12 +158,83 @@ export function findPreparation( /** Removes an entry from the pool so no other create can claim it. Callers must run this in the * same synchronous turn as the selection that produced `entry`. */ -export function takePreparation(entry: PreparationEntry): void { +export function takePreparation( + entry: PreparationEntry, + requestedCanonicalBase = entry.canonicalBase +): PreparationClaim { preparations.delete(entry.key) clearTimeout(entry.expiration) + const requestedKey = preparationEntryKey( + entry.repoPathKey, + entry.workspaceRootKey, + requestedCanonicalBase, + entry.wslDistro + ) + const claim = { entry, requestedKey, pendingPreparations: new Map() } + claims.add(claim) + return claim } -export function startPreparation(args: StartPreparationArgs): Promise { +function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined { + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + return [...claims] + .toReversed() + .find((claim) => claim.entry.key === key || claim.requestedKey === key) +} + +/** Preserve one request per canonical key, with explicit prefetch taking precedence. */ +function deferPreparationForClaim( + args: StartPreparationArgs, + kind: DeferredPreparation['kind'] +): boolean { + const matching = matchingClaim(args) + if (!matching) { + return false + } + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + if (kind === 'explicit' || !matching.pendingPreparations.has(key)) { + matching.pendingPreparations.set(key, { args, kind }) + } + return true +} + +/** A second release is inert, including after a test reset. */ +export function releasePreparationClaim(claim: PreparationClaim): { + released: boolean + pendingPreparations: DeferredPreparation[] +} { + if (!claims.delete(claim)) { + return { released: false, pendingPreparations: [] } + } + return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] } +} + +export function startPreparation( + args: StartPreparationArgs, + kind: DeferredPreparation['kind'] = 'explicit' +): Promise { + const existing = findPreparation( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + if (existing) { + return existing.ready + } + if (deferPreparationForClaim(args, kind)) { + return Promise.resolve() + } return worktreePreparationGit.run(() => startBackgroundPreparation(args)) } @@ -227,6 +309,7 @@ function startBackgroundPreparation({ export async function _resetPreparationPoolForTests(): Promise { const entries = [...preparations.values()] preparations.clear() + claims.clear() await resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { diff --git a/src/main/worktree-create-preparation-timing.test.ts b/src/main/worktree-create-preparation-timing.test.ts new file mode 100644 index 00000000000..bd78f506201 --- /dev/null +++ b/src/main/worktree-create-preparation-timing.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AddWorktreeResult } from './git/worktree' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + prepare: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + listWorktreeGraph: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepare, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: vi.fn() +})) +vi.mock('./git/worktree-base-ref-probe', () => ({ resolveLocalWorktreeBaseRef: vi.fn() })) +vi.mock('./git/worktree-base-divergence', () => ({ measureRetargetDivergence: vi.fn() })) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: vi.fn(), + getWorktreeMirrorDistro: vi.fn() +})) +vi.mock('./ipc/worktree-logic', () => ({ + computeWorkspaceRootAsync: vi.fn(), + getWorktreePathSettings: vi.fn() +})) + +import { + _resetWorktreeCreatePreparationsForTests, + consumePreparedWorktreeCreate +} from './worktree-create-preparation' +import { startPreparation } from './worktree-create-preparation-pool' +import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' + +const request = { + repoPath: '/repo', + workspaceRoot: '/workspace', + worktreePath: '/workspace/feature', + branch: 'feature', + baseBranch: 'origin/main' +} + +function prepare() { + return startPreparation({ + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: request.baseBranch, + canonicalBase: 'refs/remotes/origin/main', + options: {} + }) +} + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.prepare.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() + vi.restoreAllMocks() +}) + +describe('prepared checkout create timing', () => { + it('separates the remaining preparation wait from finalization', async () => { + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + const finalizeStarted = Promise.withResolvers() + const finalize = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + mocks.finalize.mockImplementation(() => { + finalizeStarted.resolve() + return finalize.promise + }) + const preparation = prepare() + await checkoutStarted.promise + + let now = 40 + const timing = createWorktreeCreateTimingRecorder(() => now) + const create = timing.time('git_worktree_add', () => + consumePreparedWorktreeCreate({ ...request, timing }) + ) + now = 150 + checkout.resolve() + await finalizeStarted.promise + now = 180 + finalize.resolve({}) + + expect(await create).toMatchObject({ status: 'hit', retargeted: false }) + await preparation + expect(timing.finish()).toEqual({ + totalDurationMs: 140, + phases: [ + { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 110 }, + { phase: 'prepared_checkout_finalize', startedAtMs: 110, durationMs: 30 }, + { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 140 } + ] + }) + }) + + it('records the wait when preparation fails and the create must fall back', async () => { + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + const preparation = Promise.allSettled([prepare()]) + await checkoutStarted.promise + let now = 0 + const timing = createWorktreeCreateTimingRecorder(() => now) + const create = consumePreparedWorktreeCreate({ ...request, timing }) + now = 250 + checkout.reject(new Error('checkout failed')) + + expect(await create).toEqual({ + status: 'miss', + reason: 'prepare_failed', + rearm: expect.any(Function) + }) + await preparation + expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 250 } + ]) + expect(mocks.finalize).not.toHaveBeenCalled() + }) + + it('records a failed finalization before its fallback cleanup', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await prepare() + let now = 0 + const timing = createWorktreeCreateTimingRecorder(() => now) + mocks.finalize.mockImplementation(async () => { + now = 80 + throw new Error('move failed') + }) + mocks.discard.mockImplementation(async () => { + now = 100 + }) + + expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({ + status: 'miss', + reason: 'finalize_failed', + rearm: expect.any(Function) + }) + expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 0 }, + { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 } + ]) + }) + + it('does not report a preparation wait when no checkout was armed', async () => { + const timing = createWorktreeCreateTimingRecorder(() => 0) + expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({ + status: 'miss', + reason: 'none_armed' + }) + expect(timing.finish().phases).toEqual([]) + }) +}) diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 9b3291bc463..e8c2bb97bce 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -555,7 +555,7 @@ describe('worktree create preparation registry', () => { branch: 'feature/test', baseBranch: 'origin/main' }) - ).resolves.toEqual({ status: 'miss', reason: 'finalize_failed' }) + ).resolves.toMatchObject({ status: 'miss', reason: 'finalize_failed' }) expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) expect(mocks.discard).toHaveBeenCalledTimes(1) }) @@ -637,9 +637,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) - // `startPreparation` overwrites the map entry outright, so a thunk that armed over a prefetch - // would leave that prefetch's locked checkout on disk with nothing holding a reference to it. - it('skips the deferred re-arm when a prefetch armed the same key mid-create', async () => { + it('starts one explicit prefetch after the create completes', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await consumeOnce('first') await prepareWorktreeCreateForRepo(store, repo, 'origin/main') @@ -655,6 +653,7 @@ describe('worktree create preparation registry', () => { // The user reopens the composer while the create is still finishing. await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) mocks.prepareCheckout.mockClear() if (attempt.status === 'hit') { @@ -662,7 +661,7 @@ describe('worktree create preparation registry', () => { } await flushBackgroundWork() - expect(mocks.prepareCheckout).not.toHaveBeenCalled() + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) it('does not re-arm when finalization failed', async () => { diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index be7ea0d5a36..3ac022910eb 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -12,11 +12,13 @@ import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' import { _resetPreparationPoolForTests, - findPreparation, hasPendingPreparations, listPreparations, + releasePreparationClaim, startPreparation, takePreparation, + type DeferredPreparation, + type PreparationClaim, type PreparationEntry } from './worktree-create-preparation-pool' import { @@ -33,6 +35,7 @@ import { resetPreparationConsumeHistoryForTests } from './worktree-create-preparation-burst' import { toHostFilesystemPath } from './host-tree-removal' +import type { WorktreeCreateTimingRecorder } from './worktree-create-timing' export { WORKTREE_CREATE_PREPARATION_LIMIT, @@ -56,7 +59,7 @@ export type PreparedWorktreeCreateAttempt = /** Run after materialization/startup completes, before returning the create result. */ rearm: () => void } - | { status: 'miss'; reason: PreparedCheckoutMissReason } + | { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void } type ConsumePreparedWorktreeArgs = { repoPath: string @@ -66,6 +69,7 @@ type ConsumePreparedWorktreeArgs = { baseBranch: string refreshLocalBaseRef?: boolean options?: AddWorktreeOptions + timing?: Pick } function canonicalBaseRef( @@ -107,16 +111,6 @@ async function prepareWorktreeCreateInBackground( getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo)) ) const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options) - const existing = findPreparation( - preparationPathKey(repo.path), - preparationPathKey(workspaceRoot), - canonicalBase, - options.wslDistro ?? '' - ) - if (existing) { - return existing.ready - } - return startPreparation({ repoPath: repo.path, workspaceRoot, @@ -127,8 +121,14 @@ async function prepareWorktreeCreateInBackground( } type ClaimedPreparation = - | { status: 'claimed'; entry: PreparationEntry; retargeted: boolean; canonicalBase: string } - | { status: 'miss'; reason: PreparedCheckoutMissReason } + | { + status: 'claimed' + entry: PreparationEntry + reservation: PreparationClaim + retargeted: boolean + canonicalBase: string + } + | { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void } async function claimPreparedWorktree( args: ConsumePreparedWorktreeArgs, @@ -187,17 +187,34 @@ async function claimPreparedWorktree( } } const entry = selection.candidate - takePreparation(entry) + const reservation = takePreparation(entry, selection.canonicalBase) try { - await entry.ready + await (args.timing + ? args.timing.time('prepared_checkout_wait', () => entry.ready) + : entry.ready) return { status: 'claimed', entry, + reservation, retargeted: selection.kind === 'retarget', canonicalBase: selection.canonicalBase } } catch { - return { status: 'miss', reason: 'prepare_failed' } + return { status: 'miss', reason: 'prepare_failed', rearm: releaseClaimAfterCreate(reservation) } + } +} + +function startDeferredPreparation(preparation: DeferredPreparation): void { + void startPreparation(preparation.args, preparation.kind).catch(() => { + // A later create still has the normal add path if speculative preparation fails. + }) +} + +function releaseClaimAfterCreate(reservation: PreparationClaim): () => void { + return () => { + for (const preparation of releasePreparationClaim(reservation).pendingPreparations) { + startDeferredPreparation(preparation) + } } } @@ -209,37 +226,38 @@ async function claimPreparedWorktree( * Returns a thunk rather than launching: the replacement is a full `reset --hard`, which on a * large repo holds a general admission slot for tens of seconds. Started mid-create it competes * with the create's own git, so the caller runs it after materialization/startup completes. The burst - * bookkeeping still happens here — a prefetch that re-armed this key while we finalized would - * otherwise swallow the consume, and the next create would look isolated when it is really the - * middle of a burst. */ + * bookkeeping still happens here so a later create is recognized as part of a burst. An explicit + * prefetch during this create takes precedence over the burst replacement at release. */ function deferRearmPreparation( entry: PreparationEntry, + reservation: PreparationClaim, baseBranch: string, canonicalBase: string ): () => void { const continuesBurst = recordPreparationConsume(entry.key) - const alreadyArmed = (): boolean => - findPreparation(entry.repoPathKey, entry.workspaceRootKey, canonicalBase, entry.wslDistro) !== - undefined - if (!continuesBurst || alreadyArmed()) { - return () => {} - } return () => { - // Re-checked here, not only at consume time: `startPreparation` overwrites the map entry - // outright, so arming over a prefetch that landed during the create would strand its - // checkout on disk with no owner to discard it. - if (alreadyArmed()) { + const { released, pendingPreparations } = releasePreparationClaim(reservation) + if (!released) { return } - void startPreparation({ - repoPath: entry.repoPath, - workspaceRoot: entry.workspaceRoot, - baseBranch, - canonicalBase, - options: entry.options - }).catch(() => { - // Why: a warm-up failure is recovered by the normal add on the next create. - }) + const requestedBaseArmed = pendingPreparations.some( + (preparation) => preparation.args.canonicalBase === canonicalBase + ) + for (const preparation of pendingPreparations) { + startDeferredPreparation(preparation) + } + if (continuesBurst && !requestedBaseArmed) { + startDeferredPreparation({ + args: { + repoPath: entry.repoPath, + workspaceRoot: entry.workspaceRoot, + baseBranch, + canonicalBase, + options: entry.options + }, + kind: 'automatic' + }) + } } } @@ -249,9 +267,9 @@ export async function consumePreparedWorktreeCreate( const options = args.options ?? {} const claim = await claimPreparedWorktree(args, options) if (claim.status === 'miss') { - return { status: 'miss', reason: claim.reason } + return { status: 'miss', reason: claim.reason, ...(claim.rearm ? { rearm: claim.rearm } : {}) } } - const { entry } = claim + const { entry, reservation } = claim try { const parentDir = isWindowsAbsolutePathLike(args.worktreePath) ? win32.dirname(args.worktreePath) @@ -259,18 +277,22 @@ export async function consumePreparedWorktreeCreate( await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) // Finalize resolves the requested base itself and resets the prepared checkout onto that // commit, so a retargeted claim is handed over at the requested commit or not at all. - const result = await finalizePreparedWorktree( - args.repoPath, - entry.preparedPath, - args.worktreePath, - args.branch, - args.baseBranch, - args.refreshLocalBaseRef, - options - ) + const finalize = (): Promise => + finalizePreparedWorktree( + args.repoPath, + entry.preparedPath, + args.worktreePath, + args.branch, + args.baseBranch, + args.refreshLocalBaseRef, + options + ) + const result = args.timing + ? await args.timing.time('prepared_checkout_finalize', finalize) + : await finalize() // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is // creating in a burst; the TTL and the preparation limit still bound an unused replacement. - const rearm = deferRearmPreparation(entry, args.baseBranch, claim.canonicalBase) + const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase) return { status: 'hit', retargeted: claim.retargeted, result, rearm } } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) @@ -278,7 +300,11 @@ export async function consumePreparedWorktreeCreate( '[worktree-create] prepared checkout could not be finalized; using normal add', error ) - return { status: 'miss', reason: 'finalize_failed' } + return { + status: 'miss', + reason: 'finalize_failed', + rearm: releaseClaimAfterCreate(reservation) + } } } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index b76814f8b83..457dad2d419 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -216,6 +216,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { }) it('supports prepared worktree creation and finalization', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD']) await runGit(['-C', 'compat-prepared', 'reset', '--hard', 'HEAD']) await runGit([ @@ -234,12 +235,15 @@ describeBinaryCompatibility('real Git binary compatibility', () => { '--no-track', '-b', 'compat-prepared-final', - 'HEAD' + head ]) await expect(runGit(['-C', 'compat-final', 'branch', '--show-current'])).resolves.toMatchObject( { stdout: 'compat-prepared-final\n' } ) + await expect(runGit(['-C', 'compat-final', 'rev-parse', 'HEAD'])).resolves.toMatchObject({ + stdout: `${head}\n` + }) await runGit(['worktree', 'unlock', 'compat-final']) await runGit(['worktree', 'remove', '--force', 'compat-final']) await runGit(['branch', '-D', 'compat-prepared-final']) From aed59a797ad5632080aa4b306c7c0508d9e0b951 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:33:37 -0700 Subject: [PATCH 041/387] chore(i18n): translate 113 new keys to es/fr/ja/ko/zh (#23067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add translations for chat resume, Git toolchain, and notebook support * Fix translation terminology in French and Korean locales - Standardize Korean terminology from "restart" to "resume" for chat resume functionality - Clarify French error message for conflicting Orca windows/terminals - Fix Korean context translation (문맥 → 컨텍스트) * fix jupyter notebook translation * chore(i18n): translate 113 new keys to es/fr/ja/ko/zh Delta since last scan (80e0bee23b): 113 new en keys (session search, resume-modal activity, ipynb venv, Cursor accounts, feature tips) plus 4 changed values (mid-turn -> working resume copy, screenshotsHint +maxSize). es +111, fr/ja/ko/zh +100 each; strictly additive, 0 keys removed. Three subagent review rounds: R1 fixed 20, R2 fixed 5 (incl. reverting an NBSP convention misread), R3 signed off clean with full placeholder parity. Gates: catalog verify, coverage --check, extraction, and locale vitest suites all pass; fr/ja/ko/zh at 100% coverage. --- config/update-translation.txt | 67 +++++++++++ src/renderer/src/i18n/locales/es.json | 159 ++++++++++++++++++++++++-- src/renderer/src/i18n/locales/fr.json | 140 +++++++++++++++++++++-- src/renderer/src/i18n/locales/ja.json | 140 +++++++++++++++++++++-- src/renderer/src/i18n/locales/ko.json | 140 +++++++++++++++++++++-- src/renderer/src/i18n/locales/zh.json | 140 +++++++++++++++++++++-- 6 files changed, 743 insertions(+), 43 deletions(-) create mode 100644 config/update-translation.txt diff --git a/config/update-translation.txt b/config/update-translation.txt new file mode 100644 index 00000000000..d1a85bab724 --- /dev/null +++ b/config/update-translation.txt @@ -0,0 +1,67 @@ +# Translation scan tracker + +Recurring task: after a batch of merged PRs, scan for i18n gaps and fix them: +1. unkeyed user-visible UI strings (gate: `audit:localization` / `verify:localization-coverage`), +2. keyed strings missing target-locale translations (en.json has the key, es/fr/ja/ko/zh does not), +3. badly translated target values (placeholder mismatch, brand/term policy violations). + +Method (reproducible): +- `node config/scripts/verify-localization-catalog.mjs` — key references vs en.json, + per-locale coverage, interpolation mismatches, generic-term regressions. +- `node config/scripts/audit-localization-coverage.mjs --check` — unkeyed strings. +- `node config/scripts/verify-localization-extraction.mjs` — en.json in sync with code. +- Missing-key delta since last scan: flatten en.json at HEAD vs at the last-scanned + base commit, keep keys absent from each target locale. +- Translate the delta per `config/i18n-translation-source.md` policy and the overrides in + `config/scripts/locale-*-overrides.mjs` / `locale-*-fixes.mjs` / `locale-generic-ui-terms.mjs` + (brands such as Orca/OpenCode/Git/Python stay Latin; `{{placeholders}}`, markdown links, + backtick code spans, and `[python.org](...)` links are preserved verbatim). +- Apply with `node config/scripts/apply-translation-delta.mjs /tmp/translations.json` + (validates placeholder parity and runs every value through `repairTranslatedValue` + so CJK spacing, brand-mistranslation fixes, and phrase fixes match the repo pipeline). +- Re-run all four gates plus the vitest locale suites before finishing. +- Mobile (`mobile/`) has no locale catalogs (only SVG `translate()` transforms); out of scope + until the mobile catalog migration lands. Missing keys fall back to English at runtime + (sparse-catalog architecture, PR #8512), so untranslated leftovers are safe, not broken. + +## Scan 2026-09-25 (first tracked scan; no prior file existed) + +- HEAD scanned: 80e0bee23b (2026-09-24) +- Window covered: 519bde81df^ .. HEAD (all PRs merged 2026-09-23..2026-09-24 back to and + including #22519; en.json deltas computed per commit, full commit list below). +- Gates before fix: catalog verify PASS, coverage --check PASS (58 allowlisted), extraction + PASS, runtime-catalog PASS. So no unkeyed strings and en.json in sync; the gap was + target-locale coverage only. + +## Scan 2026-09-26 + +- HEAD scanned: a15a5c8c1a6 (origin/main @ 067975bfd1 plus merge; 12 newest main + commits add zero en keys, verified). +- Window covered: 80e0bee23b .. HEAD. Note: the previous scan's own branch commits + (chat-resume/Git-toolchain/notebook translations + fr/ko terminology fixes + + jupyter fix) have since landed upstream as #22746, so this scan's base-to-HEAD + delta already includes them; the merge was resolved with --theirs on + es/fr/ja/zh (our stale native-chat.handoff.* translations correctly dropped: + the keys were removed from en.json by upstream #22783). +- Delta since base: 113 new en keys (mostly sessionSearch x44, resume-modal + activity, ipynb venv, Cursor accounts, feature-tips), 4 changed en values + (3x "mid-turn" -> "working" resume copy; screenshotsHint gained {{maxSize}}). + fr/ja/ko/zh translations of the 3 resume keys already matched new semantics. +- Applied: es +111 (107 new + 3 resume + screenshotsHint), fr/ja/ko/zh +100 each + (99 new + screenshotsHint) via apply-translation-delta.mjs (pipeline repaired + ja 使用状況->使用量 x1, ja phrase x2, zh 智能体->代理 x5). Strictly additive: + +511 keys, 0 removed. +- Gates after fix: catalog verify PASS (fr/ja/ko/zh 14733/14733 = 100%; + es 12328/14733, remaining 2405 missing are pre-existing es backlog, out of + delta scope), coverage --check PASS (58 allowlisted), extraction PASS, + locale vitest suites PASS (policy x7 files, ko overrides, generic terms, + frozen keywords, native-chat locales). +- Reviews (3 of max 4 rounds, subagents; round 0 = 5 parallel translators): + R1 fixed 20 (es kw.rateLimit->EN per 10-hit precedent; fr NBSP guillemets; + ja settingsLink/progressStarting; ko demo-snippet localization x8 + + consentInstruction fragment; zh x6 incl. 仪表盘/表结构/工作流程). + R2 fixed 5 and reverted fr NBSP->plain spaces (91-vs-7 file convention; + R1 evidence was circular) + ja kw.spend->追加料金 + ja particle fix + + ko 색인화. R3 sign-off clean on all locales (R2 fixes byte-verified, + full placeholder parity 0 mismatches, 0 missing keys). + diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 56c99187d65..a78c4641ab8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -34,7 +34,8 @@ "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del daemon y análisis de disco de los espacios de trabajo.", "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", - "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." + "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI.", + "cursorToggleDescription": "Muestra el uso del plan de Cursor cuando inicias sesión con cursor-agent o el IDE de Cursor." }, "menuBarIcon": { "title": "Mostrar icono en la barra de menús", @@ -727,7 +728,8 @@ "tooMany": "You can attach up to {{maxCount}} images.", "dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.", "invalidImage": "{{fileName}} is not a valid supported image.", - "additionalErrors": "{{count}} additional images could not be attached." + "additionalErrors": "{{count}} additional images could not be attached.", + "totalTooLarge": "{{fileName}} superaría el total de {{maxSize}} de archivos adjuntos." } } } @@ -2802,7 +2804,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3339,7 +3341,8 @@ "grokUsageMenu": "Uso de Grok", "floatingTerminalNewActivity": "{{label}}, nueva actividad", "codexSignInSuccess": "Sesión iniciada en Codex", - "codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo." + "codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo.", + "cursorUsageMenu": "Uso de Cursor" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -3507,6 +3510,13 @@ "noSubscription": { "label": "Sin suscripción" } + }, + "cursor": { + "expired": { + "label": "Inicio de sesión caducado", + "message": "Ejecuta cursor-agent login en una terminal del equipo donde se ejecuta Orca y vuelve a intentar consultar el uso." + }, + "plan": "Plan" } }, "SshTargetStatusRow": { @@ -4452,7 +4462,9 @@ "69969ba364": "Envío…", "imageReadFailed": "Could not read the attached images. Try attaching them again.", "attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.", - "imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed." + "imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed.", + "imagesNotIncluded": "Comentarios enviados. No se pudieron subir tus capturas de pantalla y no se incluyeron.", + "imagesRejected": "Comentarios enviados. Tus capturas de pantalla eran demasiado grandes para subirlas y no se incluyeron." }, "SidebarFilter": { "e3b3898218": "Agregar proyecto", @@ -5334,7 +5346,8 @@ }, "SidebarFeedbackImageAttachments": { "attachImages": "Attach", - "removeImage": "Remove {{fileName}}" + "removeImage": "Remove {{fileName}}", + "screenshotsHint": "Adjunta hasta {{count}} capturas de pantalla, {{maxSize}} en total" }, "WorkspaceKanbanSearchField": { "bdb753c78d": "Ningún espacio de trabajo coincide", @@ -6064,7 +6077,8 @@ "shellEnvNamesAlwaysPassed": "PATH, la configuración regional y SSH_AUTH_SOCK se pasan siempre. Se aplica la próxima vez que un chat se inicie o se reanude.", "shellEnvTitle": "Usar el entorno de tu shell", "shellEnvCopy": "Los chats de Codex y Claude empiezan con todas las variables que exporta tu shell de inicio de sesión, igual que un terminal. Desactívalo para elegir cuáles reciben.", - "shellEnvToggleLabel": "Alternar el uso del entorno de tu shell" + "shellEnvToggleLabel": "Alternar el uso del entorno de tu shell", + "resumeCopy": "Cuando Orca se cierra o instala una actualización, los chats que estaban trabajando se reanudan automáticamente al volver a abrir Orca." }, "agentDashboard": { "title": "Panel de agentes", @@ -7813,7 +7827,18 @@ "d2c6a0e8f1": "grok", "c1b5f9d7e0": "xai", "b0a4e8c6d9": "OAuth", - "a9f3d7b5c8": "inicio de sesión" + "a9f3d7b5c8": "inicio de sesión", + "cursor": { + "description": "Uso mensual del plan obtenido del inicio de sesión de Cursor que ya está en este equipo (cursor-agent login).", + "kw": { + "cursor": "cursor", + "rateLimit": "rate limit", + "spend": "gasto", + "statusBar": "barra de estado", + "usage": "uso" + }, + "title": "Uso de Cursor" + } } }, "advanced": { @@ -8040,7 +8065,14 @@ "d6c0a9e2f4": "grok", "c5b9f8d1e3": "xai", "usagePercentageDisplayTitle": "Porcentajes de uso", - "usagePercentageDisplayDescription": "Elige si los límites del proveedor muestran el porcentaje usado o restante." + "usagePercentageDisplayDescription": "Elige si los límites del proveedor muestran el porcentaje usado o restante.", + "cursor": { + "description": "Muestra el uso mensual del plan de Cursor a partir del inicio de sesión de Cursor en este equipo.", + "kw": { + "cursor": "cursor" + }, + "title": "Uso de Cursor" + } } }, "auto": { @@ -10430,6 +10462,31 @@ "saveFailed": "No se pudieron guardar los valores predeterminados de visibilidad.", "updateServer": "Actualiza este servidor para configurar los valores predeterminados de las fuentes.", "updateServerDefaults": "Actualiza este servidor para configurar los valores predeterminados de visibilidad." + }, + "CursorAccountsSection": { + "dashboardLink": "Panel de Cursor", + "expired": "Inicio de sesión caducado — ejecuta cursor-agent login en el equipo donde se ejecuta Orca y haz clic en Actualizar uso.", + "loading": "Cargando…", + "noAllowance": "Cursor no reportó ninguna cuota de uso para esta cuenta.", + "planTotal": "Plan", + "refreshUsage": "Actualizar uso", + "resets": "Se restablece {{when}}", + "signedIn": "Sesión iniciada", + "signedInFrom": "Sesión iniciada. Orca lee la sesión almacenada en {{source}}.", + "signedInGeneric": "Sesión iniciada. Orca lee la sesión de Cursor almacenada en este equipo.", + "signedOut": "No se encontró ningún inicio de sesión de Cursor en este equipo", + "signedOutHelp": "Inicia sesión con el IDE de Cursor o ejecuta cursor-agent login en una terminal, y haz clic en Actualizar uso aquí.", + "source": { + "cli": "Archivo de autenticación de Cursor CLI", + "desktop": "IDE de Cursor", + "keychain": "Llavero de macOS (cursor-agent)" + }, + "staleUsage": "Último uso conocido — falló la actualización más reciente: {{reason}}", + "subtitle": "Muestra tu uso mensual del plan de Cursor a partir del inicio de sesión que ya está en este equipo. Orca solo lo lee — nunca modifica tu inicio de sesión de Cursor.", + "title": "Cursor", + "usageDescription": "Cursor factura dos cuotas que se restablecen con tu ciclo de facturación, más gasto bajo demanda cuando se agotan.", + "usageLabel": "Uso", + "usageTitle": "Uso mensual del plan" } }, "right": { @@ -13105,7 +13162,22 @@ "noPython": "No se encontró Python en este equipo. Instálalo desde [python.org](https://www.python.org/downloads/) y vuelve a ejecutar la celda.", "kernelDied": "El kernel murió.", "localOnly": "Las celdas del notebook solo pueden ejecutarse para archivos de este equipo.", - "pythonOnly": "Solo los notebooks de Python pueden ejecutarse en Orca." + "pythonOnly": "Solo los notebooks de Python pueden ejecutarse en Orca.", + "commandCopied": "Comando copiado", + "createVenv": "Crear .venv", + "createVenvItem": "Crear entorno virtual…", + "createVenvTitle": "¿Crear un entorno virtual?", + "creatingButton": "Creando…", + "creatingVenvDescription": "Creando .venv en {{folder}} e instalando ipykernel en él. Esto puede tardar un minuto.", + "creatingVenvTitle": "Creando entorno virtual…", + "installingButton": "Instalando…", + "installingDescription": "Instalando ipykernel en '{{env}}' con pip. Esto puede tardar un minuto.", + "installingTitle": "Instalando ipykernel…", + "kernelCreatingVenv": "Creando .venv…", + "setupFailed": "No funcionó. Salida de Python:", + "tryAgain": "Intentar de nuevo", + "useAnotherPython": "Usar otro Python…", + "venvDescription": "Orca creará un .venv en {{folder}} desde '{{env}}', instalará ipykernel en él y ejecutará este notebook allí." }, "MarkdownPreview": { "e4683f70c4": "Cancelar", @@ -14746,7 +14818,17 @@ "notConfirmedOtherOne": "No se pudo confirmar que 1 otro chat se reanudó", "notConfirmedOtherMany": "No se pudo confirmar que {{value0}} otros chats se reanudaron", "show": "Mostrar", - "dismiss": "Descartar" + "dismiss": "Descartar", + "activityApproval": "Esperando tu aprobación: {{value0}}", + "activityMidReply": "Estaba respondiendo", + "activityMonitorMany": "Supervisando {{value0}} tareas en segundo plano", + "activityMonitorOne": "Supervisando: {{value0}}", + "activityQuestion": "Esperando tu respuesta: {{value0}}", + "activitySubagentMany": "{{value0}} subagentes en ejecución", + "activitySubagentOne": "Subagente en ejecución: {{value0}}", + "activityUnnamed": "sin nombre", + "updateBody": "Estos chats estaban trabajando cuando Orca instaló una actualización. Al reanudarlos, cada uno se restaura donde se detuvo, con su contexto completo, y se pide al agente que revise lo que estaba haciendo antes de continuar. Tu propio prompt no se vuelve a enviar.", + "body": "Estos chats estaban trabajando cuando Orca se cerró. Al reanudarlos, cada uno se restaura donde se detuvo, con su contexto completo, y se pide al agente que revise lo que estaba haciendo antes de continuar. Tu propio prompt no se vuelve a enviar." }, "NativeChatResumeOutcomeRow": { "openChat": "Abrir chat", @@ -14767,6 +14849,13 @@ "dispatchRejected": "El agente rechazó el mensaje “continue”. Abre el chat y envía uno tú mismo.", "unsupported": "Orca no puede reanudar este chat. Ábrelo para ver dónde se detuvo.", "fallback": "Orca no pudo reanudar este chat. Ábrelo para continuar manualmente." + }, + "web": { + "preloadApi": { + "cursorAccounts": { + "hostOnly": "Los detalles del inicio de sesión de Cursor solo se pueden leer en el equipo donde se ejecuta Orca." + } + } } }, "i18n": { @@ -15161,5 +15250,53 @@ "stopped": "detenido", "finished": "finalizado" } + }, + "featureTips": { + "sessionSearch": { + "ageHours": "2 h", + "ageMinutes": "12 min", + "ageYesterday": "1 d", + "agentsInstruction": "Tus agentes también pueden buscar en él. Pide a uno que “encuentre la sesión donde corregimos el tiempo de espera del inicio de sesión”.", + "consentInstruction": "Cámbialo o desactívalo cuando quieras en", + "continueInBackground": "Continuar en segundo plano", + "demoHit1Age": "3 d", + "demoHit1Snippet": "Aumentó el [[tiempo de espera de inicio de sesión]] a 30 s y agregó un reintento en la actualización del token.", + "demoHit1Title": "Corregir redirección de autenticación inestable", + "demoHit2Age": "1 sem", + "demoHit2Snippet": "¿Por qué se cuelga la página de [[inicio de sesión]] después de un [[tiempo de espera]] al reactivar?", + "demoHit2Title": "Actualización de sesión tras suspensión", + "demoHit3Age": "2 sem", + "demoHit3Snippet": "Agregó una prueba que cubre la ruta de [[tiempo de espera de inicio de sesión]] de principio a fin.", + "demoHit3Title": "Refuerzo e2e de autenticación", + "demoHit4Age": "5 d", + "demoHit4Snippet": "La [[reversión]] elimina primero el índice nuevo y luego restaura la columna anterior.", + "demoHit4Title": "Migración de la tabla de pedidos", + "demoHit5Age": "3 sem", + "demoHit5Snippet": "¿Puedes escribir un plan de [[reversión]] de la [[migración]] antes de reintentar?", + "demoHit5Title": "Falló la implementación en staging", + "demoHit6Age": "1 mes", + "demoHit6Snippet": "Cada [[migración]] aquí es reversible excepto el cambio de nombre del enum.", + "demoHit6Title": "Notas de revisión del esquema", + "demoQuery": "tiempo de espera de inicio de sesión", + "demoQuery2": "reversión de migración", + "demoRoleAssistant": "Asistente", + "demoRoleUser": "Usuario", + "enableFailed": "No se pudo activar la búsqueda de sesiones. Inténtalo de nuevo.", + "indexingDescription": "Orca está leyendo tus transcripciones anteriores de agentes para que puedas buscar en ellas. Esto puede tardar unos minutos.", + "indexingTitle": "Indexando tus sesiones de agentes", + "progressStarting": "Buscando tus sesiones…", + "readyDescription": "Todas las sesiones de agentes de este equipo ya se pueden encontrar por lo que se dijo en ellas.", + "readyTitle": "La búsqueda de sesiones está lista", + "readyToast": "La búsqueda de sesiones está lista", + "readyToastOpen": "Abrir", + "recent1Text": "Movió las entradas de búsqueda a Flujos de trabajo y actualizó las pruebas.", + "recent1Title": "Ordenar la barra lateral de ajustes", + "recent2Text": "Actualiza las dependencias y asegúrate de que la compilación siga pasando.", + "recent2Title": "Actualizar el archivo de bloqueo", + "recent3Text": "Esta es una primera versión de las notas, agrupadas por área.", + "recent3Title": "Borrador de notas de la versión", + "settingsLink": "Ajustes → Búsqueda de sesiones de agentes", + "startSearching": "Empezar a buscar" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index de7876b99e0..0efd3af9e3a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -133,7 +133,8 @@ "resourceUsageToggleDescription": "Afficher le gestionnaire de ressources. Cliquez dessus pour le CPU, la mémoire, les sessions, les contrôles des daemons et les analyses disque des espaces de travail.", "portsToggleDescription": "Afficher les ports actifs de l'espace de travail. Cliquez dessus pour les ports par espace de travail et les écouteurs externes.", "antigravityToggleDescription": "Afficher l'utilisation de l'abonnement Antigravity pour l'espace de travail actif.", - "grokToggleDescription": "Afficher l'utilisation des crédits de l'abonnement Grok lorsque vous êtes connecté via Grok CLI." + "grokToggleDescription": "Afficher l'utilisation des crédits de l'abonnement Grok lorsque vous êtes connecté via Grok CLI.", + "cursorToggleDescription": "Afficher l'utilisation du forfait Cursor lorsque vous êtes connecté via cursor-agent ou Cursor IDE." }, "menuBarIcon": { "title": "Afficher l'icône dans la barre de menus", @@ -968,7 +969,8 @@ "tooMany": "Vous pouvez joindre jusqu'à {{maxCount}} images.", "dimensionsTooLarge": "{{fileName}} a des dimensions trop grandes pour un aperçu sans risque.", "invalidImage": "{{fileName}} n'est pas une image valide prise en charge.", - "additionalErrors": "{{count}} images supplémentaires n'ont pas pu être jointes." + "additionalErrors": "{{count}} images supplémentaires n'ont pas pu être jointes.", + "totalTooLarge": "{{fileName}} ferait dépasser {{maxSize}} au total pour les pièces jointes." } } }, @@ -3215,7 +3217,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3761,7 +3763,8 @@ "grokUsageMenu": "Utilisation Grok", "floatingTerminalNewActivity": "{{label}}, nouvelle activité", "codexSignInSuccess": "Connecté à Codex", - "codexSignInError": "Échec de la connexion à Codex. Veuillez réessayer." + "codexSignInError": "Échec de la connexion à Codex. Veuillez réessayer.", + "cursorUsageMenu": "Utilisation Cursor" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connecter un compte", @@ -3952,6 +3955,13 @@ "noSubscription": { "label": "Aucun abonnement" } + }, + "cursor": { + "expired": { + "label": "Connexion expirée", + "message": "Exécutez cursor-agent login dans un terminal sur l'ordinateur qui exécute Orca, puis réessayez." + }, + "plan": "Forfait" } }, "SshTargetStatusRow": { @@ -5341,7 +5351,9 @@ "69969ba364": "Envoi…", "imageReadFailed": "Impossible de lire les images jointes. Essayez de les joindre à nouveau.", "attachWhileSending": "Attendez la fin de l'envoi du commentaire en cours avant de joindre d'autres images.", - "imagesNotDelivered": "Commentaire envoyé, mais la livraison des images n'a pas pu être confirmée." + "imagesNotDelivered": "Commentaire envoyé, mais la livraison des images n'a pas pu être confirmée.", + "imagesNotIncluded": "Commentaire envoyé. Vos captures d'écran n'ont pas pu être téléversées et n'ont pas été incluses.", + "imagesRejected": "Commentaire envoyé. Vos captures d'écran étaient trop volumineuses pour être téléversées et n'ont pas été incluses." }, "SidebarFilter": { "e3b3898218": "Ajouter un projet", @@ -6241,7 +6253,8 @@ }, "SidebarFeedbackImageAttachments": { "attachImages": "Joindre", - "removeImage": "Supprimer {{fileName}}" + "removeImage": "Supprimer {{fileName}}", + "screenshotsHint": "Joignez jusqu'à {{count}} captures d'écran, {{maxSize}} au total" }, "WorkspaceKanbanSearchField": { "bdb753c78d": "Aucun espace de travail correspondant", @@ -9023,7 +9036,18 @@ "b2c4e7f1a8": "point final", "3a9b6d2c4e": "clé API", "5d8f1a3b7c": "Chine", - "7e2a4b8c1d": "outre-mer" + "7e2a4b8c1d": "outre-mer", + "cursor": { + "description": "Utilisation mensuelle du forfait lue depuis la connexion Cursor déjà présente sur cet ordinateur (cursor-agent login).", + "kw": { + "cursor": "cursor", + "rateLimit": "limite de débit", + "spend": "dépenses", + "statusBar": "barre d'état", + "usage": "utilisation" + }, + "title": "Utilisation Cursor" + } } }, "advanced": { @@ -9279,6 +9303,13 @@ "close": "fermer", "notification": "zone de notification", "background": "arrière-plan" + }, + "cursor": { + "description": "Afficher l'utilisation mensuelle du forfait Cursor à partir de la connexion Cursor sur cet ordinateur.", + "kw": { + "cursor": "cursor" + }, + "title": "Utilisation Cursor" } } }, @@ -12095,6 +12126,31 @@ "TerminalRenderingSection": { "6d4c55bacc": "Images en ligne", "fffab5890b": "Affichez les images directement dans le terminal à l'aide des protocoles graphiques SIXEL, iTerm2 (IIP) et Kitty." + }, + "CursorAccountsSection": { + "dashboardLink": "Tableau de bord Cursor", + "expired": "Connexion expirée — exécutez cursor-agent login sur l'ordinateur qui exécute Orca, puis cliquez sur Actualiser l'utilisation.", + "loading": "Chargement…", + "noAllowance": "Cursor n'a signalé aucun quota d'utilisation pour ce compte.", + "planTotal": "Forfait", + "refreshUsage": "Actualiser l'utilisation", + "resets": "Réinitialisation {{when}}", + "signedIn": "Connecté", + "signedInFrom": "Connecté. Orca lit la session stockée dans {{source}}.", + "signedInGeneric": "Connecté. Orca lit la session Cursor stockée sur cet ordinateur.", + "signedOut": "Aucune connexion Cursor trouvée sur cet ordinateur", + "signedOutHelp": "Connectez-vous avec Cursor IDE, ou exécutez cursor-agent login dans un terminal, puis cliquez ici sur Actualiser l'utilisation.", + "source": { + "cli": "Fichier d'authentification du CLI Cursor", + "desktop": "Cursor IDE", + "keychain": "Trousseau macOS (cursor-agent)" + }, + "staleUsage": "Dernière utilisation connue — la dernière actualisation a échoué : {{reason}}", + "subtitle": "Affiche l'utilisation mensuelle de votre forfait Cursor à partir de la connexion déjà présente sur cet ordinateur. Orca se contente de la lire — il ne modifie jamais votre connexion Cursor.", + "title": "Cursor", + "usageDescription": "Cursor facture deux enveloppes qui se réinitialisent avec votre cycle de facturation, plus des dépenses à la demande une fois épuisées.", + "usageLabel": "Utilisation", + "usageTitle": "Utilisation mensuelle du forfait" } }, "right": { @@ -15036,7 +15092,22 @@ "noPython": "Python est introuvable sur cet ordinateur. Installez-le depuis [python.org](https://www.python.org/downloads/), puis réexécutez la cellule.", "kernelDied": "Le kernel est mort.", "localOnly": "Les cellules du notebook ne peuvent s'exécuter que pour des fichiers sur cet ordinateur.", - "pythonOnly": "Seuls les notebooks Python peuvent s'exécuter dans Orca." + "pythonOnly": "Seuls les notebooks Python peuvent s'exécuter dans Orca.", + "commandCopied": "Commande copiée.", + "createVenv": "Créer .venv", + "createVenvItem": "Créer un environnement virtuel…", + "createVenvTitle": "Créer un environnement virtuel ?", + "creatingButton": "Création…", + "creatingVenvDescription": "Création de .venv dans {{folder}} et installation d'ipykernel. Cela peut prendre une minute.", + "creatingVenvTitle": "Création de l'environnement virtuel…", + "installingButton": "Installation…", + "installingDescription": "Installation d'ipykernel dans « {{env}} » avec pip. Cela peut prendre une minute.", + "installingTitle": "Installation d'ipykernel…", + "kernelCreatingVenv": "Création de .venv…", + "setupFailed": "Cela n'a pas fonctionné. Sortie de Python :", + "tryAgain": "Réessayer", + "useAnotherPython": "Utiliser un autre Python…", + "venvDescription": "Orca va créer un .venv dans {{folder}} à partir de « {{env}} », y installer ipykernel, puis y exécuter ce notebook." }, "MarkdownPreview": { "e4683f70c4": "Annuler", @@ -17255,6 +17326,13 @@ "dispatchRejected": "L'agent a refusé le message “continue”. Ouvrez le chat et envoyez-en un vous-même.", "unsupported": "Orca ne peut pas reprendre ce chat. Ouvrez-le pour voir où il s'est arrêté.", "fallback": "Orca n'a pas pu reprendre ce chat. Ouvrez-le pour continuer manuellement." + }, + "web": { + "preloadApi": { + "cursorAccounts": { + "hostOnly": "Les détails de connexion Cursor ne sont lisibles que sur l'ordinateur qui exécute Orca." + } + } } }, "i18n": { @@ -18146,6 +18224,52 @@ "unassignedInstruction": "Assignez un raccourci de dictée avant de lancer la dictée vocale dans un volet actif.", "settingsInstruction": "Modifiez le modèle, le mode de dictée ou le raccourci à tout moment dans", "settingsLink": "Paramètres → Voix" + }, + "sessionSearch": { + "ageHours": "2 h", + "ageMinutes": "12 min", + "ageYesterday": "1 j", + "agentsInstruction": "Vos agents peuvent aussi la fouiller. Demandez à l'un d'eux de « retrouver la session où nous avons corrigé le délai de connexion ».", + "consentInstruction": "Modifiez ce réglage ou désactivez-le à tout moment dans", + "continueInBackground": "Continuer en arrière-plan", + "demoHit1Age": "3 j", + "demoHit1Snippet": "Le [[délai de connexion]] est passé à 30 s avec un nouvel essai sur l'actualisation du jeton.", + "demoHit1Title": "Corriger la redirection d'authentification instable", + "demoHit2Age": "1 sem.", + "demoHit2Snippet": "Pourquoi la page de [[connexion]] se fige-t-elle après un [[délai dépassé]] au réveil ?", + "demoHit2Title": "Actualisation de session après la mise en veille", + "demoHit3Age": "2 sem.", + "demoHit3Snippet": "Ajout d'un test couvrant le parcours [[délai de connexion]] de bout en bout.", + "demoHit3Title": "Renforcement des tests e2e d'authentification", + "demoHit4Age": "5 j", + "demoHit4Snippet": "Le [[retour en arrière]] supprime d'abord le nouvel index, puis restaure l'ancienne colonne.", + "demoHit4Title": "Migration de la table des commandes", + "demoHit5Age": "3 sem.", + "demoHit5Snippet": "Pouvez-vous rédiger un plan de [[retour en arrière]] de [[migration]] avant le nouvel essai ?", + "demoHit5Title": "Échec du déploiement en préproduction", + "demoHit6Age": "1 mois", + "demoHit6Snippet": "Chaque [[migration]] ici est réversible, sauf le renommage de l'enum.", + "demoHit6Title": "Notes de révision du schéma", + "demoQuery": "délai de connexion", + "demoQuery2": "retour en arrière de migration", + "demoRoleAssistant": "Assistant", + "demoRoleUser": "Utilisateur", + "enableFailed": "Impossible d'activer la recherche de session. Réessayez.", + "indexingDescription": "Orca lit vos transcriptions d'agent passées pour vous permettre de les rechercher. Cela peut prendre quelques minutes.", + "indexingTitle": "Indexation de vos sessions d'agent", + "progressStarting": "Recherche de vos sessions…", + "readyDescription": "Chaque session d'agent sur cet ordinateur peut désormais être retrouvée par son contenu.", + "readyTitle": "La recherche de session est prête", + "readyToast": "La recherche de session est prête", + "readyToastOpen": "Ouvrir", + "recent1Text": "Entrées de recherche déplacées sous Flux de travail et tests mis à jour.", + "recent1Title": "Ranger la barre latérale des paramètres", + "recent2Text": "Mettez à jour les dépendances et vérifiez que la compilation réussit toujours.", + "recent2Title": "Mettre à jour le fichier de verrouillage", + "recent3Text": "Voici une première version des notes, regroupées par domaine.", + "recent3Title": "Brouillon des notes de version", + "settingsLink": "Paramètres → Recherche de session d'agent", + "startSearching": "Commencer la recherche" } }, "quickOpen": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 732aa65d48d..a106142e0f8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -34,7 +34,8 @@ "resourceUsageToggleDescription": "リソースマネージャーを表示します。クリックすると、CPU、メモリ、セッション、デーモンコントロール、ワークスペースディスクスキャンの情報を確認できます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", - "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" + "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。", + "cursorToggleDescription": "cursor-agent または Cursor IDE でサインインしている場合に Cursor プランの使用状況を表示します。" }, "menuBarIcon": { "title": "メニューバーアイコンを表示", @@ -869,7 +870,8 @@ "tooMany": "画像は最大 {{maxCount}} 件まで添付できます。", "dimensionsTooLarge": "{{fileName}} は画像サイズが大きすぎるため、安全にプレビューできません。", "invalidImage": "{{fileName}} は有効な画像として読み取れません。", - "additionalErrors": "ほかに {{count}} 件の画像を添付できませんでした。" + "additionalErrors": "ほかに {{count}} 件の画像を添付できませんでした。", + "totalTooLarge": "{{fileName}} を追加すると添付ファイルの合計が {{maxSize}} を超えます。" } } }, @@ -3093,7 +3095,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3666,7 +3668,8 @@ "grokUsageMenu": "Grok の使用状況", "floatingTerminalNewActivity": "{{label}}、新規アクティビティ", "codexSignInSuccess": "Codex にサインインしました", - "codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。" + "codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。", + "cursorUsageMenu": "Cursor の使用状況" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -3841,6 +3844,13 @@ "noSubscription": { "label": "サブスクリプションなし" } + }, + "cursor": { + "expired": { + "label": "サインインの有効期限切れ", + "message": "Orca を実行しているコンピュータのターミナルで cursor-agent login を実行し、使用状況を再試行してください。" + }, + "plan": "プラン" } }, "SshTargetStatusRow": { @@ -5204,7 +5214,9 @@ "69969ba364": "送信中…", "imageReadFailed": "添付された画像を読み取れませんでした。もう一度添付してください。", "attachWhileSending": "現在のフィードバックの送信が完了してから、画像を追加してください。", - "imagesNotDelivered": "フィードバックを送信しましたが、画像が届いたかどうかは確認できませんでした。" + "imagesNotDelivered": "フィードバックを送信しましたが、画像が届いたかどうかは確認できませんでした。", + "imagesNotIncluded": "フィードバックを送信しました。スクリーンショットをアップロードできなかったため、含まれていません。", + "imagesRejected": "フィードバックを送信しました。スクリーンショットが大きすぎてアップロードできなかったため、含まれていません。" }, "SidebarFilter": { "e3b3898218": "プロジェクトを追加", @@ -6164,7 +6176,8 @@ }, "SidebarFeedbackImageAttachments": { "attachImages": "Attach", - "removeImage": "{{fileName}} を削除" + "removeImage": "{{fileName}} を削除", + "screenshotsHint": "スクリーンショットを最大 {{count}} 件、合計 {{maxSize}} まで添付できます" }, "WorkspaceKanbanSearchField": { "bdb753c78d": "一致するワークスペースはありません", @@ -8900,7 +8913,18 @@ "b2c4e7f1a8": "終点", "3a9b6d2c4e": "APIキー", "5d8f1a3b7c": "中国", - "7e2a4b8c1d": "海外" + "7e2a4b8c1d": "海外", + "cursor": { + "description": "このコンピュータに既にある Cursor のサインイン(cursor-agent login)から読み取った月間プラン使用状況。", + "kw": { + "cursor": "cursor", + "rateLimit": "レート制限", + "spend": "追加料金", + "statusBar": "ステータスバー", + "usage": "使用量" + }, + "title": "Cursor の使用状況" + } } }, "advanced": { @@ -9156,6 +9180,13 @@ "close": "近い", "notification": "通知領域", "background": "背景" + }, + "cursor": { + "description": "このコンピュータの Cursor サインインから月間 Cursor プラン使用状況を表示します。", + "kw": { + "cursor": "cursor" + }, + "title": "Cursor の使用状況" } } }, @@ -11992,6 +12023,31 @@ "TerminalRenderingSection": { "6d4c55bacc": "インライン画像", "fffab5890b": "SIXEL、iTerm2 (IIP)、および Kitty グラフィックスプロトコルを使用して、ターミナルに画像を直接表示します。" + }, + "CursorAccountsSection": { + "dashboardLink": "Cursor ダッシュボード", + "expired": "サインインの有効期限が切れています — Orca を実行しているコンピュータで cursor-agent login を実行し、「使用状況を更新」をクリックしてください。", + "loading": "読み込み中…", + "noAllowance": "Cursor によると、このアカウントには使用量の割り当てがありません。", + "planTotal": "プラン", + "refreshUsage": "使用状況を更新", + "resets": "{{when}} にリセット", + "signedIn": "サインイン済み", + "signedInFrom": "サインイン済み。Orca は {{source}} に保存されたセッションを読み取ります。", + "signedInGeneric": "サインイン済み。Orca はこのコンピュータに保存された Cursor セッションを読み取ります。", + "signedOut": "このコンピュータに Cursor のサインインが見つかりません", + "signedOutHelp": "Cursor IDE でサインインするか、ターミナルで cursor-agent login を実行してから、ここで「使用状況を更新」をクリックしてください。", + "source": { + "cli": "Cursor CLI 認証ファイル", + "desktop": "Cursor IDE", + "keychain": "macOS キーチェーン(cursor-agent)" + }, + "staleUsage": "最後に確認できた使用状況 — 最新の更新に失敗しました: {{reason}}", + "subtitle": "このコンピュータに既にある Cursor のサインインから、月間の Cursor プラン使用状況を表示します。Orca は読み取るだけで、Cursor のログインを変更することはありません。", + "title": "Cursor", + "usageDescription": "Cursor は請求サイクルとともにリセットされる 2 つのプールの利用に課金し、使い切るとオンデマンドの追加料金が発生します。", + "usageLabel": "使用量", + "usageTitle": "月間プラン使用状況" } }, "right": { @@ -14933,7 +14989,22 @@ "noPython": "このコンピュータに Python が見つかりません。[python.org](https://www.python.org/downloads/) からインストールして、セルを再実行してください。", "kernelDied": "カーネルが停止しました。", "localOnly": "ノートブックセルはこのコンピュータ上のファイルでのみ実行できます。", - "pythonOnly": "Orca では Python ノートブックのみ実行できます。" + "pythonOnly": "Orca では Python ノートブックのみ実行できます。", + "commandCopied": "コマンドをコピーしました", + "createVenv": ".venv を作成", + "createVenvItem": "仮想環境を作成…", + "createVenvTitle": "仮想環境を作成しますか?", + "creatingButton": "作成中…", + "creatingVenvDescription": "{{folder}} に .venv を作成し、ipykernel をインストールしています。数分かかることがあります。", + "creatingVenvTitle": "仮想環境を作成中…", + "installingButton": "インストール中…", + "installingDescription": "pip で '{{env}}' に ipykernel をインストールしています。数分かかることがあります。", + "installingTitle": "ipykernel をインストール中…", + "kernelCreatingVenv": ".venv を作成中…", + "setupFailed": "処理に失敗しました。Python の出力:", + "tryAgain": "再試行", + "useAnotherPython": "別の Python を使用…", + "venvDescription": "Orca は '{{env}}' から {{folder}} に .venv を作成し、ipykernel をインストールして、このノートブックをそこで実行します。" }, "MarkdownPreview": { "e4683f70c4": "キャンセル", @@ -17156,6 +17227,13 @@ "dispatchRejected": "Agent が “continue” メッセージを拒否しました。チャットを開いて自分で送信してください。", "unsupported": "このチャットは Orca で再開できません。開いて停止位置を確認してください。", "fallback": "Orca はこのチャットを再開できませんでした。開いて手動で続けてください。" + }, + "web": { + "preloadApi": { + "cursorAccounts": { + "hostOnly": "Cursor のサインイン詳細は、Orca を実行しているコンピュータでのみ読み取れます。" + } + } } }, "i18n": { @@ -18186,6 +18264,52 @@ "unassignedInstruction": "フォーカスされたペインで音声ディクテーションを開始する前に、ディクテーションショートカットを割り当てます。", "settingsInstruction": "いつでもモデル、ディクテーションモード、ショートカットを変更できます。", "settingsLink": "設定→音声" + }, + "sessionSearch": { + "ageHours": "2時間前", + "ageMinutes": "12分前", + "ageYesterday": "1日前", + "agentsInstruction": "Agent も検索できます。「ログインタイムアウトを修正したセッションを探して」と頼んでみてください。", + "consentInstruction": "いつでも以下で変更またはオフにできます:", + "continueInBackground": "バックグラウンドで続行", + "demoHit1Age": "3日前", + "demoHit1Snippet": "[[ログインタイムアウト]]を 30 秒に引き上げ、トークン更新時の再試行を追加しました。", + "demoHit1Title": "不安定な認証リダイレクトの修正", + "demoHit2Age": "1週間前", + "demoHit2Snippet": "スリープ復帰後に[[ログイン]]ページが[[タイムアウト]]で止まるのはなぜですか?", + "demoHit2Title": "スリープ後のセッション更新", + "demoHit3Age": "2週間前", + "demoHit3Snippet": "[[ログインタイムアウト]]の経路をエンドツーエンドでカバーするテストを追加しました。", + "demoHit3Title": "認証 E2E の強化", + "demoHit4Age": "5日前", + "demoHit4Snippet": "[[ロールバック]]では新規インデックスを先に削除し、古いカラムを復元します。", + "demoHit4Title": "注文テーブルの移行", + "demoHit5Age": "3週間前", + "demoHit5Snippet": "再試行の前に[[移行]][[ロールバック]]計画を書いてもらえますか?", + "demoHit5Title": "ステージングのデプロイに失敗", + "demoHit6Age": "1か月前", + "demoHit6Snippet": "ここの[[移行]]は enum の名前変更を除きすべて可逆です。", + "demoHit6Title": "スキーマレビューメモ", + "demoQuery": "ログインタイムアウト", + "demoQuery2": "移行 ロールバック", + "demoRoleAssistant": "アシスタント", + "demoRoleUser": "ユーザー", + "enableFailed": "セッション検索をオンにできませんでした。再試行してください。", + "indexingDescription": "過去の Agent の会話を読み取り、検索できるようにしています。数分かかることがあります。", + "indexingTitle": "Agent セッションをインデックス作成中", + "progressStarting": "セッションを探しています…", + "readyDescription": "このコンピュータ上のすべての Agent セッションを、会話内容から検索できるようになりました。", + "readyTitle": "セッション検索の準備ができました", + "readyToast": "セッション検索の準備ができました", + "readyToastOpen": "オープン", + "recent1Text": "検索エントリをワークフロー配下に移動し、テストを更新しました。", + "recent1Title": "設定サイドバーの整理", + "recent2Text": "依存関係を更新し、ビルドが通ることを確認します。", + "recent2Title": "ロックファイルの更新", + "recent3Text": "メモの初版です。分野別にまとめています。", + "recent3Title": "リリースノートの下書き", + "settingsLink": "設定 → Agent セッションの検索", + "startSearching": "検索を開始" } }, "quickOpen": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb7c6881577..99cda848808 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -34,7 +34,8 @@ "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", - "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." + "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다.", + "cursorToggleDescription": "cursor-agent 또는 Cursor IDE로 로그인한 경우 Cursor 플랜 사용량을 표시합니다." }, "menuBarIcon": { "title": "메뉴 바 아이콘 표시", @@ -869,7 +870,8 @@ "tooMany": "You can attach up to {{maxCount}} images.", "dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.", "invalidImage": "{{fileName}} is not a valid supported image.", - "additionalErrors": "{{count}} additional images could not be attached." + "additionalErrors": "{{count}} additional images could not be attached.", + "totalTooLarge": "{{fileName}}을(를) 포함하면 첨부 파일 합계가 {{maxSize}}를 초과합니다." } } }, @@ -3093,7 +3095,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3666,7 +3668,8 @@ "grokUsageMenu": "Grok 사용량", "floatingTerminalNewActivity": "{{label}}, 새 활동", "codexSignInSuccess": "Codex에 로그인했습니다", - "codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요." + "codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.", + "cursorUsageMenu": "Cursor 사용량" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -3841,6 +3844,13 @@ "noSubscription": { "label": "구독 없음" } + }, + "cursor": { + "expired": { + "label": "로그인 만료", + "message": "Orca가 실행 중인 컴퓨터의 터미널에서 cursor-agent login을 실행한 뒤 사용량을 다시 시도하세요." + }, + "plan": "플랜" } }, "SshTargetStatusRow": { @@ -5204,7 +5214,9 @@ "69969ba364": "전송 중…", "imageReadFailed": "Could not read the attached images. Try attaching them again.", "attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.", - "imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed." + "imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed.", + "imagesNotIncluded": "피드백을 보냈습니다. 스크린샷을 업로드하지 못해 포함되지 않았습니다.", + "imagesRejected": "피드백을 보냈습니다. 스크린샷이 너무 커서 업로드하지 못해 포함되지 않았습니다." }, "SidebarFilter": { "e3b3898218": "프로젝트 추가", @@ -6164,7 +6176,8 @@ }, "SidebarFeedbackImageAttachments": { "attachImages": "Attach", - "removeImage": "Remove {{fileName}}" + "removeImage": "Remove {{fileName}}", + "screenshotsHint": "최대 {{count}}장, 총 {{maxSize}}까지 스크린샷을 첨부하세요." }, "WorkspaceKanbanSearchField": { "bdb753c78d": "일치하는 워크스페이스가 없습니다", @@ -8863,7 +8876,18 @@ "b2c4e7f1a8": "끝점", "3a9b6d2c4e": "API 키", "5d8f1a3b7c": "중국", - "7e2a4b8c1d": "해외" + "7e2a4b8c1d": "해외", + "cursor": { + "description": "이 컴퓨터에 이미 있는 Cursor 로그인(cursor-agent login)에서 읽은 월간 플랜 사용량입니다.", + "kw": { + "cursor": "cursor", + "rateLimit": "속도 제한", + "spend": "지출", + "statusBar": "상태 표시줄", + "usage": "사용량" + }, + "title": "Cursor 사용량" + } } }, "advanced": { @@ -9119,6 +9143,13 @@ "close": "닫다", "notification": "알림 영역", "background": "배경" + }, + "cursor": { + "description": "이 컴퓨터의 Cursor 로그인에서 월간 Cursor 플랜 사용량을 표시합니다.", + "kw": { + "cursor": "cursor" + }, + "title": "Cursor 사용량" } } }, @@ -11992,6 +12023,31 @@ "TerminalRenderingSection": { "6d4c55bacc": "인라인 이미지", "fffab5890b": "SIXEL, iTerm2(IIP) 및 Kitty 그래픽 프로토콜을 사용하여 터미널에 이미지를 직접 표시합니다." + }, + "CursorAccountsSection": { + "dashboardLink": "Cursor 대시보드", + "expired": "로그인이 만료되었습니다 — Orca가 실행 중인 컴퓨터에서 cursor-agent login을 실행한 뒤 사용량 새로고침을 클릭하세요.", + "loading": "로드 중…", + "noAllowance": "Cursor에서 이 계정에 사용 한도가 없다고 보고했습니다.", + "planTotal": "플랜", + "refreshUsage": "사용량 새로고침", + "resets": "{{when}}에 초기화", + "signedIn": "로그인됨", + "signedInFrom": "로그인되었습니다. Orca는 {{source}}에 저장된 세션을 읽습니다.", + "signedInGeneric": "로그인되었습니다. Orca는 이 컴퓨터에 저장된 Cursor 세션을 읽습니다.", + "signedOut": "이 컴퓨터에서 Cursor 로그인을 찾을 수 없습니다", + "signedOutHelp": "Cursor IDE로 로그인하거나 터미널에서 cursor-agent login을 실행한 뒤 여기서 사용량 새로고침을 클릭하세요.", + "source": { + "cli": "Cursor CLI 인증 파일", + "desktop": "Cursor IDE", + "keychain": "macOS 키체인(cursor-agent)" + }, + "staleUsage": "마지막으로 확인된 사용량 — 최신 새로고침이 실패했습니다: {{reason}}", + "subtitle": "이 컴퓨터에 이미 있는 로그인에서 Cursor 월간 플랜 사용량을 보여줍니다. Orca는 읽기만 하며 Cursor 로그인을 변경하지 않습니다.", + "title": "Cursor", + "usageDescription": "Cursor는 요금 주기에 따라 초기화되는 두 가지 풀과, 소진 후에 발생하는 주문형 지출로 과금합니다.", + "usageLabel": "사용량", + "usageTitle": "월간 플랜 사용량" } }, "right": { @@ -14933,7 +14989,22 @@ "noPython": "이 컴퓨터에서 Python을 찾을 수 없습니다. [python.org](https://www.python.org/downloads/)에서 설치한 뒤 셀을 다시 실행하세요.", "kernelDied": "커널이 종료되었습니다.", "localOnly": "노트북 셀은 이 컴퓨터의 파일에 대해서만 실행할 수 있습니다.", - "pythonOnly": "Orca에서는 Python 노트북만 실행할 수 있습니다." + "pythonOnly": "Orca에서는 Python 노트북만 실행할 수 있습니다.", + "commandCopied": "명령이 복사되었습니다.", + "createVenv": ".venv 만들기", + "createVenvItem": "가상 환경 만들기…", + "createVenvTitle": "가상 환경을 만들까요?", + "creatingButton": "만드는 중…", + "creatingVenvDescription": "{{folder}}에 .venv를 만들고 ipykernel을 설치하고 있습니다. 1분 정도 걸릴 수 있습니다.", + "creatingVenvTitle": "가상 환경 만드는 중…", + "installingButton": "설치 중…", + "installingDescription": "pip로 '{{env}}'에 ipykernel을 설치하고 있습니다. 1분 정도 걸릴 수 있습니다.", + "installingTitle": "ipykernel 설치 중…", + "kernelCreatingVenv": ".venv 만드는 중…", + "setupFailed": "실패했습니다. Python 출력:", + "tryAgain": "다시 시도", + "useAnotherPython": "다른 Python 사용…", + "venvDescription": "Orca가 '{{env}}'에서 {{folder}}에 .venv를 만들고, ipykernel을 설치한 뒤 이 노트북을 거기서 실행합니다." }, "MarkdownPreview": { "e4683f70c4": "취소", @@ -17156,6 +17227,13 @@ "dispatchRejected": "에이전트가 “continue” 메시지를 거부했습니다. 채팅을 열어 직접 보내세요.", "unsupported": "이 채팅은 Orca에서 재개할 수 없습니다. 열어서 중단된 위치를 확인하세요.", "fallback": "Orca가 이 채팅을 재개하지 못했습니다. 열어서 수동으로 계속하세요." + }, + "web": { + "preloadApi": { + "cursorAccounts": { + "hostOnly": "Cursor 로그인 정보는 Orca가 실행 중인 컴퓨터에서만 읽을 수 있습니다." + } + } } }, "i18n": { @@ -18186,6 +18264,52 @@ "unassignedInstruction": "초점이 맞춰진 창에서 음성 받아쓰기를 시작하기 전에 받아쓰기 바로가기를 할당하세요.", "settingsInstruction": "언제든지 모델, 받아쓰기 모드 또는 바로가기를 변경할 수 있습니다.", "settingsLink": "설정 → 음성" + }, + "sessionSearch": { + "ageHours": "2시간", + "ageMinutes": "12분", + "ageYesterday": "1일", + "agentsInstruction": "에이전트도 검색할 수 있습니다. 에이전트에게 “로그인 시간 초과를 수정한 세션을 찾아줘”라고 요청해 보세요.", + "consentInstruction": "언제든지 다음에서 변경하거나 끄기", + "continueInBackground": "백그라운드에서 계속하기", + "demoHit1Age": "3일", + "demoHit1Snippet": "[[로그인 시간 초과]]를 30초로 늘리고 토큰 새로고침에 재시도를 추가했습니다.", + "demoHit1Title": "불안정한 인증 리디렉션 수정", + "demoHit2Age": "1주", + "demoHit2Snippet": "절전 모드 해제 후 [[로그인]] 페이지가 [[시간 초과]] 후에 멈추는 이유는 무엇인가요?", + "demoHit2Title": "절전 후 세션 새로고침", + "demoHit3Age": "2주", + "demoHit3Snippet": "[[로그인 시간 초과]] 경로를 처음부터 끝까지 다루는 테스트를 추가했습니다.", + "demoHit3Title": "인증 e2e 강화", + "demoHit4Age": "5일", + "demoHit4Snippet": "[[롤백]]이 새 인덱스를 먼저 삭제한 뒤 이전 컬럼을 복원합니다.", + "demoHit4Title": "주문 테이블 마이그레이션", + "demoHit5Age": "3주", + "demoHit5Snippet": "다시 시도하기 전에 [[마이그레이션]] [[롤백]] 계획을 작성해 줄 수 있나요?", + "demoHit5Title": "스테이징 배포 실패", + "demoHit6Age": "1개월", + "demoHit6Snippet": "여기의 모든 [[마이그레이션]]은 enum 이름 변경을 제외하고 되돌릴 수 있습니다.", + "demoHit6Title": "스키마 검토 메모", + "demoQuery": "로그인 시간 초과", + "demoQuery2": "마이그레이션 롤백", + "demoRoleAssistant": "어시스턴트", + "demoRoleUser": "사용자", + "enableFailed": "세션 검색을 켜지 못했습니다. 다시 시도하세요.", + "indexingDescription": "Orca가 이전 에이전트 대화를 읽어 검색할 수 있도록 준비하고 있습니다. 몇 분 정도 걸릴 수 있습니다.", + "indexingTitle": "에이전트 세션을 색인화하는 중", + "progressStarting": "세션을 찾는 중…", + "readyDescription": "이제 이 컴퓨터의 모든 에이전트 세션을 대화 내용으로 찾을 수 있습니다.", + "readyTitle": "세션 검색이 준비되었습니다", + "readyToast": "세션 검색이 준비되었습니다", + "readyToastOpen": "열기", + "recent1Text": "검색 항목을 워크플로 아래로 옮기고 테스트를 업데이트했습니다.", + "recent1Title": "설정 사이드바 정리", + "recent2Text": "의존성을 업데이트하고 빌드가 여전히 통과하는지 확인하세요.", + "recent2Title": "잠금 파일 업데이트", + "recent3Text": "초안 메모입니다. 영역별로 정리했습니다.", + "recent3Title": "릴리스 노트 초안", + "settingsLink": "설정 → 에이전트 세션 검색", + "startSearching": "검색 시작하기" } }, "quickOpen": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b9dc67facaa..a8bb37c53f7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -34,7 +34,8 @@ "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。点击可查看工作区范围的端口和外部监听器。", "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用情况。", - "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用情况。" + "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用情况。", + "cursorToggleDescription": "通过 cursor-agent 或 Cursor IDE 登录后显示 Cursor 套餐用量。" }, "menuBarIcon": { "title": "显示菜单栏图标", @@ -869,7 +870,8 @@ "tooMany": "最多可附加 {{maxCount}} 张图片。", "dimensionsTooLarge": "{{fileName}} 的尺寸过大,无法安全预览。", "invalidImage": "{{fileName}} 不是有效的受支持图片。", - "additionalErrors": "还有 {{count}} 张图片无法附加。" + "additionalErrors": "还有 {{count}} 张图片无法附加。", + "totalTooLarge": "{{fileName}} 将使附件总大小超过 {{maxSize}}。" } } }, @@ -3093,7 +3095,7 @@ "missing": { "tui": { "title": "This ZCode build has no terminal UI", - "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + "description": "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." } } }, @@ -3666,7 +3668,8 @@ "grokUsageMenu": "Grok 使用情况", "floatingTerminalNewActivity": "{{label}},有新活动", "codexSignInSuccess": "已登录 Codex", - "codexSignInError": "Codex 登录失败,请重试。" + "codexSignInError": "Codex 登录失败,请重试。", + "cursorUsageMenu": "Cursor 用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -3841,6 +3844,13 @@ "noSubscription": { "label": "无订阅" } + }, + "cursor": { + "expired": { + "label": "登录已过期", + "message": "请在运行 Orca 的电脑上的终端中运行 cursor-agent login,然后重新刷新用量。" + }, + "plan": "套餐" } }, "SshTargetStatusRow": { @@ -5204,7 +5214,9 @@ "69969ba364": "正在发送...", "imageReadFailed": "无法读取附加的图片。请重新附加。", "attachWhileSending": "请等待当前反馈发送完成,再附加更多图片。", - "imagesNotDelivered": "反馈已发送,但无法确认图片已送达。" + "imagesNotDelivered": "反馈已发送,但无法确认图片已送达。", + "imagesNotIncluded": "反馈已发送。您的截图无法上传,未包含在内。", + "imagesRejected": "反馈已发送。您的截图过大,无法上传,未包含在内。" }, "SidebarFilter": { "e3b3898218": "添加项目", @@ -6164,7 +6176,8 @@ }, "SidebarFeedbackImageAttachments": { "attachImages": "Attach", - "removeImage": "Remove {{fileName}}" + "removeImage": "Remove {{fileName}}", + "screenshotsHint": "最多附加 {{count}} 张截图,总计 {{maxSize}}" }, "WorkspaceKanbanSearchField": { "bdb753c78d": "没有匹配的工作区", @@ -8863,7 +8876,18 @@ "7e2a4b8c1d": "海外", "37020a02c2": "安慰", "25591bf95b": "粘贴完整的 opencode.ai Cookie 标头,包括 __Host-console_session,以进行速率限制提取。", - "d16378a88f": "极小极大" + "d16378a88f": "极小极大", + "cursor": { + "description": "从此电脑上已有的 Cursor 登录(cursor-agent login)读取每月套餐用量。", + "kw": { + "cursor": "cursor", + "rateLimit": "速率限制", + "spend": "支出", + "statusBar": "状态栏", + "usage": "用量" + }, + "title": "Cursor 用量" + } } }, "advanced": { @@ -9119,6 +9143,13 @@ "close": "关闭", "notification": "通知区", "background": "背景" + }, + "cursor": { + "description": "显示来自此电脑上 Cursor 登录的每月 Cursor 套餐用量。", + "kw": { + "cursor": "cursor" + }, + "title": "Cursor 用量" } } }, @@ -11992,6 +12023,31 @@ "TerminalRenderingSection": { "6d4c55bacc": "内嵌图像", "fffab5890b": "使用 SIXEL、iTerm2 (IIP) 和 Kitty 图形协议直接在终端中显示图像。" + }, + "CursorAccountsSection": { + "dashboardLink": "Cursor 仪表盘", + "expired": "登录已过期 — 请在运行 Orca 的电脑上执行 cursor-agent login,然后点击“刷新用量”。", + "loading": "加载中…", + "noAllowance": "Cursor 报告此账号没有可用额度。", + "planTotal": "套餐", + "refreshUsage": "刷新用量", + "resets": "{{when}}重置", + "signedIn": "已登录", + "signedInFrom": "已登录。Orca 读取存储在{{source}}中的会话。", + "signedInGeneric": "已登录。Orca 读取存储在此电脑上的 Cursor 会话。", + "signedOut": "在此电脑上未找到 Cursor 登录", + "signedOutHelp": "请使用 Cursor IDE 登录,或在终端中运行 cursor-agent login,然后在此点击“刷新用量”。", + "source": { + "cli": "Cursor CLI 认证文件", + "desktop": "Cursor IDE", + "keychain": "macOS 钥匙串(cursor-agent)" + }, + "staleUsage": "上次已知用量 — 最近一次刷新失败:{{reason}}", + "subtitle": "显示来自此电脑上已有登录的每月 Cursor 套餐用量。Orca 仅读取,不会更改您的 Cursor 登录。", + "title": "Cursor", + "usageDescription": "Cursor 按两个随账单周期重置的额度池计费,用完后按需计费。", + "usageLabel": "用量", + "usageTitle": "每月套餐用量" } }, "right": { @@ -14933,7 +14989,22 @@ "noPython": "在此电脑上未找到 Python。请从 [python.org](https://www.python.org/downloads/) 安装,然后重新运行单元格。", "kernelDied": "内核已停止。", "localOnly": "笔记本单元格只能针对此电脑上的文件运行。", - "pythonOnly": "Orca 中只能运行 Python 笔记本。" + "pythonOnly": "Orca 中只能运行 Python 笔记本。", + "commandCopied": "命令已复制", + "createVenv": "创建 .venv", + "createVenvItem": "创建虚拟环境…", + "createVenvTitle": "创建虚拟环境?", + "creatingButton": "正在创建…", + "creatingVenvDescription": "正在 {{folder}} 中创建 .venv 并安装 ipykernel。这可能需要一分钟。", + "creatingVenvTitle": "正在创建虚拟环境…", + "installingButton": "正在安装…", + "installingDescription": "正在使用 pip 将 ipykernel 安装到“{{env}}”。这可能需要一分钟。", + "installingTitle": "正在安装 ipykernel…", + "kernelCreatingVenv": "正在创建 .venv…", + "setupFailed": "操作失败。Python 输出:", + "tryAgain": "重试", + "useAnotherPython": "使用其他 Python…", + "venvDescription": "Orca 将基于“{{env}}”在 {{folder}} 中创建 .venv,安装 ipykernel,并在其中运行此笔记本。" }, "MarkdownPreview": { "e4683f70c4": "取消", @@ -17156,6 +17227,13 @@ "dispatchRejected": "代理拒绝了“continue”消息。请打开聊天并自行发送一条。", "unsupported": "此聊天无法由 Orca 恢复。请将其打开以查看停止位置。", "fallback": "Orca 无法恢复此聊天。请将其打开以手动继续。" + }, + "web": { + "preloadApi": { + "cursorAccounts": { + "hostOnly": "Cursor 登录详情仅可在运行 Orca 的电脑上读取。" + } + } } }, "i18n": { @@ -18186,6 +18264,52 @@ "unassignedInstruction": "在焦点窗格中开始语音听写之前指定听写快捷方式。", "settingsInstruction": "随时更改模型、听写模式或快捷方式", "settingsLink": "设置 → 语音" + }, + "sessionSearch": { + "ageHours": "2小时", + "ageMinutes": "12分钟", + "ageYesterday": "1天", + "agentsInstruction": "您的代理也可以搜索。让它“找到我们修复登录超时的那次会话”。", + "consentInstruction": "随时可在以下位置更改或关闭", + "continueInBackground": "在后台继续", + "demoHit1Age": "3天", + "demoHit1Snippet": "将 [[登录超时]] 延长至 30 秒,并在令牌刷新时添加了重试。", + "demoHit1Title": "修复不稳定的身份验证重定向", + "demoHit2Age": "1周", + "demoHit2Snippet": "为什么唤醒后 [[登录]] 页面在 [[超时]] 后卡住?", + "demoHit2Title": "睡眠后会话刷新", + "demoHit3Age": "2周", + "demoHit3Snippet": "添加了覆盖 [[登录超时]] 路径的端到端测试。", + "demoHit3Title": "身份验证端到端加固", + "demoHit4Age": "5天", + "demoHit4Snippet": "[[回滚]] 会先删除新索引,再恢复旧列。", + "demoHit4Title": "订单表迁移", + "demoHit5Age": "3周", + "demoHit5Snippet": "在重试之前,你能写一份 [[迁移]] [[回滚]] 计划吗?", + "demoHit5Title": "预发布部署失败", + "demoHit6Age": "1个月", + "demoHit6Snippet": "这里的每个 [[迁移]] 都是可逆的,除了枚举重命名。", + "demoHit6Title": "表结构评审记录", + "demoQuery": "登录超时", + "demoQuery2": "迁移回滚", + "demoRoleAssistant": "助手", + "demoRoleUser": "用户", + "enableFailed": "无法开启会话搜索。请重试。", + "indexingDescription": "Orca 正在读取您过去的代理会话记录,以便您可以搜索它们。这可能需要几分钟。", + "indexingTitle": "正在索引您的代理会话", + "progressStarting": "正在查找您的会话…", + "readyDescription": "现在可以通过会话内容找到此电脑上的每个代理会话。", + "readyTitle": "会话搜索已就绪", + "readyToast": "会话搜索已就绪", + "readyToastOpen": "打开", + "recent1Text": "将搜索入口移至“工作流程”下,并更新了测试。", + "recent1Title": "整理设置侧边栏", + "recent2Text": "更新依赖项并确保构建仍然通过。", + "recent2Title": "更新 lockfile", + "recent3Text": "这是按领域分组的记录初稿。", + "recent3Title": "发布说明草稿", + "settingsLink": "设置 → 代理会话搜索", + "startSearching": "开始搜索" } }, "quickOpen": { From 85ac14e9c263aeb3441fd6e9e8a8c87f598db4ef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:37:54 -0700 Subject: [PATCH 042/387] fix(codex): retain runtime MCP entries without losing revocation (#22426) * Retain runtime-only MCP entries Adapted from the investigation and proposal by @mmarabel. Co-authored-by: mmarabel * fix(codex): respect inline and dotted canonical MCP ownership * fix(codex): retain canonical MCP removal across upgrades * fix(types): include MCP ownership in CLI project * Keep unrelated main test formatting unchanged --------- Co-authored-by: mmarabel --- config/tsconfig.cli.json | 1 + .../codex-config-mirror-mcp-ownership.test.ts | 159 ++++++++++++++++++ src/main/codex/codex-config-mirror.test.ts | 94 +++++++++++ src/main/codex/codex-config-mirror.ts | 81 +++++++-- src/main/codex/config-settings-baseline.ts | 24 ++- src/main/codex/config-settings-promotion.ts | 27 ++- src/main/codex/config-toml-line-scan.ts | 14 +- .../codex/config-toml-mcp-servers.test.ts | 67 ++++++++ src/main/codex/config-toml-mcp-servers.ts | 42 +++++ .../config-toml-runtime-owned-sections.ts | 12 ++ 10 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 src/main/codex/codex-config-mirror-mcp-ownership.test.ts create mode 100644 src/main/codex/config-toml-mcp-servers.test.ts create mode 100644 src/main/codex/config-toml-mcp-servers.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f9bcf82c52d..12d4d2fe785 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -92,6 +92,7 @@ "../src/main/codex/config-toml-hook-trust-read.ts", "../src/main/codex/config-toml-key-path.ts", "../src/main/codex/config-toml-line-scan.ts", + "../src/main/codex/config-toml-mcp-servers.ts", "../src/main/codex/config-toml-project-trust.ts", "../src/main/codex/config-toml-runtime-owned-sections.ts", "../src/main/codex/config-toml-syntax.ts", diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts new file mode 100644 index 00000000000..85121996a06 --- /dev/null +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + syncSystemConfigIntoManagedCodexHome, + syncSystemConfigIntoLegacySharedCodexHome +} from './codex-config-mirror' + +let root: string +let runtimeHomePath: string +let systemHomePath: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-mcp-ownership-')) + runtimeHomePath = join(root, 'runtime') + systemHomePath = join(root, 'system') + mkdirSync(runtimeHomePath) + mkdirSync(systemHomePath) +}) + +afterEach(() => rmSync(root, { recursive: true, force: true })) + +describe('canonical MCP ownership during config mirroring', () => { + it('does not duplicate a server defined inline in the canonical MCP table', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + '[mcp_servers]\nshared = { command = "system" }\n' + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('shared = { command = "system" }') + expect(runtimeConfig).not.toContain('[mcp_servers.shared]') + }) + + it('preserves deletion after a canonical inline server is rewritten by the runtime', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + '[mcp_servers]\nshared = { command = "system" }\n' + ) + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "system"\n[mcp_servers.runtime_only]\ncommand = "runtime"\n' + ) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).not.toContain('[mcp_servers.shared]') + expect(runtimeConfig).toContain('[mcp_servers.runtime_only]') + }) + + it('honors a closed canonical MCP root and its later removal', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.runtime_only]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + 'mcp_servers = { shared = { enabled = false } }\n' + ) + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + expect( + JSON.parse( + readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8') + ) + ).toMatchObject({ mcpServerRoot: true }) + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.shared]\nenabled = false\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) +}) + +describe('MCP ownership migration', () => { + it.each([1, 2, 3])( + 'keeps pre-ownership baseline version %s canonical for one pass', + (version) => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.removed]\ncommand = "old"\n' + ) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeFileSync( + join(runtimeHomePath, '.orca-config-settings-baseline.json'), + JSON.stringify({ version, settings: {} }) + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + expect( + JSON.parse( + readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8') + ) + ).toMatchObject({ mcpServers: [] }) + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.added]\ncommand = "new"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.added]' + ) + } + ) + + it('keeps the retained shared home one-way without an ownership baseline', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) + + it('tracks commented CRLF names so their later removal remains authoritative', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + '[mcp_servers.shared]\ncommand = "runtime"\n' + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + '[mcp_servers.shared] # see [docs]\r\ncommand = "system"\r\n' + ) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain('"runtime"') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.shared]' + ) + }) +}) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 2469a2be724..74db1cd9c05 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -52,6 +52,10 @@ function getRuntimeConfigPath(): string { return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') } +function getRuntimeBaselinePath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', '.orca-config-settings-baseline.json') +} + beforeEach(() => { fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) @@ -364,6 +368,96 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) + it('preserves runtime-only MCP servers and nested descendants with system precedence', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[mcp_servers.runtime_only]', + 'command = "runtime-command"', + '', + '[mcp_servers.runtime_only.env]', + 'MODE = "runtime"', + '', + '[mcp_servers.shared]', + 'command = "runtime-shared"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [ + '[mcp_servers."shared"]', + 'command = "system-shared"', + '', + '[mcp_servers.system_only]', + 'command = "system-only"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('[mcp_servers.runtime_only]') + expect(runtimeConfig).toContain('[mcp_servers.runtime_only.env]') + expect(runtimeConfig).toContain('MODE = "runtime"') + expect(runtimeConfig).toContain('command = "system-shared"') + expect(runtimeConfig).not.toContain('runtime-shared') + expect(runtimeConfig.match(/\[mcp_servers\.(?:shared|"shared")\]/g)).toHaveLength(1) + expect(runtimeConfig).toContain('[mcp_servers.system_only]') + expect(JSON.parse(readFileSync(getRuntimeBaselinePath(), 'utf-8'))).toMatchObject({ + mcpServers: ['shared', 'system_only'] + }) + }) + + it('revokes a previously mirrored MCP server when the system source deletes it', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), '[mcp_servers.revoked]\ncommand = "run"\n', 'utf-8') + writeFileSync(getSystemConfigPath(), '[mcp_servers.revoked]\ncommand = "run"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + writeFileSync(getSystemConfigPath(), 'model = "system"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).not.toContain('[mcp_servers.revoked]') + }) + + it('keeps runtime-only MCP additions after a source refresh and remains byte-idempotent', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync(getRuntimeConfigPath(), '[mcp_servers.runtime_only]\ncommand = "run"\n', 'utf-8') + writeFileSync(getSystemConfigPath(), 'model = "system"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + const first = readFileSync(getRuntimeConfigPath(), 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(first) + expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toContain('[mcp_servers.runtime_only]') + }) + + it('keeps an explicit system MCP disable canonical', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + '[mcp_servers.blocked]\ncommand = "runtime"\nenabled = true\n', + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + '[mcp_servers."blocked"]\ncommand = "system"\nenabled = false\n', + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('enabled = false') + expect(runtimeConfig).not.toContain('enabled = true') + }) + it('deduplicates basic and literal project headers by decoded Windows path', () => { mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index af6f010da18..275e350328a 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,3 +1,4 @@ +import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' import { dirname, join } from 'node:path' import { observeAgentStateFile } from './codex-path-observation' import { @@ -21,6 +22,7 @@ import { preserveRuntimeConflictValues } from './codex-config-settings-preservat import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' import { deduplicateProjectTomlSections, + getMcpServerTomlSectionName, getProjectTrustLevel, getRevocationTomlSectionHeaderKey, getTomlSectionHeaderKey, @@ -109,7 +111,9 @@ function mirrorSystemConfigIntoManagedCodexHome(homes: CodexSettingsPromotionHom ), // Why: this pass made the runtime's marketplace and plugin tables canonical, // so a later source config that lacks one is a removal, not an addition. - mirroredRegistrations: true + mirroredRegistrations: true, + mirroredMcpServers: mirrorResult.mirroredMcpServerNames, + mirroredMcpServerRoot: mirrorResult.mirroredMcpServerRoot }) return true } @@ -167,11 +171,14 @@ export function syncSystemConfigIntoLegacySharedCodexHome( let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) + // The retired home has no ownership baseline; its entire MCP root stays canonical. mirroredRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, - prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), + new Set(), + true ) : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) } @@ -191,7 +198,12 @@ export function syncSystemConfigIntoLegacySharedCodexHome( type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'refused-indeterminate'; error: unknown } - | { status: 'mirrored'; preservedConflictKeys: ReadonlySet } + | { + status: 'mirrored' + preservedConflictKeys: ReadonlySet + mirroredMcpServerNames: ReadonlySet + mirroredMcpServerRoot: boolean + } function syncSystemConfigIntoManagedCodexHomeUnsafe( { runtimeHomePath, systemHomePath, systemConfigDir }: CodexSettingsPromotionHomes, @@ -225,34 +237,55 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( ) return runtimeConfigExists ? { status: 'skipped-missing-source' } - : { status: 'mirrored', preservedConflictKeys: new Set() } + : { + status: 'mirrored', + preservedConflictKeys: new Set(), + mirroredMcpServerNames: new Set(), + mirroredMcpServerRoot: false + } } const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir) if (!runtimeConfigExists) { - writeFileAtomically( - runtimeConfigPath, - applyCodexDaemonSocketGuard( - prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir), - runtimeHomePath - ) + const freshRuntimeConfig = applyCodexDaemonSocketGuard( + prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir), + runtimeHomePath ) - return { status: 'mirrored', preservedConflictKeys: new Set() } + const ownership = readMcpServerTomlOwnership(freshRuntimeConfig) + writeFileAtomically(runtimeConfigPath, freshRuntimeConfig) + return { + status: 'mirrored', + preservedConflictKeys: new Set(), + mirroredMcpServerNames: ownership.names, + mirroredMcpServerRoot: ownership.ownsRoot + } } const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) + const { names: mirroredMcpServerNames, ownsRoot: mirroredMcpServerRoot } = + readMcpServerTomlOwnership(systemConfig) // Why: reuse the bytes already observed above rather than re-reading. A second // read could succeed where the first failed and re-open the gap this closes. const runtimeConfig = runtimeConfigObservation.value const preserved = preserveRuntimeConflictValues( - mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig), + mergeSystemCodexConfigIntoRuntime( + runtimeConfig, + systemConfig, + promotionPlan.mirroredMcpServers, + promotionPlan.mirroredMcpServerRoot + ), promotionPlan.runtimeValuesToPreserve ) const nextRuntimeConfig = applyCodexDaemonSocketGuard(preserved.content, runtimeHomePath) if (nextRuntimeConfig !== runtimeConfig) { writeFileAtomically(runtimeConfigPath, nextRuntimeConfig) } - return { status: 'mirrored', preservedConflictKeys: preserved.keys } + return { + status: 'mirrored', + preservedConflictKeys: preserved.keys, + mirroredMcpServerNames, + mirroredMcpServerRoot + } } export function resolveCodexConfigMirrorSourceDirectory( @@ -284,7 +317,12 @@ export function prepareSystemConfigForFreshRuntimeMirror( return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir)) } -function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { +function mergeSystemCodexConfigIntoRuntime( + runtimeConfig: string, + systemConfig: string, + mirroredMcpServerNames: ReadonlySet = new Set(), + mirroredMcpServerRoot = false +): string { const runtimeSections = deduplicateProjectTomlSections(getTomlSections(runtimeConfig)) const runtimeProjectHeaders = new Set( runtimeSections @@ -307,6 +345,7 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter((section) => getProjectTrustLevel(section.block) === 'trusted') .map((section) => getTomlSectionHeaderKey(section.header)) ) + const systemMcpServers = readMcpServerTomlOwnership(systemConfig) // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook // trust and project trust are written under Orca's managed CODEX_HOME and // must survive the copy unless the user explicitly revoked project trust in @@ -314,7 +353,19 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: return joinTomlBlocks([ stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), ...runtimeSections - .filter((section) => isRuntimePreservedTomlSection(section.header)) + .filter((section) => { + if (isRuntimePreservedTomlSection(section.header)) { + return true + } + const mcpServerName = getMcpServerTomlSectionName(section.header) + return ( + mcpServerName !== null && + !systemMcpServers.ownsRoot && + !mirroredMcpServerRoot && + !systemMcpServers.names.has(mcpServerName) && + !mirroredMcpServerNames.has(mcpServerName) + ) + }) .filter( (section) => !isRuntimeProjectTomlSection(section.header) || diff --git a/src/main/codex/config-settings-baseline.ts b/src/main/codex/config-settings-baseline.ts index 5cc26e361b9..d6c44909ef8 100644 --- a/src/main/codex/config-settings-baseline.ts +++ b/src/main/codex/config-settings-baseline.ts @@ -21,6 +21,9 @@ export type CodexSettingsBaseline = { * table reads as an addition rather than as a canonical removal. */ registrations: ReadonlyMap> + /** MCP server names the last mirror copied from the canonical source. */ + mcpServers: ReadonlySet + mcpServerRoot: boolean } type StoredSettingsBaseline = { @@ -28,6 +31,8 @@ type StoredSettingsBaseline = { settings: Record conflicts?: Record registrations?: Record> + mcpServers?: string[] + mcpServerRoot?: boolean } /** @@ -81,7 +86,14 @@ function readParsedCodexSettingsBaseline( conflicts.set(key, conflict) } } - return { settings, conflicts, registrations: readStoredRegistrations(parsed.registrations) } + return { + settings, + conflicts, + registrations: readStoredRegistrations(parsed.registrations), + mcpServers: readStoredMcpServers(parsed.mcpServers), + // Older mirrors owned the whole MCP root; retain that removal policy for one pass. + mcpServerRoot: parsed.mcpServers === undefined || parsed.mcpServerRoot === true + } } catch (error) { // Why: invalid baseline state is still `null` — resetting it is the intent, // and only a read that FAILED must be preserved. @@ -89,6 +101,10 @@ function readParsedCodexSettingsBaseline( } } +function readStoredMcpServers(stored: string[] | undefined): ReadonlySet { + return new Set((stored ?? []).filter((name): name is string => typeof name === 'string')) +} + function readStoredRegistrations( stored: Record> | undefined ): Map> { @@ -124,7 +140,8 @@ export function writeCodexSettingsBaseline( ): void { const file: StoredSettingsBaseline = { version: 3, - settings: Object.fromEntries(baseline.settings) + settings: Object.fromEntries(baseline.settings), + mcpServers: [...baseline.mcpServers] } if (baseline.conflicts.size > 0) { file.conflicts = Object.fromEntries(baseline.conflicts) @@ -134,6 +151,9 @@ export function writeCodexSettingsBaseline( [...baseline.registrations].map(([key, fields]) => [key, Object.fromEntries(fields)]) ) } + if (baseline.mcpServerRoot) { + file.mcpServerRoot = true + } const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) const serialized = `${JSON.stringify(file, null, 2)}\n` let existing: string | null = null diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 52f4d6eefcd..56e499a6c18 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -37,6 +37,9 @@ export type CodexSettingsBaselineSnapshotOptions = { * mirrored would read a source config that never had them as a removal. */ mirroredRegistrations?: boolean + /** Names copied from the canonical source in this mirror pass. */ + mirroredMcpServers?: ReadonlySet + mirroredMcpServerRoot?: boolean } /** @@ -71,7 +74,9 @@ export function snapshotCodexRuntimeSettingsBaseline( conflicts, registrations: options.mirroredRegistrations ? readCodexRegistrationBaseline(runtimeConfig) - : new Map() + : new Map(), + mcpServers: options.mirroredMcpServers ?? new Set(), + mcpServerRoot: options.mirroredMcpServerRoot ?? false }) } catch (error) { console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) @@ -88,6 +93,9 @@ export type CodexSettingsPromotionHomes = { export type CodexSettingsPromotionPlan = { conflicts: ReadonlyMap runtimeValuesToPreserve: ReadonlyMap + /** MCP names the previous mirror copied from the canonical source. */ + mirroredMcpServers: ReadonlySet + mirroredMcpServerRoot: boolean } function getHostPromotionHomes(): CodexSettingsPromotionHomes { @@ -142,6 +150,8 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( throw new Error('Codex settings baseline could not be read') } const baseline = baselineObservation.kind === 'present' ? baselineObservation.baseline : null + const mirroredMcpServers = baseline?.mcpServers ?? new Set() + const mirroredMcpServerRoot = baseline?.mcpServerRoot ?? false const updates = new Map() const conflicts = new Map() const runtimeValuesToPreserve = new Map() @@ -160,7 +170,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // canonical is an addition, never a removal it must honor. Scalars still need a // real baseline, so they stay gated above. if (updates.size === 0 && !hasCodexRegistrationEntries(runtimeTomlObservation.value)) { - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } // Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it. mkdirSync(systemHomePath, { recursive: true, mode: 0o700 }) @@ -202,17 +212,17 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( ) ) if (nextContent === systemContent) { - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } if (targetExists && parseWslUncPath(writeTarget.path)) { // Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode. writeFileSync(writeTarget.path, nextContent, 'utf-8') - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } writeFileAtomically(writeTarget.path, nextContent, { mode: writeTarget.mode }) - return { conflicts, runtimeValuesToPreserve } + return { conflicts, runtimeValuesToPreserve, mirroredMcpServers, mirroredMcpServerRoot } } type PromotionCollectionContext = { @@ -264,7 +274,12 @@ function getComparableRaw(value: TopLevelSettingValue | undefined): string | nul } function emptyPromotionPlan(): CodexSettingsPromotionPlan { - return { conflicts: new Map(), runtimeValuesToPreserve: new Map() } + return { + conflicts: new Map(), + runtimeValuesToPreserve: new Map(), + mirroredMcpServers: new Set(), + mirroredMcpServerRoot: false + } } // Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config. diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts index 98d096167fe..0e983aac2dd 100644 --- a/src/main/codex/config-toml-line-scan.ts +++ b/src/main/codex/config-toml-line-scan.ts @@ -95,8 +95,18 @@ export function updateTomlLineScanState(state: TomlLineScanState, line: string): } export function getTomlTableHeader(line: string): string | null { - const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line) - return match?.[1] ?? null + let index = 0 + while (index < line.length && line[index] !== '#') { + if (line[index] === '"') { + index = skipTomlBasicString(line, index + 1) + } else if (line[index] === "'") { + index = skipTomlLiteralString(line, index + 1) + } else { + index++ + } + } + const header = line.slice(0, index).trimEnd() + return /^\s*\[\[?.+\]\]?$/.test(header) ? header : null } export function parseTomlSingleLineStringValue( diff --git a/src/main/codex/config-toml-mcp-servers.test.ts b/src/main/codex/config-toml-mcp-servers.test.ts new file mode 100644 index 00000000000..e1d57770074 --- /dev/null +++ b/src/main/codex/config-toml-mcp-servers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { readMcpServerTomlOwnership } from './config-toml-mcp-servers' +import { getTomlTableHeader } from './config-toml-line-scan' + +describe('MCP server TOML ownership', () => { + it.each([ + '[mcp_servers."server.with.dot"]\ncommand = "agent"', + '[mcp_servers]\n"server.with.dot" = { command = "agent" }', + 'mcp_servers."server.with.dot".command = "agent"', + '[mcp_servers."server.with.dot".env]\nMODE = "fixture"', + '[mcp_servers."server.with.dot"] # see [docs]\ncommand = "agent"', + '[mcp_servers."server.with.dot"] # server\r\ncommand = "agent"\r\n', + '[[mcp_servers."server.with.dot"]] # array\r\ncommand = "agent"' + ])('recognizes the decoded server name in %s', (config) => { + expect(readMcpServerTomlOwnership(config)).toEqual({ + names: new Set(['server.with.dot']), + ownsRoot: false + }) + }) + + it('treats a root assignment as ownership of the whole closed table', () => { + expect(readMcpServerTomlOwnership('"mcp_servers" = { shared = { enabled = false } }')).toEqual({ + names: new Set(), + ownsRoot: true + }) + }) + + it.each([ + '[profile] # comment\r\nmcp_servers = { foo = {} }\r\n', + '[profile] # see [docs]\nmcp_servers = { foo = {} }\n', + '[not valid]\nmcp_servers = { foo = {} }\n' + ])('does not treat a nested assignment as a canonical root in %s', (config) => { + expect(readMcpServerTomlOwnership(config)).toEqual({ names: new Set(), ownsRoot: false }) + }) + + it('ignores apparent keys in strings, arrays and unrelated tables', () => { + const config = [ + 'description = """', + '[mcp_servers.fake]', + 'mcp_servers = {}', + '"""', + 'args = [', + '"mcp_servers.quoted = {}",', + ']', + '[profile]', + 'mcp_servers = {}', + '[mcp_servers.real]', + 'command = "agent"' + ].join('\n') + expect(readMcpServerTomlOwnership(config)).toEqual({ + names: new Set(['real']), + ownsRoot: false + }) + }) +}) + +describe('commented TOML headers', () => { + it.each<[string, string | null]>([ + ['[mcp_servers."name#with]bracket"] # see [docs]\r', '[mcp_servers."name#with]bracket"]'], + ['[[mcp_servers.name]] # comment\r', '[[mcp_servers.name]]'], + ["[mcp_servers.'literal#name'] # comment", "[mcp_servers.'literal#name']"], + ['# [mcp_servers.fake]', null], + ['command = "[mcp_servers.fake]"', null] + ])('recognizes the structural header in %s', (line, header) => { + expect(getTomlTableHeader(line)).toBe(header) + }) +}) diff --git a/src/main/codex/config-toml-mcp-servers.ts b/src/main/codex/config-toml-mcp-servers.ts new file mode 100644 index 00000000000..12a492a7c88 --- /dev/null +++ b/src/main/codex/config-toml-mcp-servers.ts @@ -0,0 +1,42 @@ +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +/** Canonical inline root assignments own the whole table, which TOML forbids extending. */ +export function readMcpServerTomlOwnership(config: string): { + names: ReadonlySet + ownsRoot: boolean +} { + const names = new Set() + let ownsRoot = false + let tablePath: string[] | null = [] + let state = createTomlLineScanState() + for (const line of config.split('\n')) { + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + tablePath = parseTomlTableHeaderPath(header)?.segments ?? null + if (tablePath?.[0] === 'mcp_servers' && tablePath[1] !== undefined) { + names.add(tablePath[1]) + } + } else { + const key = parseTomlKeyPath(line) + const path = + tablePath && key && line[key.end] === '=' ? [...tablePath, ...key.segments] : [] + if (path[0] === 'mcp_servers') { + if (path[1] === undefined) { + ownsRoot = true + } else { + names.add(path[1]) + } + } + } + } + state = updateTomlLineScanState(state, line) + } + return { names, ownsRoot } +} diff --git a/src/main/codex/config-toml-runtime-owned-sections.ts b/src/main/codex/config-toml-runtime-owned-sections.ts index 0ff9ab19f6e..fee0cfdf643 100644 --- a/src/main/codex/config-toml-runtime-owned-sections.ts +++ b/src/main/codex/config-toml-runtime-owned-sections.ts @@ -5,6 +5,7 @@ import { isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' +import { parseTomlTableHeaderPath } from './config-toml-key-path' import { normalizeCodexProjectPathForLookup, normalizeCodexProjectPathForRevocationLookup, @@ -91,6 +92,17 @@ export function isRuntimeProjectTomlSection(header: string): boolean { return parseCodexProjectHeaderPath(header) !== null } +const CODEX_MCP_SERVER_TABLE_ROOT = 'mcp_servers' + +/** Returns the decoded MCP server name for an owner table or nested descendant. */ +export function getMcpServerTomlSectionName(header: string): string | null { + const table = parseTomlTableHeaderPath(header) + if (!table || table.isArray || table.segments[0] !== CODEX_MCP_SERVER_TABLE_ROOT) { + return null + } + return table.segments[1] ?? null +} + export function getTomlSectionHeaderKey(header: string): string { const projectPath = parseCodexProjectHeaderPath(header) return projectPath === null From 58d1ff3b6abdabee4bb1ed6ef9809b9d4b61561e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 21:55:21 -0700 Subject: [PATCH 043/387] Provide the Orca CLI automatically in managed WSL terminals (#22761) * Provide the Orca CLI automatically in managed WSL terminals * Simplify managed WSL CLI provisioning Never block a shell on CLI availability, keep the shared WSL login-shell builder unchanged, provision from PTY env assembly only, drop the error variable and command probing, and reuse the existing WSLENV helper. * Scope the managed WSL CLI to WSL terminals Provision only for WSL panes and publish the directory through addOrcaWslInteropEnv, so daemon terminals keep inherited WSLENV entries and non-WSL builds never see the variable. Write the bridge with a UTF-8 BOM so Windows PowerShell 5.1 keeps non-ASCII user-data paths, give the dev bridge the dev launcher's app-launch env, and drop the unused skill-setup wiring and runtime capability. * Tighten the managed WSL CLI bridge and setup Launch the bridge child exactly like the registered bridge (no hidden window or output relay; verified through WSL with Node and Electron), give the dev bridge the dev launcher's NODE_OPTIONS stash, clear the guest-only directory before starting Windows processes, collapse setup into one function, warn once, and guard WSL env routing with tests. * Harden managed WSL CLI quoting and inheritance PowerShell also ends single-quoted strings at typographic quotes, so a user-data path such as O'Brien with a curly apostrophe broke the managed bridge. Fix the shared quotePowerShellLiteral and reuse it. Drop an inherited ORCA_WSL_CLI_DIR on the daemon path, remove the unreachable PATH dedupe, and cover failed setup with a stale caller value. * Cover the managed WSL CLI in zsh and on POSIX CI Add a live zsh case that reaches a real prompt, a POSIX test that runs the PATH restore snippet in bash and zsh under set -u, and a null result for unwritable user data. Say what a failed write actually costs, and document per-spawn write logging and older-daemon behaviour. * Keep system bashrc out of the PATH restore test CI runners make bash -c read /etc/bash.bashrc, which fails under set -u. --------- Co-authored-by: Orca Worker --- docs/reference/windows-edr-posture.md | 4 +- docs/reference/wsl-managed-cli.md | 37 +++++ .../local-bash-rcfile.txt | 7 + .../local-zsh-zshenv.txt | 7 + .../cli/wsl-cli-powershell-boundary.test.ts | 60 +++++++- src/main/cli/wsl-cli-scripts.ts | 81 ++++++++-- src/main/cli/wsl-managed-cli.test.ts | 68 +++++++++ src/main/cli/wsl-managed-cli.ts | 53 +++++++ src/main/cli/wsl-managed-cli.wsl.test.ts | 142 ++++++++++++++++++ .../daemon-zsh-shell-ready-wrapper-spec.ts | 1 + .../pty-subprocess-env-inheritance.test.ts | 5 +- .../pty-subprocess/spawn-environment.ts | 4 +- .../pty-serializer-settlement-mapping.test.ts | 20 ++- src/main/ipc/pty/host-env/assembly.ts | 11 +- .../local-pty-shell-ready-bash-rcfile.ts | 2 + .../local-pty-shell-ready-wrapper-fileset.ts | 1 + src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 2 + src/main/shell-wrapper-file-writer.ts | 5 +- src/main/wsl-managed-cli-path-restore.test.ts | 59 ++++++++ src/main/wsl-managed-cli-path-restore.ts | 8 + src/main/zsh-startup-wrapper-builder.ts | 4 + src/relay/pty-shell-overlay-wrappers.ts | 1 + src/shared/powershell-native-argument.test.ts | 3 + src/shared/powershell-native-argument.ts | 3 +- 25 files changed, 566 insertions(+), 24 deletions(-) create mode 100644 docs/reference/wsl-managed-cli.md create mode 100644 src/main/cli/wsl-managed-cli.test.ts create mode 100644 src/main/cli/wsl-managed-cli.ts create mode 100644 src/main/cli/wsl-managed-cli.wsl.test.ts create mode 100644 src/main/wsl-managed-cli-path-restore.test.ts create mode 100644 src/main/wsl-managed-cli-path-restore.ts diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index 1ba19df87ea..f1e63657f76 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -203,7 +203,9 @@ unquoted parameter string on whitespace. One site still spells `-ExecutionPolicy Bypass` with **no** encoding, the weaker signal: `src/main/cli/wsl-cli-scripts.ts` (`-File`, and it is a real script -file, so the switch is not a no-op there). `src/main/system-fonts.ts` dropped it +file, so the switch is not a no-op there). Every Orca-managed WSL terminal now +reaches it by default on each CLI call (`docs/reference/wsl-managed-cli.md`), not +only after the user registers the WSL CLI. `src/main/system-fonts.ts` dropped it for plain `-Command`; `src/shared/secure-path-windows-acl.ts` no longer runs PowerShell at all, having moved to `icacls.exe`; and computer use now asks for `-ExecutionPolicy RemoteSigned` in diff --git a/docs/reference/wsl-managed-cli.md b/docs/reference/wsl-managed-cli.md new file mode 100644 index 00000000000..8ae3b61aa62 --- /dev/null +++ b/docs/reference/wsl-managed-cli.md @@ -0,0 +1,37 @@ +# CLI access in Orca-managed WSL shells + +A WSL terminal on a Windows host gets this app's CLI (`orca-ide` packaged, +`orca-dev` in development) on its PATH with nothing installed in the guest: +`~/.local/bin`, shell profiles, and the Windows user PATH are untouched. External +WSL shells still need Settings → General registration. + +1. **Host.** `buildPtyHostEnv` calls `getManagedWslCliDir` for WSL panes only. It + writes a launcher and PowerShell bridge (reusing `wsl-cli-scripts.ts`) under + `/wsl-managed-cli/` and exports `ORCA_WSL_CLI_DIR`. + Content addressing follows `shell-wrapper-content-address.ts`: builds sharing + user data never overwrite each other, and a present file is complete because + each one lands by rename. Old directories are not collected. +2. **Crossing.** `addOrcaWslInteropEnv` adds `ORCA_WSL_CLI_DIR/p`, so both the + daemon and in-process spawn paths translate it with the distro's own mounts. +3. **Guest.** `WSL_MANAGED_CLI_PATH_RESTORE` runs after user startup files in the + bash rcfile and the local zsh first-prompt hook, which run once per Orca shell. + It leads PATH with the directory when `$ORCA_WSL_CLI_DIR/$ORCA_CLI_COMMAND` is + executable, and otherwise prints one warning. Other login shells get no CLI; + nothing blocks a shell. + +The colocated launcher finds its bridge beside itself and PowerShell by Windows +path, so neither guest PATH nor the automount root matters. The bridge pins this +app's user-data directory and is written with a UTF-8 BOM so Windows PowerShell 5.1 +reads non-ASCII paths correctly. It clears `ORCA_WSL_CLI_DIR`, which WSLENV maps +back to Windows, so an app the CLI starts never inherits it. In development it runs +Electron as Node on `out/cli/index.js` directly, with the environment +`buildWindowsDevLauncher` sets (`ORCA_APP_EXECUTABLE`, stashed `NODE_OPTIONS`). +Otherwise it launches its child exactly like the registered bridge. + +A missing runtime (logged once) or a failed write (logged per spawn) leaves +`ORCA_WSL_CLI_DIR` unset. A terminal daemon from an older build adds no WSLENV +entry, so its WSL panes lack the CLI until the daemon restarts. + +Run the opt-in end-to-end test on Windows with `ORCA_BACKGROUND_LAUNCH=1`, +`ORCA_TEST_MANAGED_WSL=1`, and optionally `ORCA_TEST_WSL_DISTRO=`. The zsh +case skips when the distro lacks `zsh` or `script`. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index a11d3e6183e..a541bb9769b 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -33,6 +33,13 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then + if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then + export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" + else + printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 + fi +fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 35a262b5e02..674ac3a9e37 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -60,6 +60,13 @@ __orca_deferred_init() { else precmd_functions=(${precmd_functions:#__orca_deferred_init}) fi + if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then + if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then + export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" + else + printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 + fi + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { diff --git a/src/main/cli/wsl-cli-powershell-boundary.test.ts b/src/main/cli/wsl-cli-powershell-boundary.test.ts index 6d9798bf38a..500d1a29f8f 100644 --- a/src/main/cli/wsl-cli-powershell-boundary.test.ts +++ b/src/main/cli/wsl-cli-powershell-boundary.test.ts @@ -154,6 +154,7 @@ describe('WSL CLI PowerShell boundary', () => { ], { encoding: 'utf8', + windowsHide: true, env: { ...process.env, ORCA_CLI_CWD: 'stale', @@ -182,7 +183,7 @@ describe('WSL CLI PowerShell boundary', () => { '-e', 'process.exit(23)' ], - { encoding: 'utf8' } + { encoding: 'utf8', windowsHide: true } ) expect(exitResult.error).toBeUndefined() expect(exitResult.status).toBe(23) @@ -191,4 +192,61 @@ describe('WSL CLI PowerShell boundary', () => { } } ) + + it.skipIf(process.platform !== 'win32')( + 'pins a non-ASCII app identity and the dev launcher env through Windows PowerShell 5.1', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-managed-bridge-')) + const userDataPath = join(root, "张三's O\u2019Brien Orca") + const cliEntryPath = join(root, 'cli \u2018entry\u2019.cjs') + const bridgePath = join(root, 'orca-wsl-bridge.ps1') + try { + await writeFile(bridgePath, buildWslBridgeScript({ userDataPath, cliEntryPath }), 'utf8') + await writeFile( + cliEntryPath, + 'console.error("to stderr"); const e = process.env; console.log(JSON.stringify({ argv: process.argv.slice(2), owner: e.ORCA_USER_DATA_PATH, app: e.ORCA_APP_EXECUTABLE, nodeOptions: e.NODE_OPTIONS ?? null, stashed: e.ORCA_NODE_OPTIONS, cliDir: e.ORCA_WSL_CLI_DIR ?? null }))\n', + 'utf8' + ) + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + bridgePath, + process.execPath, + '-WslCwd', + root, + ...FORWARDED_ARGS + ], + { + encoding: 'utf8', + windowsHide: true, + env: { + ...process.env, + ORCA_APP_EXECUTABLE: '', + NODE_OPTIONS: '--max-old-space-size=4096', + ORCA_WSL_CLI_DIR: 'C:\\guest-only' + } + } + ) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + expect(result.stderr).toContain('to stderr') + expect(JSON.parse(result.stdout.trim())).toEqual({ + argv: FORWARDED_ARGS, + owner: userDataPath, + app: process.execPath, + nodeOptions: null, + stashed: '--max-old-space-size=4096', + cliDir: null + }) + } finally { + await removeTree(root) + } + } + ) }) diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 62482221d8d..3f4e4156433 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -1,9 +1,44 @@ +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' + const MANAGED_MARKER = '# Orca managed WSL CLI launcher' const BRIDGE_MANAGED_MARKER = '# Orca managed WSL CLI PowerShell bridge' +const FIND_INTEROP_POWERSHELL = `if command -v powershell.exe >/dev/null 2>&1; then + ORCA_POWERSHELL=powershell.exe +elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then + ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe +else + echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 + exit 1 +fi` + export function buildWslLauncher( windowsLauncherPath: string, bridgePath = '${XDG_DATA_HOME:-$HOME/.local/share}/orca/orca-wsl-bridge.ps1' +): string { + return buildLauncher(windowsLauncherPath, quoteShell(bridgePath), FIND_INTEROP_POWERSHELL) +} + +/** Launcher that finds its bridge beside itself and PowerShell by Windows path, independent of guest PATH. */ +export function buildColocatedWslLauncher( + windowsLauncherPath: string, + windowsPowerShellPath: string +): string { + return buildLauncher( + windowsLauncherPath, + '"$(dirname -- "$0")/orca-wsl-bridge.ps1"', + `ORCA_POWERSHELL=$(wslpath -u ${quoteShell(windowsPowerShellPath)}) +if [ ! -x "$ORCA_POWERSHELL" ]; then + echo "Orca WSL CLI requires Windows interop and access to $ORCA_POWERSHELL." >&2 + exit 1 +fi` + ) +} + +function buildLauncher( + windowsLauncherPath: string, + bridgePathExpression: string, + resolvePowerShell: string ): string { const encodedTarget = Buffer.from(windowsLauncherPath, 'utf8').toString('base64') return `#!/usr/bin/env bash @@ -11,15 +46,8 @@ set -euo pipefail ${MANAGED_MARKER} # ORCA_WIN_LAUNCHER_B64=${encodedTarget} ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)} -ORCA_BRIDGE_PS1=${quoteShell(bridgePath)} -if command -v powershell.exe >/dev/null 2>&1; then - ORCA_POWERSHELL=powershell.exe -elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then - ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -else - echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 - exit 1 -fi +ORCA_BRIDGE_PS1=${bridgePathExpression} +${resolvePowerShell} # Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and # help usable, and repair cwd before any WSL interop tool tries to resolve it. ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { @@ -35,8 +63,21 @@ exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_P ` } -export function buildWslBridgeScript(): string { - return `${BRIDGE_MANAGED_MARKER} +/** `app` pins the bridge to one Orca instance; the guest-registered bridge omits it. */ +export function buildWslBridgeScript(app?: { + userDataPath: string + cliEntryPath?: string +}): string { + const setAppEnv = app + ? [ + `$env:ORCA_USER_DATA_PATH = ${quotePowerShellLiteral(app.userDataPath)}`, + // Why: WSLENV /p maps this guest-only dir back; an app the CLI starts must not inherit it. + 'Remove-Item Env:ORCA_WSL_CLI_DIR -ErrorAction SilentlyContinue', + ...(app.cliEntryPath ? buildDevCliEnv(app.cliEntryPath) : []) + ] + : [] + // Why the BOM: PowerShell 5.1 reads BOM-less scripts as ANSI, garbling non-ASCII embedded paths. + return `${app ? '\uFEFF' : ''}${BRIDGE_MANAGED_MARKER} function ConvertTo-NativeCommandLineArgument { param([AllowEmptyString()][string]$Value) @@ -110,7 +151,7 @@ try { # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. $StartInfo = [System.Diagnostics.ProcessStartInfo]::new() $StartInfo.FileName = $OrcaLauncher - $StartInfo.Arguments = (($ForwardArgs | ForEach-Object { +${bridgeLines(setAppEnv)} $StartInfo.Arguments = (($ForwardArgs | ForEach-Object { ConvertTo-NativeCommandLineArgument $_ }) -join ' ') $StartInfo.UseShellExecute = $false @@ -136,6 +177,22 @@ exit $exitCode ` } +/** Runs the dev CLI directly (its .cmd launcher adds a cmd.exe quoting boundary) with that launcher's env. */ +function buildDevCliEnv(cliEntryPath: string): string[] { + return [ + "$env:ELECTRON_RUN_AS_NODE = '1'", + "if (-not $env:ORCA_APP_EXECUTABLE) { $env:ORCA_APP_EXECUTABLE = $OrcaLauncher; $env:ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' }", + '$env:ORCA_NODE_OPTIONS = $env:NODE_OPTIONS', + '$env:ORCA_NODE_REPL_EXTERNAL_MODULE = $env:NODE_REPL_EXTERNAL_MODULE', + 'Remove-Item Env:NODE_OPTIONS, Env:NODE_REPL_EXTERNAL_MODULE -ErrorAction SilentlyContinue', + `$ForwardArgs = @(${quotePowerShellLiteral(cliEntryPath)}) + $ForwardArgs` + ] +} + +function bridgeLines(lines: readonly string[]): string { + return lines.map((line) => ` ${line}\n`).join('') +} + export function getBridgePathFromCommandPath(commandPath: string): string { // Why: both the current Linux command and the legacy pre-rename command // share one WSL bridge under ~/.local/share/orca. diff --git a/src/main/cli/wsl-managed-cli.test.ts b/src/main/cli/wsl-managed-cli.test.ts new file mode 100644 index 00000000000..23e764f5a5b --- /dev/null +++ b/src/main/cli/wsl-managed-cli.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { getManagedWslCliDir } from './wsl-managed-cli' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-managed-wsl-')) + roots.push(root) + const resourcesPath = join(root, 'resources with spaces') + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + writeFileSync(join(resourcesPath, 'bin', 'orca.exe'), 'fixture') + return { isPackaged: true, resourcesPath, userDataPath: join(root, 'user data') } +} + +describe('managed WSL CLI provisioning', () => { + it('reuses a complete tree, repairs missing files, and isolates app identities and updates', () => { + const host = fixture() + const directory = getManagedWslCliDir(host) ?? '' + const launcher = join(directory, 'orca-ide') + const modified = statSync(launcher).mtimeMs + expect(getManagedWslCliDir(host)).toBe(directory) + expect(statSync(launcher).mtimeMs).toBe(modified) + rmSync(launcher) + expect(getManagedWslCliDir(host)).toBe(directory) + expect(readFileSync(launcher, 'utf8')).toContain('resources with spaces') + const second = { ...host, userDataPath: join(host.userDataPath, 'second') } + expect(getManagedWslCliDir(second)).not.toBe(directory) + const update = fixture() + expect(getManagedWslCliDir({ ...update, userDataPath: host.userDataPath })).not.toBe(directory) + }) + + it('provides nothing when the packaged CLI runtime is missing', () => { + const host = fixture() + rmSync(join(host.resourcesPath, 'bin', 'orca.exe')) + expect(getManagedWslCliDir(host)).toBeNull() + }) + + it('provides nothing when user data cannot hold the CLI', () => { + const host = fixture() + writeFileSync(host.userDataPath, 'a file, not a directory') + expect(getManagedWslCliDir(host)).toBeNull() + }) + + it('runs the development CLI directly with the dev launcher env', () => { + const host = fixture() + const appPath = host.resourcesPath + const cliEntryPath = join(appPath, 'out', 'cli', 'index.js') + mkdirSync(join(appPath, 'out', 'cli'), { recursive: true }) + writeFileSync(cliEntryPath, 'fixture') + installFakeAppEnvironment({ getPath: () => host.userDataPath, getAppPath: () => appPath }) + const directory = getManagedWslCliDir({ ...host, isPackaged: false }) ?? '' + expect(readFileSync(join(directory, 'orca-dev'), 'utf8')).toContain(process.execPath) + const bridge = readFileSync(join(directory, 'orca-wsl-bridge.ps1'), 'utf8') + expect(bridge.startsWith('\uFEFF')).toBe(true) + expect(bridge).toContain(host.userDataPath) + expect(bridge).toContain(cliEntryPath) + expect(bridge).toContain('$env:ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT') + expect(bridge).toContain('Remove-Item Env:NODE_OPTIONS') + }) +}) diff --git a/src/main/cli/wsl-managed-cli.ts b/src/main/cli/wsl-managed-cli.ts new file mode 100644 index 00000000000..2e3c478b9a9 --- /dev/null +++ b/src/main/cli/wsl-managed-cli.ts @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { DEV_COMMAND_NAME } from './cli-install-constants' +import { buildColocatedWslLauncher, buildWslBridgeScript } from './wsl-cli-scripts' + +/** Packaged builds share `orca-ide` with guest registration; dev builds get their own name. */ +export function getWslCliCommandName(isPackaged: boolean): string { + return isPackaged ? LINUX_CLI_COMMAND_NAME : DEV_COMMAND_NAME +} + +let warnedMissingRuntime = false + +/** + * Directory holding this app's WSL launcher and bridge, or null when the CLI runtime is + * missing or unwritable. Content-addressed like shell-ready wrappers: builds sharing userData + * never overwrite each other, and a present file is complete because each one lands by rename. + */ +export function getManagedWslCliDir(opts: { + isPackaged: boolean + userDataPath: string + resourcesPath?: string +}): string | null { + const cliEntryPath = opts.isPackaged + ? undefined + : join(getAppEnvironment().getAppPath(), 'out', 'cli', 'index.js') + const launcherPath = opts.isPackaged + ? opts.resourcesPath && getBundledLauncherPath('win32', opts.resourcesPath) + : process.execPath + if (!launcherPath || !existsSync(cliEntryPath ?? launcherPath)) { + if (!warnedMissingRuntime) { + warnedMissingRuntime = true + console.warn('[WSL CLI] Orca CLI runtime is missing; WSL terminals will not provide it.') + } + return null + } + const launcher = buildColocatedWslLauncher(launcherPath, windowsPowerShellPath()) + const bridge = buildWslBridgeScript({ userDataPath: opts.userDataPath, cliEntryPath }) + const digest = createHash('sha256').update(launcher).update(bridge).digest('hex').slice(0, 20) + const directory = join(opts.userDataPath, 'wsl-managed-cli', digest) + const files = [ + [join(directory, getWslCliCommandName(opts.isPackaged)), launcher], + [join(directory, 'orca-wsl-bridge.ps1'), bridge] + ] as const + const ready = + files.every(([path]) => existsSync(path)) || + writeShellWrapperFiles(files, '[WSL CLI]', 'WSL terminals will start without the Orca CLI') + return ready ? directory : null +} diff --git a/src/main/cli/wsl-managed-cli.wsl.test.ts b/src/main/cli/wsl-managed-cli.wsl.test.ts new file mode 100644 index 00000000000..c7738397d77 --- /dev/null +++ b/src/main/cli/wsl-managed-cli.wsl.test.ts @@ -0,0 +1,142 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { getManagedWslCliDir } from './wsl-managed-cli' +import { buildLocalShellReadyWrapperFiles } from '../providers/local-pty-shell-ready-wrapper-fileset' + +// Explicit opt-in: never require a developer's WSL installation for unit tests. +const enabled = process.platform === 'win32' && process.env.ORCA_TEST_MANAGED_WSL === '1' +const FIXTURE_CLI = + 'if(process.argv.includes("--exit"))process.exit(23); console.error("bridge stderr"); console.log(JSON.stringify({argv:process.argv.slice(2),owner:process.env.ORCA_USER_DATA_PATH,handle:process.env.ORCA_TERMINAL_HANDLE}))' + +async function withManagedCli( + run: (fixture: { + env: Record + guestRoot: string + root: string + userDataPath: string + wsl: (args: string[], input?: string) => ReturnType + }) => Promise +): Promise { + const root = mkdtempSync(join(tmpdir(), "orca WSL's managed CLI ")) + const distro = process.env.ORCA_TEST_WSL_DISTRO || undefined + const userDataPath = join(root, 'user data 张三 O\u2019Brien') + const env: Record = { ORCA_BACKGROUND_LAUNCH: '1' } + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } + } + const wsl = (args: string[], input?: string) => + runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, args), + env, + cwd: root, + input, + timeoutMs: 30_000 + }) + const snapshot = () => + wsl([ + 'sh', + '-c', + 'for file in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv" "$HOME/.local/bin/orca" "$HOME/.local/bin/orca-ide" "$HOME/.local/bin/orca-dev" "$HOME/.local/share/orca/orca-wsl-bridge.ps1"; do if [ -f "$file" ]; then sha256sum "$file"; fi; done; printf "PATH=%s\\n" "$PATH"' + ]) + try { + const before = await snapshot() + expect(before.code, before.stderr).toBe(0) + mkdirSync(join(root, 'out', 'cli'), { recursive: true }) + writeFileSync(join(root, 'out', 'cli', 'index.js'), FIXTURE_CLI) + for (const [path, content] of buildLocalShellReadyWrapperFiles(join(root, 'wrapper'))) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + } + const translated = await wsl(['wslpath', '-u', root]) + expect(translated.code, translated.stderr).toBe(0) + installFakeAppEnvironment({ getPath: () => userDataPath, getAppPath: () => root }) + const directory = getManagedWslCliDir({ isPackaged: false, userDataPath }) + expect(directory).not.toBeNull() + Object.assign(env, { + ORCA_WSL_CLI_DIR: directory ?? '', + ORCA_CLI_COMMAND: 'orca-dev', + ORCA_TERMINAL_HANDLE: 'term_managed_fixture' + }) + addOrcaWslInteropEnv(env) + await run({ env, guestRoot: translated.stdout.trim(), root, userDataPath, wsl }) + expect((await snapshot()).stdout).toBe(before.stdout) + } finally { + await removeTree(root) + } +} + +it.skipIf(!enabled)( + 'executes the managed bridge after bash startup resets PATH, without guest registration', + () => + withManagedCli(async ({ env, guestRoot, root, userDataPath, wsl }) => { + writeFileSync(join(root, '.bash_profile'), 'export PATH=/usr/bin:/bin\n') + const shell = (command: string) => + wsl([ + 'env', + `HOME=${guestRoot}`, + 'PATH=/usr/bin:/bin', + 'bash', + '--rcfile', + `${guestRoot}/wrapper/bash/rcfile`, + '-ic', + command + ]) + + const result = await shell('orca-dev "two words" "literal $" | cat') + expect(result.code, result.stderr).toBe(0) + expect(result.stderr).toContain('bridge stderr') + expect(result.stdout).toContain('"argv":["two words","literal $"]') + expect(result.stdout).toContain(JSON.stringify(userDataPath)) + expect(result.stdout).toContain('"handle":"term_managed_fixture"') + expect((await shell('orca-dev --exit')).code).toBe(23) + + env.ORCA_WSL_CLI_DIR = join(root, 'missing-cli') + // An unusable CLI warns but never blocks the shell. + const missing = await shell('echo SHELL_CONTINUED') + expect(missing.code).toBe(0) + expect(missing.stdout).toContain('SHELL_CONTINUED') + expect(missing.stderr).toContain('Check WSL Windows-drive mount options') + }), + 90_000 +) + +// Why a PTY: the zsh restore runs from the first-prompt hook, which `zsh -c` never reaches. +it.skipIf(!enabled)( + 'executes the managed bridge from an interactive zsh after .zshrc resets PATH', + (ctx) => + withManagedCli(async ({ guestRoot, root, userDataPath, wsl }) => { + if ((await wsl(['sh', '-c', 'command -v zsh && command -v script'])).code !== 0) { + ctx.skip() + } + writeFileSync(join(root, '.zshrc'), 'export PATH=/usr/bin:/bin\n') + const result = await wsl( + [ + 'env', + `HOME=${guestRoot}`, + 'PATH=/usr/bin:/bin', + `ZDOTDIR=${guestRoot}/wrapper/zsh`, + 'script', + '-qec', + 'zsh -l', + '/dev/null' + ], + `orca-dev "two words" > "$HOME/zsh-out"; print -r -- "path=$PATH" >> "$HOME/zsh-out"; exit\n` + ) + expect(result.code, result.stderr).toBe(0) + const output = await wsl(['cat', `${guestRoot}/zsh-out`]) + expect(output.stdout).toContain('"argv":["two words"]') + expect(output.stdout).toContain(JSON.stringify(userDataPath)) + expect(output.stdout).toMatch(/path=\/mnt\/.*wsl-managed-cli\/[0-9a-f]{20}:\/usr\/bin:\/bin/) + }), + 90_000 +) diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index 70ea670966c..2da68eef53b 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -11,6 +11,7 @@ export function getDaemonZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { + managedWslCli: false, agentTeamsPath: true, remoteCliBinDir: false, codexHome: true, diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts index f6f6cb3a056..636b53d70a1 100644 --- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts +++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts @@ -125,11 +125,13 @@ describe('createPtySubprocess', () => { const saved = { ORCA_PANE_KEY: process.env.ORCA_PANE_KEY, ORCA_TAB_ID: process.env.ORCA_TAB_ID, - ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID + ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID, + ORCA_WSL_CLI_DIR: process.env.ORCA_WSL_CLI_DIR } process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf' process.env.ORCA_TAB_ID = 'parent-tab' process.env.ORCA_WORKTREE_ID = 'parent-worktree' + process.env.ORCA_WSL_CLI_DIR = 'C:/parent/wsl-managed-cli' try { await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) @@ -147,6 +149,7 @@ describe('createPtySubprocess', () => { expect(env.ORCA_PANE_KEY).toBeUndefined() expect(env.ORCA_TAB_ID).toBeUndefined() expect(env.ORCA_WORKTREE_ID).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() }) it('preserves explicit child Orca pane identity over parent env', async () => { diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 129747d4a0d..32281f38618 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -28,7 +28,9 @@ const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' + 'ORCA_AGENT_LAUNCH_TOKEN', + // Not identity but equally per-spawn: an inherited copy names another launch's CLI. + 'ORCA_WSL_CLI_DIR' ] as const const WINDOWS_PATH_ENV_KEY_RE = /^path$/i diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 47082d6e745..56169791d23 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -3,6 +3,7 @@ import { spawnMock, openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock- import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type * as WslManagedCliModule from '../cli/wsl-managed-cli' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -46,6 +47,13 @@ vi.mock('../telemetry/client', () => vi.mock('../telemetry/classify-error', () => import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) ) +const managedWslCliDir = vi.hoisted(() => + vi.fn((): string | null => 'C:\\orca-user-data\\wsl-managed-cli\\hash') +) +vi.mock('../cli/wsl-managed-cli', async (importOriginal) => ({ + ...(await importOriginal()), + getManagedWslCliDir: managedWslCliDir +})) vi.mock('../cli/linux-terminal-orca-cli-shim', () => import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) ) @@ -455,12 +463,13 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - env: { ORCA_TERMINAL_HANDLE: 'term_untrusted' } + env: { ORCA_TERMINAL_HANDLE: 'term_untrusted', ORCA_WSL_CLI_DIR: 'C:\\stale' } }) const spawnCall = spawnMock.mock.calls.at(-1)! const env = spawnCall[2].env as Record expect(env.ORCA_TERMINAL_HANDLE).toBe('term_trusted') + expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String)) }) it('forwards the trusted Orca terminal handle into managed WSL terminals', async () => { @@ -497,11 +506,13 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') expect(env.ORCA_CLI_COMMAND).toBe('orca-ide') + expect(env.ORCA_WSL_CLI_DIR).toBe('C:\\orca-user-data\\wsl-managed-cli\\hash') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', 'ORCA_USER_DATA_PATH/p', 'ORCA_CLI_COMMAND/u', + 'ORCA_WSL_CLI_DIR/p', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', // Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196). @@ -513,7 +524,8 @@ describe('registerPtyHandlers', () => { expect.arrayContaining(['ORCA_OMP_SOURCE_AGENT_DIR/p']) ) }) - it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => { + it('forces managed WSL env over stale caller values, even when CLI setup fails', async () => { + managedWslCliDir.mockReturnValueOnce(null) const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, @@ -534,7 +546,8 @@ describe('registerPtyHandlers', () => { rows: 24, shellOverride: 'wsl.exe', env: { - ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data' + ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data', + ORCA_WSL_CLI_DIR: '/tmp/stale-wsl-cli' } }) } finally { @@ -547,5 +560,6 @@ describe('registerPtyHandlers', () => { const env = spawnCall[2].env as Record expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + expect(env.ORCA_WSL_CLI_DIR).toBeUndefined() }) }) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index dedd44e70e3..b5daa5bed7f 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -11,6 +11,7 @@ import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' import { prependOrcaCliDirToChildPath } from '../../../cli/orca-cli-child-path' +import { getManagedWslCliDir, getWslCliCommandName } from '../../../cli/wsl-managed-cli' import { stripLegacyTerminalShimEnv } from '../../../pty/legacy-terminal-shim-dir' import { mergePersistedWindowsPath } from '../../../pty/windows-environment-path' import { resolveCodexShellLaunchPreflightCommand } from '../../../pty/codex-shell-launch-preflight' @@ -271,11 +272,17 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT } + // Why: an inherited copy (e.g. Orca launched from a WSL pane) names another launch's CLI. + delete baseEnv.ORCA_WSL_CLI_DIR // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged) + const managedCliDir = getManagedWslCliDir(opts) + if (managedCliDir) { + baseEnv.ORCA_WSL_CLI_DIR = managedCliDir + } } else { if (!opts.isPackaged) { baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath @@ -293,7 +300,7 @@ export function buildPtyHostEnv( baseEnv.BROWSER === undefined && process.env.BROWSER === undefined ) { - const cliCommand = opts.isWsl ? (opts.isPackaged ? 'orca-ide' : 'orca-dev') : 'orca' + const cliCommand = opts.isWsl ? getWslCliCommandName(opts.isPackaged) : 'orca' baseEnv.BROWSER = `${cliCommand} open-url --url %s` } diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index f752b75f67a..877fdda3bcc 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight' import { getBashStartupCommandPromptBlock } from '../pty/posix-shell-startup-command' @@ -45,6 +46,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index f0e3f792fc0..47fb521afcb 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -24,6 +24,7 @@ export function getLocalZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { + managedWslCli: true, agentTeamsPath: true, remoteCliBinDir: false, codexHome: true, diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index e56146ea991..1df30613076 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -62,6 +62,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_TERMINAL_HANDLE: 'term_wsl', ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', + ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash', ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', @@ -87,6 +88,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') + expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p') expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 04035a957ca..b3047fd8a27 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -79,6 +79,8 @@ export function addOrcaWslInteropEnv(env: Record): void { // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. 'ORCA_SHELL_READY_ROOT/p', 'ORCA_CLI_COMMAND/u', + // Why /p: the managed CLI launcher lives in the host's userData tree. + 'ORCA_WSL_CLI_DIR/p', 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', diff --git a/src/main/shell-wrapper-file-writer.ts b/src/main/shell-wrapper-file-writer.ts index 1e57a210bba..a30dff1503e 100644 --- a/src/main/shell-wrapper-file-writer.ts +++ b/src/main/shell-wrapper-file-writer.ts @@ -14,7 +14,8 @@ export type ShellWrapperFile = readonly [path: string, content: string] export function writeShellWrapperFiles( files: readonly ShellWrapperFile[], - logPrefix: string + logPrefix: string, + consequence = 'Shell will launch unwrapped' ): boolean { try { for (const [path, content] of files) { @@ -38,7 +39,7 @@ export function writeShellWrapperFiles( ? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})` : String(error) console.error(`${logPrefix} Failed to write shell wrapper files: ${errorMessage}`) - console.error(`${logPrefix} Shell will launch unwrapped`) + console.error(`${logPrefix} ${consequence}`) return false } } diff --git a/src/main/wsl-managed-cli-path-restore.test.ts b/src/main/wsl-managed-cli-path-restore.test.ts new file mode 100644 index 00000000000..01d80390099 --- /dev/null +++ b/src/main/wsl-managed-cli-path-restore.test.ts @@ -0,0 +1,59 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' + +const posix = process.platform !== 'win32' +const hasZsh = posix && runProcessSync({ program: 'sh', args: ['-c', 'command -v zsh'] }).code === 0 +const root = posix ? mkdtempSync(join(tmpdir(), 'orca managed cli restore ')) : '' +if (posix) { + writeFileSync(join(root, 'orca-dev'), '#!/bin/sh\n') + chmodSync(join(root, 'orca-dev'), 0o755) + writeFileSync(join(root, 'orca-ide'), '#!/bin/sh\n') + chmodSync(join(root, 'orca-ide'), 0o644) +} +afterAll(() => { + if (root) { + rmSync(root, { recursive: true, force: true }) + } +}) + +// Mirrors how each shell embeds the snippet: bash rcfile top level, zsh first-prompt hook. +const SHELLS = [ + { name: 'bash', enabled: posix, args: (body: string) => ['--norc', '--noprofile', '-uc', body] }, + { + name: 'zsh', + enabled: hasZsh, + args: (body: string) => ['-fuc', `__orca_hook() {\n emulate -L zsh\n${body}\n}\n__orca_hook`] + } +] as const + +function run( + shell: (typeof SHELLS)[number], + env: Record +): { code: number | null; stdout: string; stderr: string } { + return runProcessSync({ + program: shell.name, + args: shell.args(`${WSL_MANAGED_CLI_PATH_RESTORE}\nprintf '%s' "$PATH"`), + env: { PATH: '/usr/bin:/bin', ...env } + }) +} + +describe.each(SHELLS)('WSL_MANAGED_CLI_PATH_RESTORE in $name', (shell) => { + it.skipIf(!shell.enabled)('leads PATH with a directory holding an executable CLI', () => { + const result = run(shell, { ORCA_WSL_CLI_DIR: root, ORCA_CLI_COMMAND: 'orca-dev' }) + expect(result).toMatchObject({ code: 0, stdout: `${root}:/usr/bin:/bin`, stderr: '' }) + }) + + it.skipIf(!shell.enabled)('warns and keeps PATH when the CLI cannot run', () => { + const result = run(shell, { ORCA_WSL_CLI_DIR: root, ORCA_CLI_COMMAND: 'orca-ide' }) + expect(result).toMatchObject({ code: 0, stdout: '/usr/bin:/bin' }) + expect(result.stderr).toContain('Orca CLI unavailable') + }) + + it.skipIf(!shell.enabled)('does nothing without a managed directory', () => { + expect(run(shell, {})).toMatchObject({ code: 0, stdout: '/usr/bin:/bin', stderr: '' }) + }) +}) diff --git a/src/main/wsl-managed-cli-path-restore.ts b/src/main/wsl-managed-cli-path-restore.ts new file mode 100644 index 00000000000..d3091433a71 --- /dev/null +++ b/src/main/wsl-managed-cli-path-restore.ts @@ -0,0 +1,8 @@ +/** Leads PATH with the managed WSL CLI after startup files ran; an unusable CLI only warns. */ +export const WSL_MANAGED_CLI_PATH_RESTORE = `if [ -n "\${ORCA_WSL_CLI_DIR:-}" ]; then + if [ -x "$ORCA_WSL_CLI_DIR/\${ORCA_CLI_COMMAND:-}" ]; then + export PATH="$ORCA_WSL_CLI_DIR\${PATH:+:$PATH}" + else + printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\\n' "$ORCA_WSL_CLI_DIR/\${ORCA_CLI_COMMAND:-}" >&2 + fi +fi` diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 27a1e0b7615..49f706e5741 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -27,6 +27,7 @@ * hook restores zsh option semantics for the body at call time. */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' +import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { getZshShellReadyMarkerRegistrationBlock, @@ -38,6 +39,8 @@ import { /** Runtime values the hook re-exports after the user's own startup files ran. */ export type ZshWrapperRestoreSpec = { + /** The managed WSL CLI dir onto PATH — local wrappers only; a no-op outside WSL. */ + managedWslCli: boolean /** Orca's agent-teams shim dir back onto PATH. */ agentTeamsPath: boolean /** Remote CLI bin dir onto PATH — relay hosts only. */ @@ -170,6 +173,7 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t builtin typeset -g precmd_functions ${permanentPrecmd} ${joinBlocks([ + spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file // ran, so the value it derives is the user's own path. #11044 is unreachable. diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 376d422f073..2231fc2b29b 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -32,6 +32,7 @@ function getRelayZshWrapperSpec(): ZshStartupHookSpec { overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', restores: { + managedWslCli: false, agentTeamsPath: false, remoteCliBinDir: true, codexHome: false, diff --git a/src/shared/powershell-native-argument.test.ts b/src/shared/powershell-native-argument.test.ts index 276413f4fac..b8e1feb8aa2 100644 --- a/src/shared/powershell-native-argument.test.ts +++ b/src/shared/powershell-native-argument.test.ts @@ -4,6 +4,9 @@ import { quotePowerShellLiteral, quotePowerShellNativeArgument } from './powersh describe('PowerShell native argument quoting', () => { it('escapes literals for PowerShell source parsing', () => { expect(quotePowerShellLiteral("WSL 'Preview'")).toBe("'WSL ''Preview'''") + expect(quotePowerShellLiteral('O\u2019Brien \u2018x\u201A\u201B')).toBe( + "'O\u2019\u2019Brien \u2018\u2018x\u201A\u201A\u201B\u201B'" + ) }) it('pre-escapes embedded quotes for Windows native argv parsing', () => { diff --git a/src/shared/powershell-native-argument.ts b/src/shared/powershell-native-argument.ts index 6376ab5d842..f357a8e8fb7 100644 --- a/src/shared/powershell-native-argument.ts +++ b/src/shared/powershell-native-argument.ts @@ -1,5 +1,6 @@ export function quotePowerShellLiteral(value: string): string { - return `'${value.replace(/'/g, "''")}'` + // Why: PowerShell also ends single-quoted strings at typographic single quotes. + return `'${value.replace(/['\u2018\u2019\u201A\u201B]/g, '$&$&')}'` } export function quotePowerShellNativeArgument(value: string): string { From 457a84d5a1660f358df1c196f4b7fe64239960b5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:05:55 -0400 Subject: [PATCH 044/387] fix(mobile): label direct paths with the shared Tailscale check (#23039) directPathForEndpoint hand-rolled Tailscale detection that called any 100.x address a tailnet and missed Tailscale IPv6, so the same endpoint could be labelled differently from the direct connection log. It now uses the shared isTailscaleEndpoint. Also: a stale no-relay comment, a duplicate routing doc line, and the overlay chain settling to Promise. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil --- .../src/transport/mobile-direct-endpoint-probe.test.ts | 3 +++ mobile/src/transport/mobile-direct-endpoint-probe.ts | 9 ++------- mobile/src/transport/mobile-endpoint-supervisor.ts | 2 +- mobile/src/transport/mobile-relay-host-overlay-store.ts | 7 +++++-- mobile/src/transport/mobile-relay-session-establisher.ts | 1 - 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts index 044c06e2e26..e225d02de8a 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts @@ -44,6 +44,9 @@ describe('mobile direct endpoint probe', () => { it('classifies a tailnet endpoint apart from a LAN one', () => { expect(directPathForEndpoint('ws://100.64.0.2:6768')).toBe('tailscale') expect(directPathForEndpoint('wss://desk.tail1234.ts.net:6768')).toBe('tailscale') + expect(directPathForEndpoint('ws://[fd7a:115c:a1e0::1]:6768')).toBe('tailscale') + // 100.0.0.0/10 is not the tailnet's 100.64.0.0/10. + expect(directPathForEndpoint('ws://100.12.0.2:6768')).toBe('lan') expect(directPathForEndpoint('ws://192.168.1.10:6768')).toBe('lan') }) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.ts b/mobile/src/transport/mobile-direct-endpoint-probe.ts index 58ce9c74a0a..a586d45eb0a 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.ts @@ -1,14 +1,9 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' export function directPathForEndpoint(endpoint: string): Exclude { - try { - const hostname = new URL(endpoint).hostname - if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) { - return 'tailscale' - } - } catch {} - return 'lan' + return isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan' } // Why: 'reconnecting' is published on any socket close, so it cannot tell a dead diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 736e1427af8..862a739fb12 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -82,7 +82,7 @@ export class MobileEndpointSupervisor { }) // Why: the race owns recovery exactly like a network-change replacement — its // failure must book the shared cooldown. recoverRelay's own guards already - // cover stopped/background/no-relay, so the timer needs no scope check. + // cover stopped/background, so the timer needs no scope check. this.directGrace = new MobileRelayDirectGraceTimer(dependencies, logical, () => { void this.recoverRelay(true, true) }) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 6dedb3eadb5..85ef8b8c181 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -7,7 +7,7 @@ import { } from './mobile-relay-host-overlay' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' -let overlayMutation: Promise = Promise.resolve() +let overlayMutation: Promise = Promise.resolve() function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { if (raw === null) { @@ -50,7 +50,10 @@ async function mutateOverlays( await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) return true }) - overlayMutation = mutation.catch(() => {}) + overlayMutation = mutation.then( + () => {}, + () => {} + ) return mutation } diff --git a/mobile/src/transport/mobile-relay-session-establisher.ts b/mobile/src/transport/mobile-relay-session-establisher.ts index 7e616002102..959f015752c 100644 --- a/mobile/src/transport/mobile-relay-session-establisher.ts +++ b/mobile/src/transport/mobile-relay-session-establisher.ts @@ -54,7 +54,6 @@ export class MobileRelaySessionEstablisher { this.relay = args.relay } - // Routing only; the row and token belong to pairing and Edit Host. async adoptRelay(relay: MobileRelayEndpoint): Promise { // Why: a stopped supervisor's host may be removed or re-paired; its successor owns routing. if (this.args.isStopped()) { From f0a36109285e7bd90c0e8452c439de12a991a02a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:34:12 -0400 Subject: [PATCH 045/387] test(terminal): re-pin the pane hook-order parity past #23049 (#23090) #23049 added a useRef, a useLayoutEffect and a useEffect to the terminal pane's chat-state, layout-persistence and title-effects hooks and merged with the parity shard red, so main fails 'preserves the recursively flattened render hook order' (211 vs 214). Pin 214 hooks, 7 useMemo, and the new order hash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../terminal-pane/terminal-pane-hook-order-parity.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts index 8645d8cecb0..ded0bbf5c2a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts @@ -24,8 +24,10 @@ const TERMINAL_PANE_HOOK_SOURCE_PATTERN = // (the confirmed-close continuation) (212 hooks, still 8 useMemo). // Then the dead adopted-structured-session portal went with its local target `useMemo` // in projection (211 hooks, 7 useMemo). +// Then chat ownership through toggles and restore (#23049) added a `useRef`, a `useLayoutEffect` +// and a `useEffect` across chat-state, layout-persistence and title-effects (214 hooks, still 7 useMemo). const PRE_REFACTOR_HOOK_ORDER_SHA256 = - '8293793dd40900d869b110ddbe8b4412080f8f505da0baeb4337102147886053' + '3736b71c612bb28b9b5298c7704ee1b32c7afc0254e75d0072ff4df870ec3be9' const sourceFiles = readdirSync(__dirname) .filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name)) @@ -90,7 +92,7 @@ function readFlattenedHookOrder(): string[] { describe('TerminalPane refactor hook parity', () => { it('preserves the recursively flattened render hook order', () => { const hooks = readFlattenedHookOrder() - expect(hooks).toHaveLength(211) + expect(hooks).toHaveLength(214) expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(7) expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe( PRE_REFACTOR_HOOK_ORDER_SHA256 From 82412dab8b33db3d50dc1feff169341ca89acd90 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 22:47:33 -0700 Subject: [PATCH 046/387] Persist profile state in SQLite with background writes (#22612) Migrate profile state to SQLite and move writes and backups into a background worker. Acknowledge terminal, SSH and automation changes only after durable saves. Preserve JSON import, recovery, rollback and compatibility exports. Validate migration, worker failures, maintenance, cross-profile moves and terminal lifetime races with unit, integration and end-to-end coverage. --- .../build-plugins/plain-node-entry-guard.ts | 30 +- config/electron-builder.config.cjs | 2 + config/scripts/build-orcad.mjs | 36 +- .../cli-main-module-bundle-parity.test.ts | 31 +- .../electron-vite-output-contract.test.ts | 31 +- config/scripts/plain-node-entry-guard.test.ts | 60 +- config/scripts/profile-state-worker-smoke.mjs | 134 +++ .../profile-state-worker-smoke.test.mjs | 91 ++ config/tsconfig.cli.json | 62 ++ electron.vite.config.ts | 24 +- src/cli/handler-group-manifest.ts | 5 + .../agent-hooks-load-boundary.test.ts | 42 + src/cli/handlers/agent-hooks.test.ts | 290 +++++- src/cli/handlers/agent-hooks.ts | 133 ++- .../profile-state-recovery-admission.test.ts | 245 +++++ src/cli/handlers/profile-state.test.ts | 487 ++++++++++ src/cli/handlers/profile-state.ts | 153 ++++ src/cli/index.ts | 3 +- src/cli/profile-state-location.ts | 13 + src/cli/runtime-client-deferral.test.ts | 4 +- src/cli/runtime/launch.ts | 6 +- .../profile-state-recovery-launch.test.ts | 154 ++++ .../runtime/profile-state-recovery-launch.ts | 78 ++ src/cli/specs/index.ts | 4 +- src/cli/specs/profile-state.test.ts | 23 + src/cli/specs/profile-state.ts | 30 + src/main/active-view-preference.ts | 21 + .../agent-auth-restart-preservation.test.ts | 6 +- src/main/agent-auth-restart-preservation.ts | 2 +- .../automation-dispatch-request.ts | 154 ++++ .../automations/automation-run-writer.test.ts | 92 +- src/main/automations/automation-run-writer.ts | 35 +- .../automation-worker-durability.test.ts | 245 +++++ ...automation-zero-grace-tick-latency.test.ts | 6 +- src/main/automations/dispatch-refusal.test.ts | 15 +- src/main/automations/dispatch-refusal.ts | 36 +- .../headless-dispatch-durability.test.ts | 107 +++ .../automations/headless-dispatch-runner.ts | 86 +- src/main/automations/refused-manual-run.ts | 4 +- .../run-completion-watcher.test.ts | 12 +- .../automations/run-completion-watcher.ts | 10 +- src/main/automations/service.ts | 138 ++- .../codex-accounts/async-file-rename.test.ts | 87 ++ .../codex-accounts/codex-account-selection.ts | 11 +- .../codex-reset-credit-coordinator.ts | 11 +- .../codex-reset-credit-ledger.test.ts | 131 +++ .../codex-reset-credit-ledger.ts | 89 +- src/main/codex-accounts/fs-utils.ts | 41 +- ...vice-account-selection-and-removal.test.ts | 96 +- .../service-reset-credit-durability.test.ts | 45 +- ...ervice-reset-credit-home-ownership.test.ts | 2 +- ...ervice-reset-credit-target-routing.test.ts | 23 +- .../codex-accounts/service-test-harness.ts | 8 +- src/main/daemon/daemon-launch-paths.ts | 5 +- .../daemon/daemon-process-identity-query.ts | 14 +- .../daemon-process-identity-time-zone.test.ts | 32 + .../daemon/daemon-pty-spawn-preparations.ts | 9 +- .../terminal-history-permission-repair.ts | 5 +- src/main/daemon/windows-conpty-warmup.test.ts | 8 + src/main/daemon/windows-conpty-warmup.ts | 3 + .../durable-file-write-syscall-proof.test.ts | 51 +- src/main/durable-file-write.ts | 34 +- src/main/index.ts | 45 +- .../ipc/orca-profile-project-transfer-args.ts | 25 + .../orca-profiles-switch-persistence.test.ts | 115 +++ src/main/ipc/orca-profiles.test.ts | 197 +++- src/main/ipc/orca-profiles.ts | 91 +- src/main/ipc/pty-dead-owner-respawn.test.ts | 13 +- .../pty-pane-reservation-settlement.test.ts | 5 +- src/main/ipc/pty-pane-restart-replace.test.ts | 1 + .../pty-persisted-incarnation-repair.test.ts | 9 +- .../pty-serializer-settlement-mapping.test.ts | 5 +- .../ipc/pty/host-env/fresh-spawn-routing.ts | 10 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 144 ++- ...spawn-commit-ssh-lease-cardinality.test.ts | 18 +- src/main/ipc/pty/ipc/spawn-commit.ts | 113 +-- src/main/ipc/pty/pane/spawn-registration.ts | 81 ++ src/main/ipc/pty/pane/spawn-telemetry.ts | 24 + src/main/ipc/pty/pane/stable-owner.ts | 76 +- ...ble-pane-absence-death-certificate.test.ts | 6 +- .../stable-pane-relay-absence-respawn.test.ts | 6 +- .../ipc/pty/pane/terminal-spawn-restore.ts | 46 + src/main/ipc/pty/runtime/spawn-commit.ts | 171 ++-- src/main/ipc/session.ts | 21 +- .../profile-active-transfer-worker.test.ts | 127 +++ .../profile-active-transfer.test.ts | 232 +++++ .../orca-profiles/profile-active-transfer.ts | 39 + .../profile-cloud-auth-status.test.ts | 1 + .../orca-profiles/profile-index-store.test.ts | 54 ++ src/main/orca-profiles/profile-index-store.ts | 60 +- .../profile-legacy-state-import.ts | 35 + .../profile-persistence-deadline.test.ts | 37 + .../profile-persistence-deadline.ts | 31 +- .../profile-project-domain-changes.ts | 144 +++ .../profile-project-domain-move-intent.ts | 58 ++ .../profile-project-domain-state.ts | 84 ++ .../profile-project-domain-transfer.test.ts | 546 +++++++++++ .../profile-project-move-intent.ts | 124 +++ .../profile-project-move-record.ts | 141 +++ .../profile-project-state-file.ts | 164 +++- ...profile-project-transfer-migration.test.ts | 314 +++++++ .../profile-project-transfer-migration.ts | 27 + .../profile-project-transfer.test.ts | 439 ++++++++- .../orca-profiles/profile-project-transfer.ts | 103 ++- .../orca-profiles/profile-storage-paths.ts | 33 +- src/main/orcad/orcad-entry.test.ts | 38 + src/main/orcad/orcad-entry.ts | 87 +- src/main/orcad/orcad-health.test.ts | 18 + src/main/orcad/orcad-health.ts | 11 +- src/main/orcad/orcad-launch-contract.test.ts | 19 +- src/main/orcad/orcad-lifecycle.ts | 64 +- .../orcad/orcad-profile-state-startup.test.ts | 115 +++ src/main/orcad/orcad-profile-state-startup.ts | 64 ++ .../orcad-profile-state-telemetry.test.ts | 46 + .../orcad/orcad-profile-state-telemetry.ts | 47 + src/main/orcad/orcad-push-startup.test.ts | 56 +- ...sistence-flush-and-save-scheduling.test.ts | 163 ++-- ...-host-admitted-terminal-membership.test.ts | 10 +- ...-host-partitioned-ssh-pty-bindings.test.ts | 12 +- src/main/persistence-initial-load.test.ts | 50 +- ...rsistence-loading-store-extraction.test.ts | 68 +- ...ce-pty-binding-leaf-tab-resolution.test.ts | 4 +- ...istence-pty-binding-reconciliation.test.ts | 12 +- ...persistence-split-pane-incarnation.test.ts | 22 +- src/main/persistence-ui-state.test.ts | 35 + ...sistence-worktree-deletion-fencing.test.ts | 12 +- .../feature-interaction-recording.ts | 4 +- .../applying-settings/ui-state-update.ts | 7 +- .../ssh-pty-consumer-recovery.ts | 53 +- .../ssh-pty-lease-operations.ts | 30 +- .../loading-store/automation-persistence.ts | 44 +- .../loading-store/backup-recovery-rotation.ts | 24 +- .../loading-store/loaded-state-parsing.ts | 91 +- .../metadata-lineage-operations.ts | 11 +- .../primary-state-write-context.ts | 17 + .../loading-store/primary-state-write-json.ts | 87 ++ .../primary-state-write-runtime.ts | 69 ++ .../loading-store/primary-state-write-sync.ts | 98 ++ .../primary-state-write-worker.ts | 106 +++ .../loading-store/primary-state-writes.ts | 415 +++++---- .../loading-store/profile-preferences.ts | 4 +- .../profile-state-authority-writes.ts | 59 ++ .../loading-store/profile-state-authority.ts | 127 +++ .../profile-state-caller-cancellation.test.ts | 87 ++ .../profile-state-checkpoints.test.ts | 238 +++++ ...profile-state-delayed-authority-fixture.ts | 130 +++ .../profile-state-direct-flush.test.ts | 188 ++++ .../profile-state-flush-lifetime.ts | 34 + .../profile-state-import-lifetime.test.ts | 117 +++ ...le-state-maintenance-compatibility.test.ts | 125 +++ ...le-state-maintenance-final-failure.test.ts | 89 ++ .../profile-state-maintenance-fixture.ts | 109 +++ ...profile-state-maintenance-recovery.test.ts | 145 +++ .../profile-state-maintenance.test.ts | 297 ++++++ .../profile-state-maintenance.ts | 198 ++++ ...-state-pty-retirement-finalization.test.ts | 199 ++++ .../profile-state-selective-write.ts | 99 ++ .../profile-state-settings-writes.test.ts | 320 +++++++ .../profile-state-sqlite-authority.test.ts | 858 ++++++++++++++++++ .../profile-state-store-backups.test.ts | 249 +++++ .../profile-state-update-quit.test.ts | 242 +++++ .../profile-state-worker-coordination.test.ts | 263 ++++++ ...file-state-worker-secret-retention.test.ts | 124 +++ .../profile-state-write-batching.test.ts | 251 +++++ .../pty-binding-async-durability.test.ts | 267 ++++++ ...y-binding-created-surface-rollback.test.ts | 135 +++ .../pty-binding-fast-lane.test.ts | 1 + .../loading-store/pty-binding-fast-lane.ts | 7 + .../loading-store/pty-binding-persistence.ts | 281 ++---- .../pty-binding-session-update.ts | 146 +++ .../pty-binding-write-rollback.ts | 94 ++ .../pty-reattach-failure-routing.test.ts | 122 +++ .../pty-retirement-async-durability.test.ts | 119 +++ ...retirement-publication-during-read.test.ts | 146 +++ .../pty-spawn-commit-dependencies-fixture.ts | 45 + .../pty-spawn-exit-durability.test.ts | 238 +++++ .../pty-spawn-handle-publication.test.ts | 77 ++ .../pty-spawn-replacement-durability.test.ts | 146 +++ .../pty-spawn-restore-durability.test.ts | 79 ++ .../secret-sentinel-substitution.test.ts | 134 ++- .../secret-sentinel-substitution.ts | 91 +- .../loading-store/session-host-partitions.ts | 9 +- .../session-snapshot-operations.ts | 50 +- .../ssh-lease-async-durability.test.ts | 128 +++ .../ssh-lease-durable-mutation.ts | 42 + .../ssh-lease-recovery-operations.ts | 47 +- .../loading-store/ssh-profile-operations.ts | 12 +- .../state-serialization-secret-handling.ts | 141 ++- .../loading-store/store-domain-composition.ts | 17 + .../loading-store/store-runtime-state.ts | 21 + src/main/persistence/loading-store/store.ts | 316 +++++-- .../workspace-session-snapshot-publication.ts | 14 +- .../worktree-identity-metadata.ts | 11 +- .../loading-store/write-flush-barriers.ts | 283 +++--- .../loading-store/write-scheduling.ts | 57 +- .../profile-state-cutover-fixture.test.ts | 164 ++++ .../profile-state-cutover-fixture.ts | 291 ++++++ .../profile-state-cutover-soak.test.ts | 350 +++++++ .../profile-state-access-identity.test.ts | 30 + .../profile-state-access-identity.ts | 84 ++ .../profile-state-access-owner.ts | 314 +++++++ .../profile-state-access-process.test.ts | 150 +++ .../profile-state-access.test.ts | 527 +++++++++++ .../profile-state/profile-state-access.ts | 99 ++ .../profile-state-active-location.ts | 49 + .../profile-state-authority-bootstrap.test.ts | 537 +++++++++++ .../profile-state-authority-bootstrap.ts | 192 ++++ ...ile-state-authority-export-fencing.test.ts | 43 + .../profile-state-authority-exports.ts | 89 ++ ...ile-state-automation-runs-equality.test.ts | 177 ++++ ...profile-state-automation-runs-migration.ts | 46 + .../profile-state-automation-runs-model.ts | 49 + .../profile-state-automation-runs-payload.ts | 72 ++ .../profile-state-automation-runs-reader.ts | 151 +++ ...tate-automation-runs-serialization.test.ts | 65 ++ .../profile-state-automation-runs-storage.ts | 49 + ...rofile-state-automation-runs-validation.ts | 154 ++++ .../profile-state-automation-runs-writer.ts | 158 ++++ .../profile-state-automation-runs.ts | 22 + ...e-state-automation-storage-upgrade.test.ts | 380 ++++++++ .../profile-state/profile-state-backup-job.ts | 33 + .../profile-state-backup-path.test.ts | 76 ++ .../profile-state-backup-path.ts | 67 ++ .../profile-state-backup-rotation.test.ts | 275 ++++++ .../profile-state-backup-rotation.ts | 130 +++ ...ofile-state-backup-temporary-files.test.ts | 91 ++ .../profile-state-backup-temporary-files.ts | 71 ++ .../profile-state-backup-worker-entry.ts | 30 + .../profile-state-backup-worker.test.ts | 236 +++++ .../profile-state-backup-worker.ts | 87 ++ ...e-state-bootstrap-publication-race.test.ts | 178 ++++ ...ofile-state-complete-domain-writes.test.ts | 122 +++ .../profile-state-complete-replacements.ts | 36 + .../profile-state-crash-recovery.test.ts | 210 +++++ ...profile-state-current-json-command.test.ts | 171 ++++ .../profile-state-database-errors.ts | 17 + ...rofile-state-database-export-crash.test.ts | 112 +++ .../profile-state-database-publication.ts | 38 + .../profile-state-database-quarantine.ts | 89 ++ .../profile-state-database-recovery.test.ts | 296 ++++++ .../profile-state-database-recovery.ts | 113 +++ ...ile-state-database-rollback-export.test.ts | 159 ++++ .../profile-state-database-schema.ts | 41 + .../profile-state-database-snapshot.test.ts | 324 +++++++ .../profile-state-database-snapshot.ts | 123 +++ .../profile-state-database-validation.ts | 146 +++ .../profile-state-database.test.ts | 339 +++++++ .../profile-state/profile-state-database.ts | 268 ++++++ .../profile-state-document-reader.ts | 99 ++ .../profile-state-document-validation.ts | 117 +++ .../profile-state-documents.test.ts | 370 ++++++++ .../profile-state/profile-state-documents.ts | 232 +++++ .../profile-state-domain-equality.test.ts | 144 +++ .../profile-state-domain-reader.test.ts | 152 ++++ .../profile-state-domain-reader.ts | 115 +++ .../profile-state-domain-write-validation.ts | 78 ++ .../profile-state-domain-writes.test.ts | 585 ++++++++++++ .../profile-state-domain-writes.ts | 231 +++++ .../profile-state-export-path.ts | 36 + .../profile-state-fragment-validation.test.ts | 132 +++ .../profile-state-json-acceptance.ts | 153 ++++ ...-state-json-compatibility-recovery.test.ts | 239 +++++ ...profile-state-large-recovery-crash.test.ts | 149 +++ .../profile-state-legacy-backup-path.ts | 16 + .../profile-state-live-store-factory.test.ts | 216 +++++ .../profile-state-live-store-factory.ts | 58 ++ .../profile-state/profile-state-migration.ts | 86 ++ .../profile-state-offline-settings.test.ts | 135 +++ .../profile-state-offline-settings.ts | 198 ++++ .../profile-state-parsed-snapshot.test.ts | 158 ++++ .../profile-state-read-concurrency.test.ts | 174 ++++ .../profile-state-read-snapshot.ts | 37 + .../profile-state-recovery-batch.test.ts | 227 +++++ .../profile-state-recovery-command.ts | 142 +++ .../profile-state-recovery-copy.test.ts | 193 ++++ .../profile-state-recovery-copy.ts | 81 ++ ...le-state-recovery-crash-boundaries.test.ts | 378 ++++++++ .../profile-state-recovery-crash-process.ts | 178 ++++ .../profile-state-recovery-required.ts | 81 ++ .../profile-state/profile-state-recovery.ts | 85 ++ .../profile-state-revision-readmission.ts | 20 + .../profile-state/profile-state-revision.ts | 31 + .../profile-state-sqlite-authority.ts | 339 +++++++ .../profile-state-startup-authority.test.ts | 329 +++++++ .../profile-state-startup-authority.ts | 68 ++ .../profile-state-startup-failure.test.ts | 96 ++ .../profile-state-startup-failure.ts | 127 +++ ...file-state-startup-recovery-dialog.test.ts | 64 ++ .../profile-state-startup-recovery-dialog.ts | 30 + .../profile-state-startup-secrets.test.ts | 184 ++++ .../profile-state-startup-snapshot.test.ts | 310 +++++++ .../profile-state-storage-classification.ts | 32 + .../profile-state-store-factory.test.ts | 460 ++++++++++ .../profile-state-store-factory.ts | 121 +++ ...profile-state-streaming-validation.test.ts | 145 +++ .../profile-state-versioned-export.test.ts | 113 +++ .../profile-state-versioned-export.ts | 52 ++ .../profile-state-worker-authority.test.ts | 76 ++ .../profile-state-worker-authority.ts | 168 ++++ ...ofile-state-worker-export-failures.test.ts | 177 ++++ .../profile-state-write-transaction.test.ts | 102 +++ .../profile-state-write-transaction.ts | 42 + .../profile-state-writer-connection.ts | 302 ++++++ .../profile-state-writer-errors.ts | 94 ++ ...ofile-state-writer-protocol-faults.test.ts | 148 +++ .../profile-state-writer-protocol.ts | 115 +++ .../profile-state-writer-request.ts | 59 ++ .../profile-state-writer-worker-client.ts | 75 ++ .../profile-state-writer-worker-entry.ts | 148 +++ .../profile-state-writer-worker-path.ts | 17 + .../profile-state-writer-worker.test.ts | 341 +++++++ .../pane-identity-migration.ts | 45 +- .../workspace-pane-normalization.ts | 34 +- .../workspace-session-write-rollback.ts | 132 +++ .../automation-definition-operations.ts | 3 + .../automation-run-operations.ts | 5 + ...ted-secret-persistence-concurrency.test.ts | 192 ++++ src/main/protected-secret-persistence.test.ts | 31 + src/main/protected-secret-persistence.ts | 42 +- ...time-terminal-close-continuity-fixtures.ts | 9 +- ...wledged-terminal-tab-retirement-fixture.ts | 18 +- ...knowledged-terminal-tab-retirement.test.ts | 21 +- .../automation-change-publication.test.ts | 31 +- ...ude-structured-session-integration.test.ts | 1 + .../folder-workspace-pty-identity.test.ts | 6 +- ...c-live-daemon-pty-tab-preservation.test.ts | 5 +- ...less-close-keeps-publication-epoch.test.ts | 17 +- ...minal-close-persistence-durability.test.ts | 16 +- .../mobile-session-terminal-retirement.ts | 10 +- .../orca-runtime-automation-operations.ts | 9 +- .../runtime/orca-runtime-automations.test.ts | 2 +- ...ld-headless-mobile-session-browser-tabs.ts | 96 +- ...orca-runtime-build-pty-terminal-summary.ts | 10 +- ...time-close-headless-mobile-terminal-tab.ts | 12 +- .../orca-runtime-close-mobile-session-tab.ts | 26 +- .../orca-runtime-fit-override-listeners.ts | 1 + src/main/runtime/orca-runtime-get-status.ts | 16 +- ...-runtime-invalidate-all-handles-for-pty.ts | 1 + ...obile-close-preserved-resurrection.test.ts | 9 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 26 +- ...me-persist-terminal-surface-retirements.ts | 145 ++- src/main/runtime/orca-runtime-register-pty.ts | 7 + ...rca-runtime-stop-terminals-for-worktree.ts | 94 +- ...terminal-retirement-host-partition.test.ts | 25 +- .../orca-runtime-terminal-retirement.test.ts | 107 ++- .../orca-runtime-test-fixtures.spec.ts | 64 +- ...rca-runtime-test-scenario-builders.spec.ts | 3 +- .../mobile-session-tabs-part-04.spec.ts | 37 +- .../mobile-session-tabs-part-05.spec.ts | 11 +- .../mobile-session-tabs-part-08.spec.ts | 5 +- .../mobile-session-tabs-part-11.spec.ts | 2 +- ...nal-creation-and-readiness-part-05.spec.ts | 2 +- .../terminal-handles-and-agent-status.spec.ts | 13 +- ...output-and-worker-recovery-part-02.spec.ts | 47 +- ...output-and-worker-recovery-part-03.spec.ts | 5 +- ...output-and-worker-recovery-part-04.spec.ts | 9 +- ...output-and-worker-recovery-part-05.spec.ts | 11 +- ...output-and-worker-recovery-part-06.spec.ts | 21 +- ...output-and-worker-recovery-part-07.spec.ts | 55 +- .../coordinator-task-dispatch.ts | 5 +- ...inator-terminal-census-unavailable.test.ts | 84 ++ src/main/runtime/orchestration/coordinator.ts | 3 + ...retirement-proof-publication-order.test.ts | 28 +- ...y-inventory-partial-relay-liveness.test.ts | 6 +- .../runtime/runtime-automation-controller.ts | 13 +- .../runtime/runtime-durable-store-fixture.ts | 40 + ...cy-worker-terminal-recovery-persistence.ts | 119 ++- src/main/runtime/runtime-store-contract.ts | 1 + .../runtime-terminal-orphan-adoption.ts | 2 +- ...inal-list-pending-pty-registration.test.ts | 144 +++ ...rminal-retirement-async-durability.test.ts | 70 ++ ...dle-name-only-real-pty.integration.test.ts | 6 +- ...workspace-session-failed-write-rollback.ts | 74 -- .../worktree-terminal-mutation-lock.test.ts | 4 + .../worktree-terminal-mutation-lock.ts | 4 + src/main/sqlite/sync-database.ts | 26 +- .../ssh-reattach-pane-cardinality.test.ts | 46 +- src/main/ssh/orcad-remote-deploy-stop.ts | 68 ++ src/main/ssh/orcad-remote-deploy.test.ts | 85 +- src/main/ssh/orcad-remote-deploy.ts | 157 ++-- src/main/ssh/orcad-remote-host-support.ts | 12 +- src/main/ssh/orcad-remote-launch.test.ts | 8 +- src/main/ssh/orcad-remote-launch.ts | 3 +- src/main/ssh/orcad-remote-process-control.ts | 28 +- src/main/ssh/orcad-remote-rollback.ts | 11 +- ...-remote-shell-commands.integration.test.ts | 312 ++++++- src/main/ssh/orcad-state-snapshot.test.ts | 2 + src/main/ssh/orcad-state-snapshot.ts | 54 +- .../ssh/ssh-orphan-relay-pty-sweep.test.ts | 2 +- src/main/ssh/ssh-pty-consumer-recovery.ts | 5 +- .../ssh-relay-session-data-delivery.test.ts | 3 +- ...elay-session-reconnect-incarnation.test.ts | 12 +- .../ssh-relay-session-recovery-races.test.ts | 177 ++-- .../ssh/ssh-relay-session-test-fixtures.ts | 19 +- src/main/ssh/ssh-relay-session.ts | 99 +- ...rowser-process-user-agent-ordering.test.ts | 290 +++++- src/main/startup/cli-command-names.ts | 1 + .../configure-process-profile-state.test.ts | 46 + src/main/startup/configure-process.test.ts | 52 +- src/main/startup/configure-process.ts | 40 +- .../startup/desktop-startup-ordering.test.ts | 24 + .../headless-pty-hydration-ordering.test.ts | 4 +- .../startup/http1-compatibility-marker.ts | 56 +- .../http1-compatibility-profile-state.test.ts | 141 +++ .../http1-compatibility-profile-state.ts | 122 +++ src/main/startup/main-process-observers.ts | 10 + .../startup/main-process-preflight-failure.ts | 41 + src/main/startup/main-process-preflight.ts | 35 +- src/main/startup/main-process-quit.ts | 17 +- .../startup/main-process-ready-foundation.ts | 31 +- .../main-process-ready-identity-write.test.ts | 11 + ...-process-ready-persistence-cleanup.test.ts | 94 ++ src/main/startup/main-process-ready.ts | 37 +- src/main/startup/main-process-state.ts | 21 + .../startup/main-window-core-services.test.ts | 114 +++ src/main/startup/main-window-core-services.ts | 7 +- .../pre-gone-crash-sampling-wiring.test.ts | 4 +- .../profile-state-recovery-preflight.test.ts | 280 ++++++ .../profile-state-recovery-preflight.ts | 76 ++ .../profile-state-write-failure.test.ts | 53 ++ .../startup/profile-state-write-failure.ts | 18 + src/main/startup/startup-diagnostics.test.ts | 15 +- src/main/startup/startup-diagnostics.ts | 8 +- src/main/updater.quit-and-install.test.ts | 92 ++ src/main/updater.ts | 4 +- src/main/updater/updater-install-support.ts | 51 +- src/main/updater/updater-setup.ts | 4 +- src/main/updater/updater-state.ts | 4 + .../attach-main-window-services.test.ts | 35 +- .../window/attach-main-window-services.ts | 3 +- .../history-gc-profile-worktree-ids.test.ts | 152 +++- .../window/history-gc-profile-worktree-ids.ts | 107 ++- src/main/window/main-window-updater.ts | 3 + src/main/worker-thread-entry-path.ts | 5 +- src/preload/api/orca-profiles-bridge.ts | 35 +- .../app-restart-checkpoint-routing.test.ts | 95 ++ src/preload/renderer-restart-wiring.test.ts | 132 ++- src/preload/renderer-restart-wiring.ts | 64 +- ...transport-recycled-pty-incarnation.test.ts | 44 +- .../direct-ssh-connect-reply-routing.test.ts | 291 ++++++ .../ipc-events/direct-ssh-state-ipc-bridge.ts | 17 +- .../worktree-agent-activation-gate.test.ts | 68 +- .../worktree-agent-live-surface-adoption.ts | 4 +- ...ktree-live-terminal-surface-owners.test.ts | 62 +- .../worktree-live-terminal-surface-owners.ts | 36 +- .../web/web-runtime-client-heartbeat.test.ts | 3 + src/shared/cli-argument-boundary.ts | 1 + src/shared/orcad-artifacts.ts | 2 + .../profile-state-recovery-command.test.ts | 28 + src/shared/profile-state-recovery-command.ts | 72 ++ src/shared/profile-state-storage-paths.ts | 16 + .../profile-state-telemetry-schema.test.ts | 29 + src/shared/telemetry-daemon-event-schemas.ts | 13 + src/shared/telemetry-event-registry.ts | 2 + src/shared/telemetry-events.ts | 1 + src/shared/uuid-v4.test.ts | 25 + src/shared/uuid-v4.ts | 1 + ...ent-session-live-force-exit-resume.spec.ts | 63 +- tests/e2e/agent-session-quit-resume.spec.ts | 54 +- tests/e2e/finished-agent-ghost-resume.spec.ts | 49 +- .../headless-serve-desktop-activation.spec.ts | 10 +- .../completed-worker-retirement-fixture.ts | 14 +- tests/e2e/helpers/electron-launch-args.ts | 10 +- .../helpers/electron-launch-args.unit.test.ts | 16 +- .../helpers/electron-main-evaluate-retry.ts | 12 +- .../electron-main-evaluate-retry.unit.test.ts | 34 +- .../e2e/helpers/electron-process-shutdown.ts | 21 +- tests/e2e/helpers/orca-restart.ts | 36 +- tests/e2e/helpers/persisted-profile-state.ts | 60 ++ .../persisted-profile-state.unit.test.ts | 71 ++ .../ssh-reconnect-failure-observation.ts | 120 +++ .../helpers/terminal-restart-persistence.ts | 186 ++++ ...y-worker-missing-terminal-recovery.spec.ts | 9 +- ...ion-legacy-worker-restart-recovery.spec.ts | 84 +- ...-client-hosted-browser-ghost-close.spec.ts | 94 +- ...terminal-parked-scrollback-restart.spec.ts | 52 +- ...ote-terminal-serve-restart-binding.spec.ts | 113 ++- ...rsisted-session-production-upgrade.spec.ts | 432 ++++++++- tests/e2e/plugin-startup-budget.spec.ts | 39 +- ...le-state-automatic-backup-recovery.spec.ts | 222 +++++ ...state-terminal-restart-persistence.spec.ts | 591 ++++++++++++ ...emote-terminal-tab-retirement.unit.test.ts | 20 +- ...ettled-worker-tab-survives-restart.spec.ts | 28 +- .../ssh-docker-reconnect-pane-restore.spec.ts | 30 +- .../ssh-docker-resource-accumulation.spec.ts | 50 +- ...ssh-docker-transport-drop-recovery.spec.ts | 13 +- ...minal-binding-crash-hydration.unit.test.ts | 101 +++ ...inal-duplicate-pty-renderer-reveal.spec.ts | 64 +- .../e2e/terminal-restart-persistence.spec.ts | 184 +--- ...kspace-session-corrupt-tab-salvage.spec.ts | 46 +- 490 files changed, 41290 insertions(+), 3485 deletions(-) rename src/cli/main-module-bundle-parity.test.ts => config/scripts/cli-main-module-bundle-parity.test.ts (60%) create mode 100644 config/scripts/profile-state-worker-smoke.mjs create mode 100644 config/scripts/profile-state-worker-smoke.test.mjs create mode 100644 src/cli/handlers/agent-hooks-load-boundary.test.ts create mode 100644 src/cli/handlers/profile-state-recovery-admission.test.ts create mode 100644 src/cli/handlers/profile-state.test.ts create mode 100644 src/cli/handlers/profile-state.ts create mode 100644 src/cli/profile-state-location.ts create mode 100644 src/cli/runtime/profile-state-recovery-launch.test.ts create mode 100644 src/cli/runtime/profile-state-recovery-launch.ts create mode 100644 src/cli/specs/profile-state.test.ts create mode 100644 src/cli/specs/profile-state.ts create mode 100644 src/main/automations/automation-dispatch-request.ts create mode 100644 src/main/automations/automation-worker-durability.test.ts create mode 100644 src/main/automations/headless-dispatch-durability.test.ts create mode 100644 src/main/codex-accounts/async-file-rename.test.ts create mode 100644 src/main/codex-accounts/codex-reset-credit-ledger.test.ts create mode 100644 src/main/daemon/daemon-process-identity-time-zone.test.ts create mode 100644 src/main/ipc/orca-profile-project-transfer-args.ts create mode 100644 src/main/ipc/orca-profiles-switch-persistence.test.ts create mode 100644 src/main/ipc/pty/pane/spawn-registration.ts create mode 100644 src/main/ipc/pty/pane/spawn-telemetry.ts create mode 100644 src/main/ipc/pty/pane/terminal-spawn-restore.ts create mode 100644 src/main/orca-profiles/profile-active-transfer-worker.test.ts create mode 100644 src/main/orca-profiles/profile-active-transfer.test.ts create mode 100644 src/main/orca-profiles/profile-active-transfer.ts create mode 100644 src/main/orca-profiles/profile-legacy-state-import.ts create mode 100644 src/main/orca-profiles/profile-persistence-deadline.test.ts create mode 100644 src/main/orca-profiles/profile-project-domain-changes.ts create mode 100644 src/main/orca-profiles/profile-project-domain-move-intent.ts create mode 100644 src/main/orca-profiles/profile-project-domain-state.ts create mode 100644 src/main/orca-profiles/profile-project-domain-transfer.test.ts create mode 100644 src/main/orca-profiles/profile-project-move-intent.ts create mode 100644 src/main/orca-profiles/profile-project-move-record.ts create mode 100644 src/main/orca-profiles/profile-project-transfer-migration.test.ts create mode 100644 src/main/orca-profiles/profile-project-transfer-migration.ts create mode 100644 src/main/orcad/orcad-entry.test.ts create mode 100644 src/main/orcad/orcad-profile-state-startup.test.ts create mode 100644 src/main/orcad/orcad-profile-state-startup.ts create mode 100644 src/main/orcad/orcad-profile-state-telemetry.test.ts create mode 100644 src/main/orcad/orcad-profile-state-telemetry.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-context.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-json.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-runtime.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-sync.ts create mode 100644 src/main/persistence/loading-store/primary-state-write-worker.ts create mode 100644 src/main/persistence/loading-store/profile-state-authority-writes.ts create mode 100644 src/main/persistence/loading-store/profile-state-authority.ts create mode 100644 src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-checkpoints.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts create mode 100644 src/main/persistence/loading-store/profile-state-direct-flush.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-flush-lifetime.ts create mode 100644 src/main/persistence/loading-store/profile-state-import-lifetime.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-fixture.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance.ts create mode 100644 src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-selective-write.ts create mode 100644 src/main/persistence/loading-store/profile-state-settings-writes.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-store-backups.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-update-quit.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-worker-coordination.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-write-batching.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-session-update.ts create mode 100644 src/main/persistence/loading-store/pty-binding-write-rollback.ts create mode 100644 src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts create mode 100644 src/main/persistence/loading-store/pty-retirement-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts create mode 100644 src/main/persistence/loading-store/ssh-lease-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/ssh-lease-durable-mutation.ts create mode 100644 src/main/persistence/profile-state-cutover-fixture.test.ts create mode 100644 src/main/persistence/profile-state-cutover-fixture.ts create mode 100644 src/main/persistence/profile-state-cutover-soak.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-identity.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-identity.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-owner.ts create mode 100644 src/main/persistence/profile-state/profile-state-access-process.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-access.ts create mode 100644 src/main/persistence/profile-state/profile-state-active-location.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-bootstrap.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-authority-exports.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-migration.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-model.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-payload.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-reader.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-storage.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs-writer.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-runs.ts create mode 100644 src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-job.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-path.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-rotation.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-rotation.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-temporary-files.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-worker-entry.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-worker.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-backup-worker.ts create mode 100644 src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-complete-replacements.ts create mode 100644 src/main/persistence/profile-state/profile-state-crash-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-current-json-command.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-errors.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-export-crash.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-publication.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-quarantine.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-recovery.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-schema.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-snapshot.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-snapshot.ts create mode 100644 src/main/persistence/profile-state/profile-state-database-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-database.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-database.ts create mode 100644 src/main/persistence/profile-state/profile-state-document-reader.ts create mode 100644 src/main/persistence/profile-state/profile-state-document-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-documents.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-documents.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-equality.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-reader.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-reader.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-write-validation.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-writes.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-domain-writes.ts create mode 100644 src/main/persistence/profile-state/profile-state-export-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-fragment-validation.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-json-acceptance.ts create mode 100644 src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-legacy-backup-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-live-store-factory.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-live-store-factory.ts create mode 100644 src/main/persistence/profile-state/profile-state-migration.ts create mode 100644 src/main/persistence/profile-state/profile-state-offline-settings.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-offline-settings.ts create mode 100644 src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-read-concurrency.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-read-snapshot.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-batch.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-command.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-copy.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-copy.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-crash-process.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery-required.ts create mode 100644 src/main/persistence/profile-state/profile-state-recovery.ts create mode 100644 src/main/persistence/profile-state/profile-state-revision-readmission.ts create mode 100644 src/main/persistence/profile-state/profile-state-revision.ts create mode 100644 src/main/persistence/profile-state/profile-state-sqlite-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-authority.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-failure.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-failure.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-secrets.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-storage-classification.ts create mode 100644 src/main/persistence/profile-state/profile-state-store-factory.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-store-factory.ts create mode 100644 src/main/persistence/profile-state/profile-state-streaming-validation.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-versioned-export.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-versioned-export.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-authority.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-authority.ts create mode 100644 src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-write-transaction.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-write-transaction.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-connection.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-errors.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-protocol.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-request.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker-client.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker-entry.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker-path.ts create mode 100644 src/main/persistence/profile-state/profile-state-writer-worker.test.ts create mode 100644 src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts create mode 100644 src/main/protected-secret-persistence-concurrency.test.ts create mode 100644 src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts create mode 100644 src/main/runtime/runtime-durable-store-fixture.ts create mode 100644 src/main/runtime/terminal-list-pending-pty-registration.test.ts create mode 100644 src/main/runtime/terminal-retirement-async-durability.test.ts delete mode 100644 src/main/runtime/workspace-session-failed-write-rollback.ts create mode 100644 src/main/ssh/orcad-remote-deploy-stop.ts create mode 100644 src/main/startup/configure-process-profile-state.test.ts create mode 100644 src/main/startup/http1-compatibility-profile-state.test.ts create mode 100644 src/main/startup/http1-compatibility-profile-state.ts create mode 100644 src/main/startup/main-process-preflight-failure.ts create mode 100644 src/main/startup/main-process-ready-persistence-cleanup.test.ts create mode 100644 src/main/startup/main-window-core-services.test.ts create mode 100644 src/main/startup/profile-state-recovery-preflight.test.ts create mode 100644 src/main/startup/profile-state-recovery-preflight.ts create mode 100644 src/main/startup/profile-state-write-failure.test.ts create mode 100644 src/main/startup/profile-state-write-failure.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts create mode 100644 src/shared/profile-state-recovery-command.test.ts create mode 100644 src/shared/profile-state-recovery-command.ts create mode 100644 src/shared/profile-state-storage-paths.ts create mode 100644 src/shared/profile-state-telemetry-schema.test.ts create mode 100644 src/shared/uuid-v4.test.ts create mode 100644 src/shared/uuid-v4.ts create mode 100644 tests/e2e/helpers/persisted-profile-state.ts create mode 100644 tests/e2e/helpers/persisted-profile-state.unit.test.ts create mode 100644 tests/e2e/helpers/ssh-reconnect-failure-observation.ts create mode 100644 tests/e2e/helpers/terminal-restart-persistence.ts create mode 100644 tests/e2e/profile-state-automatic-backup-recovery.spec.ts create mode 100644 tests/e2e/profile-state-terminal-restart-persistence.spec.ts create mode 100644 tests/e2e/terminal-binding-crash-hydration.unit.test.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 7dc017b9c98..2589a4921e9 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -16,15 +16,33 @@ type OutputChunk = Rollup.OutputChunk // electron, and smoke-loads daemon-entry under plain Node to prove its module // graph still resolves. -// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): -// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run -// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +// The CLI loads these paths after electron-vite replaces out/main. +export const CLI_MAIN_ENTRY_NAMES = [ + 'agent-hooks/managed-agent-hook-controls', + 'codex/managed-home-shell-preflight', + 'claude-accounts/keychain', + ...[ + 'access', + 'active-location', + 'storage-classification', + 'offline-settings', + 'export-path', + 'backup-path', + 'database-recovery', + 'domain-reader', + 'recovery', + 'recovery-command' + ].map((module) => `persistence/profile-state/profile-state-${module}`), + 'startup/http1-compatibility-marker' +] as const + +// Plain-Node processes and CLI modules cannot load Electron's API. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', - 'agent-hooks/managed-agent-hook-controls' + ...CLI_MAIN_ENTRY_NAMES ] as const // Entries executed as worker threads of the main process. Electron's module is @@ -41,7 +59,9 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', - 'usage-scan-worker-entry' + 'usage-scan-worker-entry', + 'profile-state-backup-worker-entry', + 'profile-state-writer-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 84c31376833..abf94296035 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -281,6 +281,8 @@ module.exports = { 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', + 'out/main/persistence/profile-state/**', + 'out/main/startup/http1-compatibility-marker.js', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 99a53fa0981..0b882689f94 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -21,6 +21,7 @@ import { import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' +import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { ORCAD_VERSION_FILENAME, ORCAD_RIPGREP_ARTIFACTS @@ -99,10 +100,8 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', recursive: true }) -/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source - * directory under `outdir`, and both children must land flat beside orcad.js — that is where - * their runtime resolvers look for them. */ -function buildForkedChild(entryPoint, outfile) { +// Child and worker resolvers require flat entries beside orcad.js. +function buildIsolatedEntry(entryPoint, outfile) { return build({ entryPoints: [entryPoint], bundle: true, @@ -120,9 +119,15 @@ function buildForkedChild(entryPoint, outfile) { }) } -const childResults = await Promise.all([ - buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), - buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE) +const isolatedResults = await Promise.all([ + buildIsolatedEntry(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildIsolatedEntry(DAEMON_ENTRY, DAEMON_OUT_FILE), + ...['writer', 'backup'].map((role) => + buildIsolatedEntry( + join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`), + join(OUT_DIR, `profile-state-${role}-worker-entry.js`) + ) + ) ]) const result = await build({ @@ -147,9 +152,7 @@ const output = Object.values(result.metafile.outputs).find( // Why check `original` and not just `path`: when electron is bundleable, esbuild // rewrites `path` to the resolved file under node_modules and the naive check passes // while the package is very much in the bundle. -// Why both metafiles: the forked children ship in the same deployment and run under the -// same plain Node. A daemon-entry that reached electron would fail at fork time, on the -// path whose whole point is that terminals survive. +// Every isolated entry ships under the same plain-Node compatibility contract. function collectImporters(metafiles, matches) { const importers = new Set() for (const metafile of metafiles) { @@ -164,7 +167,7 @@ function collectImporters(metafiles, matches) { return importers } -const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const metafiles = [result.metafile, ...isolatedResults.map((entry) => entry.metafile)] const electronImporters = collectImporters( metafiles, (specifier) => specifier === 'electron' || specifier.startsWith('electron/') @@ -254,6 +257,12 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + try { + await smokeProfileStateWorkers(OUT_DIR) + } catch (error) { + console.error('[build-orcad] profile state worker check failed:', error) + process.exitCode = 1 + } } // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on @@ -295,7 +304,10 @@ async function smokeLoadWatcherChild() { resolve(failure) } child.on('message', (message) => { - if (message?.op === 'subscribe-started') { + // Wait until the subscribe lifecycle has sent its final acknowledgement. + // Disconnecting on subscribe-started races the subsequent subscribed or + // subscribe-failed message and makes the child report an expected EPIPE. + if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') { child.disconnect() } }) diff --git a/src/cli/main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts similarity index 60% rename from src/cli/main-module-bundle-parity.test.ts rename to config/scripts/cli-main-module-bundle-parity.test.ts index a109fcb9fdb..ed810e34382 100644 --- a/src/cli/main-module-bundle-parity.test.ts +++ b/config/scripts/cli-main-module-bundle-parity.test.ts @@ -1,6 +1,8 @@ import { readFileSync, readdirSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { electronViteConfig } from '../../electron.vite.config' +import { GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard' const REPO_ROOT = resolve(__dirname, '..', '..') const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') @@ -18,7 +20,7 @@ function listCliSourceFiles(dir: string): string[] { } // Why: `import type` is erased by tsc, so it needs no emitted module at runtime. -const VALUE_IMPORT_FROM_MAIN = /(? { @@ -30,12 +32,12 @@ function findMainImports(): { file: string; module: string }[] { }) } -function findElectronViteMainEntries(): Set { - const config = readFileSync(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf-8') - return new Set( - // Why: entries wrap across lines once the path is long, so allow whitespace. - [...config.matchAll(/resolve\(\s*'src\/main\/([^']+)\.ts'\s*\)/g)].map((match) => match[1]) - ) +function findElectronViteMainEntries(): Record { + const input = electronViteConfig.main?.build?.rollupOptions?.input + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Expected named main-process inputs') + } + return input } describe('CLI imports of main-process modules', () => { @@ -45,14 +47,25 @@ describe('CLI imports of main-process modules', () => { // final-artifact runtime verifier. it('has an electron-vite entry for every main module the CLI imports', () => { const entries = findElectronViteMainEntries() - const missing = findMainImports().filter(({ module }) => !entries.has(module)) + const missing = findMainImports().filter( + ({ module }) => entries[module] !== join(REPO_ROOT, 'src', 'main', `${module}.ts`) + ) expect(missing).toEqual([]) }) + it('guards every CLI main module against Electron imports', () => { + const guarded = new Set(GUARDED_ENTRY_NAMES) + expect(findMainImports().filter(({ module }) => !guarded.has(module))).toEqual([]) + }) + it('finds the imports it is meant to guard', () => { // Why: a broken matcher would make the guard above vacuously pass. + expect(findMainImports()).toContainEqual({ + file: join('src', 'cli', 'profile-state-location.ts'), + module: 'persistence/profile-state/profile-state-active-location' + }) expect(findMainImports().length).toBeGreaterThanOrEqual(2) - expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2) + expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2) }) }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 285b58f2ac7..fe6108a6ff1 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -73,9 +73,7 @@ function failBootstrapWithBanner(options: { return processMock } -const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs') as { - files: string[] -} +const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs') describe('Electron Vite output contract', () => { it("minifies main and renderer with rolldown's in-process minifier", () => { @@ -104,6 +102,33 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) + it('keeps offline profile-state CLI imports unpacked at stable paths', () => { + const input = electronViteConfig.main?.build?.rollupOptions?.input + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Expected named main-process inputs') + } + + for (const name of [ + 'persistence/profile-state/profile-state-access', + 'persistence/profile-state/profile-state-active-location', + 'persistence/profile-state/profile-state-backup-path', + 'persistence/profile-state/profile-state-database-recovery', + 'persistence/profile-state/profile-state-domain-reader', + 'persistence/profile-state/profile-state-export-path', + 'persistence/profile-state/profile-state-offline-settings', + 'persistence/profile-state/profile-state-recovery', + 'persistence/profile-state/profile-state-recovery-command', + 'persistence/profile-state/profile-state-storage-classification', + 'startup/http1-compatibility-marker' + ]) { + expect(input).toHaveProperty(name) + } + expect(electronBuilderConfig.asarUnpack).toContain('out/main/persistence/profile-state/**') + expect(electronBuilderConfig.asarUnpack).toContain( + 'out/main/startup/http1-compatibility-marker.js' + ) + }) + it('externalizes packaged dependencies but bundles self-contained main dependencies', () => { const external = electronViteConfig.main?.build?.rollupOptions?.external if (typeof external !== 'function') { diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index 673b796fd43..843d185b101 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' import { afterEach, describe, expect, it } from 'vitest' import { + CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin, GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard' @@ -158,8 +159,8 @@ describe('guarded entry names', () => { // main-process worker and kills it at startup. The worker entries carried only // hand-written "must stay electron-free" comments, and the port-scan worker sits // one import away from a client that deliberately does require electron. -describe('worker thread entry guard', () => { - function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { +describe('CLI and worker thread entry guard', () => { + function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { const hook = plugin.writeBundle if (typeof hook !== 'function') { throw new Error('Expected writeBundle hook') @@ -171,7 +172,7 @@ describe('worker thread entry guard', () => { ) } - function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk { + function entryChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk { return { type: 'chunk', code, @@ -183,33 +184,54 @@ describe('worker thread entry guard', () => { } as Rollup.OutputChunk } + it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => { + const plugin = createPlainNodeEntryGuardPlugin() + const entry = entryChunk(name, 'require("electron")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron') + + entry.code = '' + const shared = entryChunk('shared', 'require("electron/main")') + shared.isEntry = false + bundle[shared.fileName] = shared + for (const edge of ['imports', 'dynamicImports'] as const) { + entry[edge] = [shared.fileName] + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron') + entry[edge] = [] + } + + shared.code = 'require("node:fs")' + entry.imports = [shared.fileName] + expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow() + }) + it('rejects an Electron require reachable from a worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'port-scan-command-worker-entry.js': workerChunk( + const bundle: Rollup.OutputBundle = { + 'port-scan-command-worker-entry.js': entryChunk( 'port-scan-command-worker-entry', 'require("electron")' ) - } as Rollup.OutputBundle + } - expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron') + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron') }) it('names the worker-thread runtime so the failure is actionable', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'stt-worker.js': workerChunk('stt-worker', 'require("electron")') - } as Rollup.OutputBundle + const bundle: Rollup.OutputBundle = { + 'stt-worker.js': entryChunk('stt-worker', 'require("electron")') + } - expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread') + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('runs as a worker thread') }) // The real risk is transitive: a worker entry importing a shared chunk that // reaches the electron-requiring client, not a direct import anyone would spot. it('follows shared chunks out of a worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'session-scanner-opencode-sqlite-worker-entry.js': workerChunk( + const bundle: Rollup.OutputBundle = { + 'session-scanner-opencode-sqlite-worker-entry.js': entryChunk( 'session-scanner-opencode-sqlite-worker-entry', 'require("./chunks/shared.js")', ['chunks/shared.js'] @@ -223,20 +245,20 @@ describe('worker thread entry guard', () => { isEntry: false, name: 'shared' } as Rollup.OutputChunk - } as Rollup.OutputBundle + } - expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js') + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('chunks/shared.js') }) it('passes a clean worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() - const bundle = { - 'warp-theme-parser-worker.js': workerChunk( + const bundle: Rollup.OutputBundle = { + 'warp-theme-parser-worker.js': entryChunk( 'warp-theme-parser-worker', 'require("node:worker_threads")' ) - } as Rollup.OutputBundle + } - expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow() + expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow() }) }) diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs new file mode 100644 index 00000000000..a866c9da554 --- /dev/null +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -0,0 +1,134 @@ +import { deepStrictEqual } from 'node:assert' +import { build } from 'esbuild' +import { mkdtempSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { Worker } from 'node:worker_threads' + +async function initializeFixture(directory, databasePath, profileId) { + const fixture = join(directory, 'initialize.cjs') + await build({ + stdin: { + contents: `import { openProfileStateDatabase } from './src/main/persistence/profile-state/profile-state-database'; + export function initialize(path, profileId) { openProfileStateDatabase(path, profileId).db.close() }`, + resolveDir: resolve(import.meta.dirname, '../..'), + sourcefile: 'profile-state-build-fixture.ts' + }, + outfile: fixture, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + createRequire(import.meta.url)(fixture).initialize(databasePath, profileId) +} + +function runWorker(entry, workerData, steps, timeoutMs) { + return new Promise((resolve, reject) => { + const worker = new Worker(entry, { workerData, execArgv: [] }) + let received = 0 + let failure + const stop = (error) => { + failure ??= error + void worker.terminate().catch((terminationError) => { + failure ??= terminationError + }) + } + const timer = setTimeout(() => stop(new Error(`${entry} timed out`)), timeoutMs) + worker.on('message', (response) => { + if (failure) { + return + } + const step = steps[received] + if (!step) { + stop(new Error(`${entry} sent an unexpected response`)) + return + } + try { + if (response?.ok === false) { + throw new Error(`${entry}: ${response.error?.message ?? response.error}`) + } + for (const [key, expected] of Object.entries(step.reply)) { + deepStrictEqual(response?.[key], expected, `${entry}: unexpected ${key}`) + } + received++ + const next = steps[received] + if (next) { + worker.postMessage(next.request) + } + } catch (error) { + stop(error) + } + }) + worker.on('error', (error) => { + failure ??= error + }) + worker.once('exit', (code) => { + clearTimeout(timer) + if (failure || code !== 0 || received !== steps.length) { + reject(failure ?? new Error(`${entry} exited before completing its protocol (${code})`)) + } else { + resolve() + } + }) + }) +} + +/** Exercise the shipped entries and copied state before publishing their content version. */ +export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-smoke-')) + const databasePath = join(directory, 'profile.db') + const targetPath = join(directory, 'backup.db') + const profileId = 'build-smoke' + const payload = JSON.stringify({ theme: 'dark', witness: 'saved \ud800 \u{1f419}' }) + try { + await initializeFixture(directory, databasePath, profileId) + await runWorker( + join(outDir, 'profile-state-writer-worker-entry.js'), + { databasePath, profileId, revision: 0 }, + [ + { reply: { id: 0, ok: true, revision: 0 } }, + { + request: { + id: 1, + command: 'write-complete', + replacements: [{ domain: 'settings', payload }] + }, + reply: { id: 1, ok: true, revision: 1 } + }, + { + request: { id: 2, command: 'close' }, + reply: { id: 2, ok: true, revision: 1 } + } + ], + timeoutMs + ) + await runWorker( + join(outDir, 'profile-state-backup-worker-entry.js'), + { databasePath, profileId, targetPath }, + [{ reply: { ok: true } }], + timeoutMs + ) + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const database = new DatabaseSync(targetPath, { readOnly: true }) + try { + deepStrictEqual(database.prepare('PRAGMA quick_check').get()['quick_check'], 'ok') + deepStrictEqual( + database + .prepare("SELECT payload FROM profile_state_documents WHERE domain = 'settings'") + .get()?.payload, + payload + ) + deepStrictEqual( + database.prepare("SELECT value FROM profile_state_meta WHERE key = 'revision'").get() + ?.value, + '1' + ) + } finally { + database.close() + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/profile-state-worker-smoke.test.mjs b/config/scripts/profile-state-worker-smoke.test.mjs new file mode 100644 index 00000000000..06f45e9fd6d --- /dev/null +++ b/config/scripts/profile-state-worker-smoke.test.mjs @@ -0,0 +1,91 @@ +import { build } from 'esbuild' +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' + +const directories = [] +const writerFilename = 'profile-state-writer-worker-entry.js' +const backupFilename = 'profile-state-backup-worker-entry.js' +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-build-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await Promise.all( + ['writer', 'backup'].map((role) => + build({ + entryPoints: [ + resolve(`src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`) + ], + outfile: join(builtDirectory, `profile-state-${role}-worker-entry.js`), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + logLevel: 'silent' + }) + ) + ) +}) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('profile state build smoke', () => { + it('writes through the built writer and verifies the built backup after handle release', async () => { + await expect(smokeProfileStateWorkers(builtDirectory)).resolves.toBeUndefined() + }) + + it('rejects a worker that exits without completing its protocol', async () => { + const directory = fixtureDirectory() + writeFileSync(join(directory, writerFilename), 'process.exit(0)\n') + await expect(smokeProfileStateWorkers(directory)).rejects.toThrow('before completing') + }) + + it('rejects a mismatched startup revision', async () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, writerFilename), + `const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 7 }) + parentPort.close()` + ) + await expect(smokeProfileStateWorkers(directory)).rejects.toThrow('unexpected revision') + }) + + it('does not accept a close acknowledgement from a worker that remains alive', async () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, writerFilename), + `const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 0 }) + parentPort.on('message', ({ id }) => parentPort.postMessage({ id, ok: true, revision: 1 })) + setInterval(() => {}, 1000)` + ) + await expect(smokeProfileStateWorkers(directory, { timeoutMs: 2_000 })).rejects.toThrow( + 'timed out' + ) + }) + + it('does not accept a successful backup response without the copied database', async () => { + const directory = fixtureDirectory() + copyFileSync(join(builtDirectory, writerFilename), join(directory, writerFilename)) + writeFileSync( + join(directory, backupFilename), + `const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ ok: true }) + parentPort.close()` + ) + await expect(smokeProfileStateWorkers(directory)).rejects.toThrow() + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 12d4d2fe785..e8d48d62980 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -20,6 +20,68 @@ "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/agent-hooks/posix-hook-command.ts", "../src/main/agent-hooks/runtime-home-hook-command.ts", + "../src/main/orca-profiles/profile-storage-paths.ts", + "../src/main/persistence/profile-state/profile-state-database.ts", + "../src/main/persistence/profile-state/profile-state-database-errors.ts", + "../src/main/persistence/profile-state/profile-state-database-validation.ts", + "../src/main/persistence/profile-state/profile-state-database-schema.ts", + "../src/main/persistence/profile-state/profile-state-documents.ts", + "../src/main/persistence/profile-state/profile-state-json-acceptance.ts", + "../src/main/persistence/profile-state/profile-state-revision.ts", + "../src/main/persistence/profile-state/profile-state-read-snapshot.ts", + "../src/main/persistence/profile-state/profile-state-sqlite-authority.ts", + "../src/main/persistence/profile-state/profile-state-authority-exports.ts", + "../src/main/persistence/profile-state/profile-state-complete-replacements.ts", + "../src/main/persistence/profile-state/profile-state-revision-readmission.ts", + "../src/main/persistence/profile-state/profile-state-writer-protocol.ts", + "../src/main/persistence/loading-store/profile-state-authority.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-migration.ts", + "../src/main/persistence/profile-state/profile-state-document-reader.ts", + "../src/main/persistence/profile-state/profile-state-document-validation.ts", + "../src/main/persistence/profile-state/profile-state-domain-writes.ts", + "../src/main/persistence/profile-state/profile-state-write-transaction.ts", + "../src/main/persistence/profile-state/profile-state-domain-write-validation.ts", + "../src/main/persistence/profile-state/profile-state-domain-reader.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-model.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-payload.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-reader.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-storage.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-validation.ts", + "../src/main/persistence/profile-state/profile-state-automation-runs-writer.ts", + "../src/main/persistence/profile-state/profile-state-offline-settings.ts", + "../src/main/persistence/profile-state/profile-state-export-path.ts", + "../src/main/persistence/profile-state/profile-state-legacy-backup-path.ts", + "../src/main/persistence/profile-state/profile-state-backup-path.ts", + "../src/main/persistence/profile-state/profile-state-backup-rotation.ts", + "../src/main/persistence/profile-state/profile-state-backup-job.ts", + "../src/main/persistence/profile-state/profile-state-backup-worker.ts", + "../src/main/persistence/profile-state/profile-state-backup-worker-entry.ts", + "../src/main/worker-thread-entry-path.ts", + "../src/main/persistence/profile-state/profile-state-database-snapshot.ts", + "../src/main/persistence/profile-state/profile-state-database-recovery.ts", + "../src/main/persistence/profile-state/profile-state-recovery-required.ts", + "../src/main/persistence/profile-state/profile-state-recovery.ts", + "../src/main/persistence/profile-state/profile-state-recovery-copy.ts", + "../src/main/persistence/profile-state/profile-state-recovery-command.ts", + "../src/main/orca-profiles/profile-project-move-record.ts", + "../src/main/orca-profiles/profile-project-domain-changes.ts", + "../src/main/persistence/profile-state/profile-state-active-location.ts", + "../src/main/persistence/profile-state/profile-state-access.ts", + "../src/main/persistence/profile-state/profile-state-access-owner.ts", + "../src/main/persistence/profile-state/profile-state-access-identity.ts", + "../src/main/daemon/daemon-process-start-time.ts", + "../src/main/daemon/daemon-process-identity-query.ts", + "../src/main/startup/startup-diagnostics.ts", + "../src/main/persistence/profile-state/profile-state-versioned-export.ts", + "../src/main/persistence/profile-state/profile-state-backup-temporary-files.ts", + "../src/main/persistence/profile-state/profile-state-database-quarantine.ts", + "../src/main/persistence/profile-state/profile-state-storage-classification.ts", + "../src/main/durable-file-write.ts", + "../src/shared/secure-file.ts", + "../src/main/sqlite/harden-database-files.ts", + "../src/main/startup/http1-compatibility-marker.ts", + "../src/main/startup/http1-compatibility-profile-state.ts", "../src/main/agent-hooks/windows-direct-cmd-hook-command.ts", "../src/main/agent-hooks/windows-powershell-hook-launcher.ts", "../src/main/amp/agent-status-plugin-source.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3cd58f4a25f..87156caf6e4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,7 +5,10 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner' import { createPdfjsViewerAssetsPlugin } from './config/build-plugins/pdfjs-viewer-assets' -import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard' +import { + CLI_MAIN_ENTRY_NAMES, + createPlainNodeEntryGuardPlugin +} from './config/build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ @@ -246,6 +249,12 @@ export const electronViteConfig: UserConfig = { // corpora and read SQLite synchronously; a worker thread keeps that // off the main-process event loop. 'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'), + 'profile-state-backup-worker-entry': resolve( + 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' + ), + 'profile-state-writer-worker-entry': resolve( + 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts' + ), // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), @@ -254,16 +263,9 @@ export const electronViteConfig: UserConfig = { 'main-thread-hang-watchdog-entry': resolve( 'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts' ), - // Why: electron-vite cleans out/main in dev. The dev CLI imports - // this path for `orca agent hooks ...`, so it must survive rebuilds. - 'agent-hooks/managed-agent-hook-controls': resolve( - 'src/main/agent-hooks/managed-agent-hook-controls.ts' - ), - 'codex/managed-home-shell-preflight': resolve( - 'src/main/codex/managed-home-shell-preflight.ts' - ), - // Why: account import mutates the user's macOS Keychain from the CLI. - 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts') + ...Object.fromEntries( + CLI_MAIN_ENTRY_NAMES.map((module) => [module, resolve(`src/main/${module}.ts`)]) + ) }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers // consume these stable CommonJS paths. diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index bda6799a8bc..bf1f1e313d1 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -183,6 +183,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'], load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS }, + { + name: 'profile-state', + keys: ['profile state exports', 'profile state rollback'], + load: async () => (await import('./handlers/profile-state.js')).PROFILE_STATE_HANDLERS + }, { name: 'diagnostics', keys: ['diagnostics memory'], diff --git a/src/cli/handlers/agent-hooks-load-boundary.test.ts b/src/cli/handlers/agent-hooks-load-boundary.test.ts new file mode 100644 index 00000000000..ff11e2a2d50 --- /dev/null +++ b/src/cli/handlers/agent-hooks-load-boundary.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { main } from '../index' + +const { prepare } = vi.hoisted(() => ({ prepare: vi.fn() })) +vi.mock('../runtime-client', () => ({ + RuntimeClient: class { + async call() { + return { result: { settings: { agentStatusHooksEnabled: true } } } + } + }, + RuntimeClientError: Error, + getDefaultUserDataPath: () => '/unused/user-data' +})) +vi.mock('../../main/codex/managed-home-shell-preflight', () => ({ + prepareManagedCodexHomeBeforeShellLaunch: prepare +})) +vi.mock('../../main/persistence/profile-state/profile-state-offline-settings', () => { + throw new Error('Offline profile settings loaded during online preparation') +}) +vi.mock('../../main/persistence/profile-state/profile-state-access', () => { + throw new Error('Profile admission loaded during online preparation') +}) +vi.mock('../profile-state-location', () => { + throw new Error('Profile location loaded during online preparation') +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + process.exitCode = undefined +}) + +it('prepares Codex through the runtime without loading offline profile storage', async () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await main(['agent', 'hooks', 'prepare-codex']) + expect(error).not.toHaveBeenCalled() + expect(prepare).toHaveBeenCalledWith({ + userDataPath: '/unused/user-data', + hooksEnabled: true + }) +}) diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 279a8900bec..1d883a65145 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -1,9 +1,28 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import * as fs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/persisted-state-types' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../main/orca-profiles/profile-storage-paths' +import { openProfileStateDatabase } from '../../main/persistence/profile-state/profile-state-database' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson +} from '../../main/persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../../main/persistence/profile-state/profile-state-sqlite-authority' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission, + type ProfileStateRuntimeAdmission +} from '../../main/persistence/profile-state/profile-state-access' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) const { applyAgentStatusHooksEnabledMock, @@ -74,6 +93,14 @@ function writeDataFile(userDataPath: string, state: PersistedState): void { writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify(state, null, 2), 'utf-8') } +function writeActiveProfileIndex(userDataPath: string, profileId: string): void { + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf-8' + ) +} + async function runAgentHooksOff(userDataPath: string): Promise { getDefaultUserDataPathMock.mockReturnValue(userDataPath) await main(['agent', 'hooks', 'off', '--json'], userDataPath) @@ -84,7 +111,7 @@ describe('agent hooks CLI handler', () => { beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-')) - applyAgentStatusHooksEnabledMock.mockReturnValue([]) + applyAgentStatusHooksEnabledMock.mockReset().mockReturnValue([]) callMock.mockReset() getCliStatusMock.mockClear() getManagedAgentHookStatusesMock.mockReturnValue([]) @@ -109,6 +136,98 @@ describe('agent hooks CLI handler', () => { expect(persisted.settings.agentStatusHooksEnabled).toBe(false) }) + it.each(['root-json', 'profile-json', 'sqlite'] as const)( + 'refuses offline %s mutation when startup wins after the stopped-status response', + async (backend) => { + const profileId = 'startup-race' + const directory = + backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const dataFile = join(directory, 'orca-data.json') + const raw = JSON.stringify({ + settings: { agentStatusHooksEnabled: true }, + unknown: { retained: null } + }) + writeFileSync(dataFile, raw) + if (backend !== 'root-json') { + writeActiveProfileIndex(userDataPath, profileId) + } + const databaseFile = join(directory, 'profile-state.db') + if (backend === 'sqlite') { + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw) + }) + } finally { + opened.db.close() + } + } + const stopped = await getCliStatusMock() + let runtime: ProfileStateRuntimeAdmission | undefined + getCliStatusMock.mockImplementationOnce(async () => { + runtime = acquireProfileStateRuntimeAdmission(userDataPath) + return stopped + }) + try { + await runAgentHooksOff(userDataPath) + expect(process.exitCode).toBe(1) + expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() + expect(readFileSync(dataFile, 'utf8')).toBe(raw) + if (backend === 'sqlite') { + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + expect(JSON.parse(exportProfileStateJson(opened.db))).toEqual(JSON.parse(raw)) + } finally { + opened.db.close() + } + } + } finally { + runtime?.release() + } + process.exitCode = undefined + await runAgentHooksOff(userDataPath) + expect(process.exitCode).not.toBe(1) + expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce() + } + ) + + it.each(['root-json', 'profile-json'] as const)( + 'excludes startup and other offline writers through %s publication', + async (backend) => { + const profileId = 'offline-first' + const directory = + backend === 'root-json' ? userDataPath : join(userDataPath, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + if (backend === 'profile-json') { + writeActiveProfileIndex(userDataPath, profileId) + } + const dataFile = join(directory, 'orca-data.json') + writeFileSync(dataFile, JSON.stringify({ settings: { agentStatusHooksEnabled: true } })) + const rename = fs.renameSync + let checkedPublication = false + vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => { + if (target === dataFile) { + checkedPublication = true + expect(() => acquireProfileStateRuntimeAdmission(userDataPath)).toThrow() + expect(() => acquireProfileStateMaintenance(userDataPath)).toThrow() + } + return rename(source, target) + }) + await runAgentHooksOff(userDataPath) + expect(checkedPublication).toBe(true) + expect(process.exitCode).not.toBe(1) + const runtime = acquireProfileStateRuntimeAdmission(userDataPath) + try { + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.agentStatusHooksEnabled).toBe( + false + ) + } finally { + runtime.release() + } + } + ) + it('keeps missing new card style off when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) delete existing.settings.experimentalNewWorktreeCardStyle @@ -129,6 +248,31 @@ describe('agent hooks CLI handler', () => { expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) + it.each(['on', 'off', 'status', 'prepare-codex'])( + 'refuses explicit remote selection before local hook command %s', + async (command) => { + const state = getDefaultPersistedState(userDataPath) + writeDataFile(userDataPath, state) + const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf8') + getDefaultUserDataPathMock.mockReturnValue(userDataPath) + + for (const selector of ['environment', 'pairing-code']) { + process.exitCode = undefined + await main( + ['agent', 'hooks', command, `--${selector}`, 'unreachable-host', '--json'], + userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(getCliStatusMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')).toBe(before) + } + } + ) + it('prepares managed Codex trust with the current hooks setting', async () => { const state = getDefaultPersistedState(userDataPath) state.settings.agentStatusHooksEnabled = false @@ -231,4 +375,146 @@ describe('agent hooks CLI handler', () => { timeoutMs: 1_000 }) }) + + it('updates an established SQLite profile without rewriting its JSON export', async () => { + const profileId = 'work-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const raw = JSON.stringify({ + settings: { + agentStatusHooksEnabled: true, + disabledTuiAgents: ['codex'], + opencodeSessionCookie: 'encrypted-ciphertext' + }, + unknownDomain: { preserved: true } + }) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync(dataFile, raw, 'utf-8') + writeActiveProfileIndex(userDataPath, profileId) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw) + }) + } finally { + opened.db.close() + } + + await runAgentHooksOff(userDataPath) + + expect(readFileSync(dataFile, 'utf-8')).toBe(raw) + const readBack = openProfileStateDatabase(databaseFile, profileId) + try { + expect(JSON.parse(exportProfileStateJson(readBack.db))).toMatchObject({ + settings: { + agentStatusHooksEnabled: false, + opencodeSessionCookie: 'encrypted-ciphertext', + disabledTuiAgents: ['codex'] + }, + unknownDomain: { preserved: true } + }) + } finally { + readBack.db.close() + } + }) + + it.each(['update-failed', 'unreachable', 'status-failed'] as const)( + 'preserves a live SQLite writer when runtime contact is %s', + async (failure) => { + const profileId = 'live-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeActiveProfileIndex(userDataPath, profileId) + const authority = new ProfileStateSqliteAuthority( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + authority.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { agentStatusHooksEnabled: true } })) + ) + if (failure === 'status-failed') { + getCliStatusMock.mockRejectedValueOnce(new Error('status transport unavailable')) + } else { + getCliStatusMock.mockResolvedValueOnce({ + id: 'test-status', + ok: true, + result: { + app: { running: true, pid: null }, + runtime: { + state: failure === 'unreachable' ? 'starting' : 'ready', + reachable: failure !== 'unreachable', + runtimeId: null + }, + graph: { state: 'ready' } + }, + _meta: { runtimeId: 'test' } + }) + callMock.mockRejectedValueOnce(new Error('settings request timed out')) + } + try { + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() + expect(() => + authority.writeSerializedDomains([ + { domain: 'ui', payload: '{"marker":"still-writable"}' } + ]) + ).not.toThrow() + const persisted = JSON.parse(authority.readSerializedState() ?? '{}') + expect(persisted).toMatchObject({ + settings: { agentStatusHooksEnabled: true }, + ui: { marker: 'still-writable' } + }) + } finally { + authority.close() + } + } + ) + + it('keeps a JSON-only active profile on the legacy path without creating SQLite', async () => { + const profileId = 'json-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeDataFile(profileDirectory, getDefaultPersistedState(userDataPath)) + writeActiveProfileIndex(userDataPath, profileId) + + await runAgentHooksOff(userDataPath) + + expect(existsSync(getOrcaProfileStateDatabaseFile(profileId, userDataPath))).toBe(false) + expect( + JSON.parse(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).settings + .agentStatusHooksEnabled + ).toBe(false) + }) + + it('fails closed when a profile has corrupt SQLite alongside legacy JSON', async () => { + const profileId = 'corrupt-profile' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + const state = getDefaultPersistedState(userDataPath) + writeDataFile(profileDirectory, state) + writeActiveProfileIndex(userDataPath, profileId) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + writeFileSync(databaseFile, 'not sqlite', 'utf-8') + const before = readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8') + + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf-8')).toBe(before) + }) + + it('fails closed when a profile index is present but unreadable', async () => { + const legacy = getDefaultPersistedState(userDataPath) + writeDataFile(userDataPath, legacy) + writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ torn', 'utf-8') + const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8') + + await runAgentHooksOff(userDataPath) + + expect(process.exitCode).toBe(1) + expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')).toBe(before) + }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 4fcfe64f9b7..11902e985c2 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { CommandHandler } from '../dispatch' import { printResult } from '../format' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { RuntimeClientError, type RuntimeClient, @@ -16,6 +17,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { PersistedState } from '../../shared/persisted-state-types' import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' +import type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings' type AgentHookCommandResult = { enabled: boolean @@ -27,28 +29,15 @@ type AgentHookCommandResult = { // Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap. const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000 -function getDataPath(): string { - const userDataPath = getDefaultUserDataPath() - const indexPath = join(userDataPath, 'orca-profile-index.json') - for (const candidate of [indexPath, `${indexPath}.bak`]) { - try { - const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) - if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) { - continue - } - const profileId = parsed.activeProfileId - if ( - typeof profileId === 'string' && - /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) && - parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId) - ) { - return join(userDataPath, 'profiles', profileId, 'orca-data.json') - } - } catch { - // Try the profile-index backup, then the legacy pre-profile path. - } - } - return join(userDataPath, 'orca-data.json') +async function getDataPath(): Promise { + return ( + (await getProfileStateLocation())?.dataFile ?? join(getDefaultUserDataPath(), 'orca-data.json') + ) +} + +async function getProfileStateLocation(): Promise { + const { getActiveProfileStateLocation } = await import('../profile-state-location.js') + return getActiveProfileStateLocation() } function isRecord(value: unknown): value is Record { @@ -92,11 +81,29 @@ function writePersistedState(dataPath: string, state: PersistedState): void { } } -function readHookSettingsFromDisk(): Pick< - GlobalSettings, - 'agentStatusHooksEnabled' | 'disabledTuiAgents' +async function readHookSettingsFromDisk(): Promise< + Pick > { - const state = readPersistedState(getDataPath()) + const { acquireProfileStateRuntimeAdmission } = + await import('../../main/persistence/profile-state/profile-state-access.js') + const admission = acquireProfileStateRuntimeAdmission(getDefaultUserDataPath()) + try { + return await readAdmittedHookSettingsFromDisk() + } finally { + admission.release() + } +} + +async function readAdmittedHookSettingsFromDisk(): Promise< + Pick +> { + const profileStateLocation = await getProfileStateLocation() + if (profileStateLocation) { + const { readAgentHookSettingsFromProfileState } = + await import('../../main/persistence/profile-state/profile-state-offline-settings.js') + return readAgentHookSettingsFromProfileState(profileStateLocation) + } + const state = readPersistedState(await getDataPath()) return { agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false, disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents) @@ -123,11 +130,32 @@ async function readHookSettings( return readHookSettingsFromDisk() } -function updateEnabledOnDisk(enabled: boolean): { +async function updateEnabledOnDisk(enabled: boolean): Promise<{ settingsPath: string settings: Pick -} { - const dataPath = getDataPath() +}> { + const { acquireProfileStateMaintenance } = + await import('../../main/persistence/profile-state/profile-state-access.js') + // A stopped-status response cannot exclude first migration racing this JSON write. + const maintenance = acquireProfileStateMaintenance(getDefaultUserDataPath()) + try { + return await updateAdmittedEnabledOnDisk(enabled) + } finally { + maintenance.release() + } +} + +async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{ + settingsPath: string + settings: Pick +}> { + const profileStateLocation = await getProfileStateLocation() + if (profileStateLocation) { + const { updateAgentHookSettingsFromProfileState } = + await import('../../main/persistence/profile-state/profile-state-offline-settings.js') + return updateAgentHookSettingsFromProfileState(profileStateLocation, enabled) + } + const dataPath = await getDataPath() const state = readPersistedState(dataPath) state.settings = { ...getDefaultPersistedState(homedir()).settings, @@ -145,20 +173,18 @@ function updateEnabledOnDisk(enabled: boolean): { } async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise { - try { - const status = await client.getCliStatus() - if (!status.result.runtime.reachable) { - return false + const status = await client.getCliStatus() + if (!status.result.runtime.reachable) { + if (status.result.app.running) { + throw new RuntimeClientError( + 'runtime_error', + 'Orca is running but unavailable. Retry when it responds, or stop Orca before changing agent hooks offline.' + ) } - await client.call( - 'settings.update', - { agentStatusHooksEnabled: enabled }, - { timeoutMs: 10_000 } - ) - return true - } catch { return false } + await client.call('settings.update', { agentStatusHooksEnabled: enabled }, { timeoutMs: 10_000 }) + return true } function localSuccess(result: TResult): RuntimeRpcSuccess { @@ -193,8 +219,8 @@ async function setAgentHooksEnabled( const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } = await import('../../main/agent-hooks/managed-agent-hook-controls.js') const updatedRuntime = await updateRunningRuntime(client, enabled) - const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled) - const settingsPath = offlineUpdate?.settingsPath ?? getDataPath() + const offlineUpdate = updatedRuntime ? null : await updateEnabledOnDisk(enabled) + const settingsPath = offlineUpdate?.settingsPath ?? (await getDataPath()) const statuses = updatedRuntime ? getManagedAgentHookStatuses() : await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings) @@ -207,7 +233,8 @@ async function setAgentHooksEnabled( } export const AGENT_HOOK_HANDLERS: Record = { - 'agent hooks prepare-codex': async ({ client }) => { + 'agent hooks prepare-codex': async ({ client, flags }) => { + rejectRemoteHookSelection(flags) if (process.env.WSL_DISTRO_NAME?.trim()) { try { await client.call( @@ -231,23 +258,33 @@ export const AGENT_HOOK_HANDLERS: Record = { settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') }) }, - 'agent hooks status': async ({ json }) => { + 'agent hooks status': async ({ json, flags }) => { + rejectRemoteHookSelection(flags) const { getManagedAgentHookStatuses } = await import('../../main/agent-hooks/managed-agent-hook-controls.js') const result: AgentHookCommandResult = { - enabled: readHookSettingsFromDisk().agentStatusHooksEnabled, - settingsPath: getDataPath(), + enabled: (await readHookSettingsFromDisk()).agentStatusHooksEnabled, + settingsPath: await getDataPath(), appliedBy: 'offline', statuses: getManagedAgentHookStatuses() } printResult(localSuccess(result), json, formatAgentHookCommandResult) }, - 'agent hooks off': async ({ client, json }) => { + 'agent hooks off': async ({ client, json, flags }) => { + rejectRemoteHookSelection(flags) const result = await setAgentHooksEnabled(client, false) printResult(localSuccess(result), json, formatAgentHookCommandResult) }, - 'agent hooks on': async ({ client, json }) => { + 'agent hooks on': async ({ client, json, flags }) => { + rejectRemoteHookSelection(flags) const result = await setAgentHooksEnabled(client, true) printResult(localSuccess(result), json, formatAgentHookCommandResult) } } + +function rejectRemoteHookSelection(flags: ReadonlyMap): void { + rejectRemoteSelectionFlags( + flags, + 'agent hooks; run this command on the machine whose hooks you want to manage.' + ) +} diff --git a/src/cli/handlers/profile-state-recovery-admission.test.ts b/src/cli/handlers/profile-state-recovery-admission.test.ts new file mode 100644 index 00000000000..1da200843c9 --- /dev/null +++ b/src/cli/handlers/profile-state-recovery-admission.test.ts @@ -0,0 +1,245 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as durableFileWrite from '../../main/durable-file-write' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from '../../main/persistence/profile-state/profile-state-access' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../../main/persistence/profile-state/profile-state-database' +import { + importProfileStateJson, + readProfileStateSnapshot +} from '../../main/persistence/profile-state/profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../../main/persistence/profile-state/profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot' +import { restoreProfileStateDatabaseBackup } from '../../main/persistence/profile-state/profile-state-database-recovery' +import { + assertNoRetainedProfileStateExports, + ProfileStateRecoveryRequiredError +} from '../../main/persistence/profile-state/profile-state-recovery-required' +import { RuntimeClient } from '../runtime-client' +import { PROFILE_STATE_HANDLERS } from './profile-state' + +const mocks = vi.hoisted(() => ({ root: vi.fn(), status: vi.fn() })) +vi.mock('../runtime-client', () => ({ + getDefaultUserDataPath: mocks.root, + RuntimeClient: class { + getCliStatus = mocks.status + }, + RuntimeClientError: class extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } + } +})) + +const roots: string[] = [] +const profileId = 'admission-recovery' +const backupState = { + settings: { + theme: 'restored', + httpProxyUrl: 'sealed:backup', + electronHttp1CompatibilityMode: true + }, + extension: { unknown: [null, '\ud800', 'backup'] }, + opaque: null +} +const liveState = { + settings: { theme: 'runtime-before-restore', httpProxyUrl: 'sealed:live' }, + extension: { unknown: [null, '\ud800', 'live'] }, + opaque: null +} + +beforeEach(() => { + mocks.status.mockReset().mockResolvedValue({ + result: { app: { running: false }, runtime: { reachable: false } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-recovery-admission-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const backupId = createProfileStateDatabaseBackupId() + const backupPath = profileStateDatabaseBackupPath(databasePath, backupId) + const source = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(source.db, JSON.stringify(backupState)) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + importProfileStateJson(source.db, JSON.stringify(liveState), { expectedRevision: 1 }) + } finally { + source.db.close() + } + mocks.root.mockReturnValue(root) + return { root, directory, databasePath, dataFile, backupId, backupPath } +} + +function rollback(profile: Awaited>): Promise { + const handler = PROFILE_STATE_HANDLERS['profile state rollback'] + if (handler === undefined) { + throw new Error('Profile rollback handler is missing') + } + return handler({ + flags: new Map([['backup', profile.backupId]]), + client: new RuntimeClient(profile.root), + cwd: profile.root, + json: true + }) +} + +function state(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return readProfileStateSnapshot(opened.db) + } finally { + opened.db.close() + } +} + +describe('offline recovery excludes runtime admission', () => { + it('refuses rollback without changing the database when a move journal is unresolved', async () => { + const profile = await fixture() + const before = readFileSync(profile.databasePath) + const intents = join(profile.root, 'profile-move-intents') + mkdirSync(intents) + const intentPath = join(intents, '00000000-0000-0000-0000-000000000001.json') + writeFileSync(intentPath, '{"partial":true}') + await expect(rollback(profile)).rejects.toThrow('pending project move') + expect(readFileSync(profile.databasePath)).toEqual(before) + expect(readFileSync(intentPath, 'utf8')).toBe('{"partial":true}') + }) + + it('refuses recovery before any mutation when a runtime has already entered', async () => { + const profile = await fixture() + const original = readFileSync(profile.databasePath) + const backup = readFileSync(profile.backupPath) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + const runtime = openProfileStateDatabase(profile.databasePath, profileId) + try { + await expect(rollback(profile)).rejects.toThrow('in use') + expect(mocks.status).not.toHaveBeenCalled() + expect(JSON.parse(readProfileStateSnapshot(runtime.db).json)).toEqual(liveState) + expect(readFileSync(profile.databasePath)).toEqual(original) + expect(readFileSync(profile.backupPath)).toEqual(backup) + expect( + readdirSync(profile.directory).some((name) => name.startsWith('profile-state-corrupt')) + ).toBe(false) + } finally { + runtime.db.close() + admission.release() + } + }) + + it('blocks startup between the stopped census and restoration while preserving complete original and restored state', async () => { + const profile = await fixture() + const original = readFileSync(profile.databasePath) + const backup = readFileSync(profile.backupPath) + mocks.status.mockImplementation(async () => { + const stopped = { result: { app: { running: false }, runtime: { reachable: false } } } + expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(profile.root)).toThrow('in use') + return stopped + }) + + await rollback(profile) + + expect(mocks.status).toHaveBeenCalledOnce() + expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState) + expect(readFileSync(profile.backupPath)).toEqual(backup) + const quarantine = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt') + ) + expect(quarantine).toBeDefined() + if (quarantine === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + const quarantined = join(profile.directory, quarantine, 'profile-state.db') + expect(readFileSync(quarantined)).toEqual(original) + expect(JSON.parse(state(quarantined).json)).toEqual(liveState) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState) + admission.release() + }) + + it('keeps startup blocked after failed durable publication and permits an explicit successful retry', async () => { + const profile = await fixture() + const backup = readFileSync(profile.backupPath) + const rename = durableFileWrite.renameDurableSync + const failure = vi + .spyOn(durableFileWrite, 'renameDurableSync') + .mockImplementation((from, to) => { + if (to === profile.databasePath) { + throw new Error('injected recovery publication failure') + } + rename(from, to) + }) + await expect(rollback(profile)).rejects.toThrow('injected recovery publication failure') + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => + assertNoRetainedProfileStateExports({ + dataFile: profile.dataFile, + databaseFile: profile.databasePath, + profileId + }) + ).toThrow(ProfileStateRecoveryRequiredError) + } finally { + admission.release() + } + expect(readFileSync(profile.backupPath)).toEqual(backup) + const quarantine = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt') + ) + if (quarantine === undefined) { + throw new Error('Recovery did not preserve original state') + } + expect(JSON.parse(state(join(profile.directory, quarantine, 'profile-state.db')).json)).toEqual( + liveState + ) + + failure.mockRestore() + await rollback(profile) + + expect(JSON.parse(state(profile.databasePath).json)).toEqual(backupState) + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it('rejects fabricated or released maintenance handles before replacing any database bytes', async () => { + const profile = await fixture() + const maintenance = acquireProfileStateMaintenance(profile.root) + const original = readFileSync(profile.databasePath) + const options = { ...profile, profileId } + expect(() => + restoreProfileStateDatabaseBackup({ ...options, maintenance: { ...maintenance } }) + ).toThrow('acquired') + maintenance.release() + expect(() => restoreProfileStateDatabaseBackup({ ...options, maintenance })).toThrow('released') + expect(readFileSync(profile.databasePath)).toEqual(original) + }) +}) diff --git a/src/cli/handlers/profile-state.test.ts b/src/cli/handlers/profile-state.test.ts new file mode 100644 index 00000000000..c6eb514d539 --- /dev/null +++ b/src/cli/handlers/profile-state.test.ts @@ -0,0 +1,487 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as durableFileWrite from '../../main/durable-file-write' +import * as http1Marker from '../../main/startup/http1-compatibility-marker' +import { readPersistedHttp1CompatibilityMode } from '../../main/startup/http1-compatibility-profile-state' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from '../../main/persistence/profile-state/profile-state-database' +import { + exportProfileStateJson, + importProfileStateJson +} from '../../main/persistence/profile-state/profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../../main/persistence/profile-state/profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from '../../main/persistence/profile-state/profile-state-database-snapshot' +import { profileStateJsonExportPath } from '../../main/persistence/profile-state/profile-state-export-path' +import { main } from '../index' + +const { getCliStatusMock, getDefaultUserDataPathMock, runtimeClientConstructorMock } = vi.hoisted( + () => ({ + getCliStatusMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(), + runtimeClientConstructorMock: vi.fn() + }) +) + +vi.mock('../runtime-client', () => { + class RuntimeClientError extends Error { + readonly code: string + readonly data: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.code = code + this.data = data + } + } + + class RuntimeClient { + getCliStatus = getCliStatusMock + + constructor( + _userDataPath?: string, + _requestTimeoutMs?: number, + remotePairingCode?: string | null, + environmentSelector?: string | null + ) { + runtimeClientConstructorMock(remotePairingCode, environmentSelector) + } + } + + return { + RuntimeClient, + RuntimeClientError, + getDefaultUserDataPath: getDefaultUserDataPathMock + } +}) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() + runtimeClientConstructorMock.mockReset() + process.exitCode = 0 +}) + +function createProfile(): { + userDataPath: string + dataFile: string + databaseFile: string + exportPath: string +} { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-state-cli-')) + temporaryDirectories.push(userDataPath) + const profileId = 'profile-cli-recovery' + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf8' + ) + const dataFile = join(profileDirectory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(profileDirectory) + const exportPath = profileStateJsonExportPath(dataFile, 1) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'old' } }), 'utf8') + writeFileSync( + exportPath, + JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } }), + 'utf8' + ) + writeFileSync(databaseFile, 'damaged sqlite primary', 'utf8') + writeFileSync(`${databaseFile}-wal`, 'damaged wal sidecar', 'utf8') + return { userDataPath, dataFile, databaseFile, exportPath } +} + +async function createDatabaseBackup( + profile: ReturnType, + profileId = 'profile-cli-recovery' +) { + const id = createProfileStateDatabaseBackupId() + const path = profileStateDatabaseBackupPath(profile.databaseFile, id) + const source = openProfileStateDatabase(join(profile.userDataPath, 'backup-source.db'), profileId) + try { + importProfileStateJson( + source.db, + JSON.stringify({ + settings: { + theme: 'sqlite-recovered', + electronHttp1CompatibilityMode: true, + httpProxyUrl: 'sealed:unchanged' + }, + extensionState: { retained: true } + }) + ) + await writeProfileStateDatabaseSnapshotAsync(source.db, path) + } finally { + source.db.close() + } + return { id, path } +} + +describe('profile-state CLI recovery', () => { + beforeEach(() => { + getCliStatusMock.mockResolvedValue({ + id: 'status', + ok: true, + result: { + app: { running: false, pid: null }, + runtime: { state: 'not_running', reachable: false, runtimeId: null }, + graph: { state: 'not_running' } + }, + _meta: { runtimeId: 'test' } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('restores the selected export through the offline CLI command', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(false) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'recovered', electronHttp1CompatibilityMode: true } }) + ) + expect( + JSON.parse(readFileSync(join(profile.userDataPath, 'http1-compatibility.json'), 'utf8')) + ).toMatchObject({ + enabled: true, + profileId: 'profile-cli-recovery' + }) + const output = vi.mocked(console.log).mock.calls.at(-1)?.[0] + expect(String(output)).toContain('quarantineDirectory') + expect(getCliStatusMock).toHaveBeenCalledOnce() + }) + + it('adopts current JSON through CLI with an honest source description', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + const original = readFileSync(profile.dataFile) + await main(['profile', 'state', 'rollback', '--current-json'], profile.userDataPath) + expect(readFileSync(profile.dataFile)).toEqual(original) + expect(existsSync(profile.databaseFile)).toBe(false) + const output = String(vi.mocked(console.log).mock.calls.at(-1)?.[0]) + expect(output).toContain('source: current JSON') + expect(output).not.toContain('revision:') + }) + + it.each([ + ['--current-json', '--revision', '1'], + ['--current-json', '--backup', '1'], + ['--current-json=false'] + ])('rejects ambiguous current JSON arguments: %s', async (...flags) => { + getCliStatusMock.mockClear() + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + await main(['profile', 'state', 'rollback', ...flags, '--json'], profile.userDataPath) + expect(process.exitCode).toBe(1) + expect(existsSync(profile.databaseFile)).toBe(true) + expect(getCliStatusMock).not.toHaveBeenCalled() + }) + + it('keeps profile-state recovery local when remote selection is configured', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code') + vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment') + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) + expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain('quarantineDirectory') + }) + + it.each([true, false])( + 'recovers an absent database and archives every export (legacy JSON present: %s)', + async (hasJson) => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + rmSync(profile.databaseFile) + rmSync(`${profile.databaseFile}-wal`) + if (!hasJson) { + rmSync(profile.dataFile) + } + const laterExport = profileStateJsonExportPath(profile.dataFile, 2) + writeFileSync(laterExport, JSON.stringify({ settings: { theme: 'later' } })) + const selectedBytes = readFileSync(profile.exportPath) + const laterBytes = readFileSync(laterExport) + + await main( + ['profile', 'state', 'rollback', '--revision', '1', '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(0) + expect(readFileSync(profile.dataFile)).toEqual(selectedBytes) + expect(existsSync(profile.exportPath)).toBe(false) + expect(existsSync(laterExport)).toBe(false) + const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(output).toMatchObject({ ok: true, result: { removedDatabaseFiles: [] } }) + if ( + !output || + typeof output !== 'object' || + !('result' in output) || + !output.result || + typeof output.result !== 'object' || + !('quarantineDirectory' in output.result) || + typeof output.result.quarantineDirectory !== 'string' + ) { + throw new Error('Expected rollback archive directory') + } + const archive = output.result.quarantineDirectory + expect(readFileSync(join(archive, basename(profile.exportPath)))).toEqual(selectedBytes) + expect(readFileSync(join(archive, basename(laterExport)))).toEqual(laterBytes) + expect(existsSync(join(archive, basename(profile.dataFile)))).toBe(hasJson) + expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true) + } + ) + + it('preserves all live recovery sources when archiving an export fails', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + const unavailableExport = profileStateJsonExportPath(profile.dataFile, 2) + mkdirSync(unavailableExport) + const original = readFileSync(profile.dataFile) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.dataFile)).toEqual(original) + expect(existsSync(profile.exportPath)).toBe(true) + expect(existsSync(profile.databaseFile)).toBe(true) + expect(existsSync(`${profile.databaseFile}-wal`)).toBe(true) + }) + + it('falls back to restored settings when refreshing the marker fails', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery') + const writeFileDurableSync = durableFileWrite.writeFileDurableSync + vi.spyOn(durableFileWrite, 'writeFileDurableSync').mockImplementation( + (tmp, target, contents) => { + if (target === join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE)) { + throw new Error('injected marker write failure') + } + return writeFileDurableSync(tmp, target, contents) + } + ) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(false) + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBeNull() + expect(readPersistedHttp1CompatibilityMode(profile.userDataPath)).toBe(true) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('quarantineDirectory') + }) + + it('preserves SQLite authority when the old marker cannot be invalidated', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + // A directory at the marker path makes non-recursive removal fail on every supported OS. + mkdirSync(join(profile.userDataPath, http1Marker.HTTP1_COMPATIBILITY_MARKER_FILE)) + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(existsSync(profile.exportPath)).toBe(true) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'old' } }) + ) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).not.toContain( + 'quarantineDirectory' + ) + expect(process.exitCode).toBe(1) + }) + + it('does not invalidate the active setting for an invalid recovery export', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery') + writeFileSync(profile.exportPath, 'invalid JSON') + + await main(['profile', 'state', 'rollback', '--revision', '1', '--json'], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(true) + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBe(true) + expect(process.exitCode).toBe(1) + }) + + it('rejects an explicit remote selector instead of silently ignoring it', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main( + ['profile', 'state', 'rollback', '--revision', '1', '--environment', 'remote', '--json'], + profile.userDataPath + ) + + expect(existsSync(profile.databaseFile)).toBe(true) + expect(vi.mocked(console.log).mock.calls.at(-1)?.[0]).toContain( + '`--environment` does not retarget profile-state recovery' + ) + }) + + it.each([['--revision', '1'], ['--current-json']])( + 'refuses rollback while runtime is reachable: %s', + async (...flags) => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + app: { running: true, pid: 123 }, + runtime: { state: 'ready', reachable: true, runtimeId: 'desktop' }, + graph: { state: 'ready' } + }, + _meta: { runtimeId: 'test' } + }) + + await main(['profile', 'state', 'rollback', ...flags], profile.userDataPath) + + expect(existsSync(profile.databaseFile)).toBe(true) + expect(readFileSync(profile.dataFile, 'utf8')).toBe( + JSON.stringify({ settings: { theme: 'old' } }) + ) + expect(vi.mocked(console.error).mock.calls.at(-1)?.[0]).toContain('Stop Orca') + } + ) + + it('lists SQLite backups alongside JSON exports without opening the damaged primary', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockClear() + + await main(['profile', 'state', 'exports', '--json'], profile.userDataPath) + + const output: unknown = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) + expect(output).toMatchObject({ + ok: true, + result: { exportPaths: [profile.exportPath], backups: [{ id: backup.id, path: backup.path }] } + }) + expect(getCliStatusMock).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'restores SQLite backup authority with damaged database present=%s', + async (hasDatabase) => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, false, 'profile-cli-recovery') + if (!hasDatabase) { + rmSync(profile.databaseFile) + rmSync(`${profile.databaseFile}-wal`) + rmSync(profile.dataFile) + } + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(0) + expect(existsSync(profile.dataFile)).toBe(false) + expect(existsSync(backup.path)).toBe(true) + const restored = openProfileStateDatabaseReadOnly( + profile.databaseFile, + 'profile-cli-recovery' + ) + try { + expect(JSON.parse(exportProfileStateJson(restored.db))).toMatchObject({ + settings: { theme: 'sqlite-recovered', httpProxyUrl: 'sealed:unchanged' }, + extensionState: { retained: true } + }) + } finally { + restored.db.close() + } + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBe(true) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('"storage": "sqlite"') + } + ) + + it('rejects a backup belonging to another profile before invalidating the startup marker', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile, 'foreign-profile') + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + http1Marker.writeHttp1CompatibilityMarker(profile.userDataPath, true, 'profile-cli-recovery') + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect( + http1Marker.readHttp1CompatibilityMarker(profile.userDataPath, 'profile-cli-recovery') + ).toBe(true) + }) + + it('requires an unambiguous retained backup selection', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--revision', '1', '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('exactly one') + }) + + it('rejects escaping backup IDs without touching any recovery artifact', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + + await main( + ['profile', 'state', 'rollback', '--backup', '../../outside', '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('backup is unavailable') + }) + + it('refuses database backup restoration while the app is running', async () => { + const profile = createProfile() + const backup = await createDatabaseBackup(profile) + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + getCliStatusMock.mockResolvedValueOnce({ + result: { app: { running: true }, runtime: { reachable: false } } + }) + + await main( + ['profile', 'state', 'rollback', '--backup', backup.id, '--json'], + profile.userDataPath + ) + + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain('Stop Orca') + }) +}) diff --git a/src/cli/handlers/profile-state.ts b/src/cli/handlers/profile-state.ts new file mode 100644 index 00000000000..b84cd694df6 --- /dev/null +++ b/src/cli/handlers/profile-state.ts @@ -0,0 +1,153 @@ +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' +import { + getDefaultUserDataPath, + RuntimeClientError, + type RuntimeClient, + type RuntimeRpcSuccess +} from '../runtime-client' +import { + getProfileStateExports, + rollbackProfileState +} from '../../main/persistence/profile-state/profile-state-recovery-command' +import { acquireProfileStateMaintenance } from '../../main/persistence/profile-state/profile-state-access' +import { + isProfileStateRecoveryCommandError, + type ProfileStateExportsResult, + type ProfileStateRollbackResult, + type ProfileStateRecoverySelector +} from '../../shared/profile-state-recovery-command' +import { + canLaunchProfileStateRecovery, + launchProfileStateRecovery +} from '../runtime/profile-state-recovery-launch' + +function localSuccess(result: TResult): RuntimeRpcSuccess { + return { + id: 'local', + ok: true, + result, + _meta: { runtimeId: 'local' } + } +} + +function formatExports(result: ProfileStateExportsResult): string { + return [ + `profileId: ${result.profileId}`, + `dataFile: ${result.dataFile}`, + `databaseFile: ${result.databaseFile}`, + 'JSON exports:', + ...(result.exportPaths.length > 0 ? result.exportPaths : ['(none)']), + 'SQLite backups:', + ...(result.backups.length > 0 + ? result.backups.map((backup) => `${backup.id}: ${backup.path}`) + : ['(none)']) + ].join('\n') +} + +function formatRollback(result: ProfileStateRollbackResult): string { + return [ + `profileId: ${result.profileId}`, + result.revision === null ? 'source: current JSON' : `revision: ${result.revision}`, + `storage: ${result.storage}`, + `restored: ${result.restoredPath}`, + `quarantine: ${result.quarantineDirectory}`, + `removedDatabaseFiles: ${result.removedDatabaseFiles.length}` + ].join('\n') +} + +function rejectProfileStateRemoteSelection(flags: ReadonlyMap): void { + rejectRemoteSelectionFlags( + flags, + "profile-state recovery; it operates on this machine's active profile." + ) +} + +async function requireStoppedRuntime(client: RuntimeClient): Promise { + const status = await client.getCliStatus() + if (status.result.runtime.reachable || status.result.app.running) { + throw new RuntimeClientError( + 'runtime_error', + 'Stop Orca before profile-state rollback so no process can write the SQLite database.' + ) + } +} + +function parseRevision(flags: Map): number { + const rawRevision = flags.get('revision') + if (typeof rawRevision !== 'string' || rawRevision.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --revision.') + } + const revision = Number(rawRevision) + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid profile-state revision: ${rawRevision}` + ) + } + return revision +} + +export const PROFILE_STATE_HANDLERS: Record = { + 'profile state exports': async ({ flags, json }) => { + rejectProfileStateRemoteSelection(flags) + const result = translateRecoveryError(() => getProfileStateExports(getDefaultUserDataPath())) + printResult(localSuccess(result), json, formatExports) + }, + 'profile state rollback': async ({ client, flags, json }) => { + rejectProfileStateRemoteSelection(flags) + const selector = parseSelector(flags) + const userDataPath = getDefaultUserDataPath() + let result: ProfileStateRollbackResult + if (canLaunchProfileStateRecovery()) { + await requireStoppedRuntime(client) + result = await launchProfileStateRecovery({ userDataPath, selector }) + } else { + const maintenance = acquireProfileStateMaintenance(userDataPath) + try { + await requireStoppedRuntime(client) + result = translateRecoveryError(() => + rollbackProfileState(userDataPath, selector, maintenance) + ) + } finally { + maintenance.release() + } + } + printResult(localSuccess(result), json, formatRollback) + } +} + +function parseSelector(flags: Map): ProfileStateRecoverySelector { + if (['revision', 'backup', 'current-json'].filter((flag) => flags.has(flag)).length !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Select exactly one of --revision, --backup, or --current-json.' + ) + } + if (flags.has('current-json')) { + if (flags.get('current-json') !== true) { + throw new RuntimeClientError('invalid_argument', '--current-json does not take a value.') + } + return { kind: 'current-json' } + } + if (!flags.has('backup')) { + return { kind: 'json', revision: parseRevision(flags) } + } + const backupId = flags.get('backup') + if (typeof backupId !== 'string' || backupId.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Profile-state rollback requires --backup.') + } + return { kind: 'sqlite', backupId } +} + +function translateRecoveryError(operation: () => T): T { + try { + return operation() + } catch (error) { + if (isProfileStateRecoveryCommandError(error)) { + throw new RuntimeClientError(error.code, error.message) + } + throw error + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 33566dcf837..b2e01c04e56 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -38,7 +38,8 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { commandPath[0] === 'serve' || commandPath[0] === 'agent' || commandPath[0] === 'vm' || - commandPath[0] === 'agent-context' + commandPath[0] === 'agent-context' || + commandPath[0] === 'profile' ) } diff --git a/src/cli/profile-state-location.ts b/src/cli/profile-state-location.ts new file mode 100644 index 00000000000..1e9325872a1 --- /dev/null +++ b/src/cli/profile-state-location.ts @@ -0,0 +1,13 @@ +import { getActiveProfileStateLocation as resolveActiveProfileStateLocation } from '../main/persistence/profile-state/profile-state-active-location' +import { RuntimeClientError, getDefaultUserDataPath } from './runtime-client' + +export function getActiveProfileStateLocation(userDataPath = getDefaultUserDataPath()) { + try { + return resolveActiveProfileStateLocation(userDataPath) + } catch (error) { + throw new RuntimeClientError( + 'runtime_error', + error instanceof Error ? error.message : String(error) + ) + } +} diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index fdf77082729..1487e2364f7 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -153,7 +153,9 @@ describe('RuntimeClient module-graph deferral', () => { async (_name, argv, constructs) => { vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code') vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment') - getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } }) + getCliStatusMock.mockResolvedValue({ + result: { runtime: { reachable: false }, app: { running: false } } + }) await main(argv, '/tmp/repo') diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index a326ae333f5..ebf10c2aaa9 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -255,7 +255,7 @@ function waitForRecipeJson(child: ReturnType): Promise 0) { return overrideExecutable diff --git a/src/cli/runtime/profile-state-recovery-launch.test.ts b/src/cli/runtime/profile-state-recovery-launch.test.ts new file mode 100644 index 00000000000..36ae0e7868f --- /dev/null +++ b/src/cli/runtime/profile-state-recovery-launch.test.ts @@ -0,0 +1,154 @@ +import { realpathSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX +} from '../../shared/profile-state-recovery-command' +import { + canLaunchProfileStateRecovery, + launchProfileStateRecovery +} from './profile-state-recovery-launch' + +const mocks = vi.hoisted(() => ({ run: vi.fn() })) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: mocks.run })) +vi.mock('./launch', () => ({ + resolveForegroundOrcaExecutable: () => '/packaged/Orca', + resolveAppRoot: () => '/application', + getExecutableAppArgs: () => ['/application'], + stripElectronRunAsNode: (env: NodeJS.ProcessEnv) => { + const clean = { ...env } + delete clean.ELECTRON_RUN_AS_NODE + return clean + } +})) + +const result = { + profileId: 'profile', + dataFile: '/root/orca-data.json', + databaseFile: '/root/profile-state.db', + exportPaths: [], + backups: [], + revision: 1, + quarantineDirectory: '/root/quarantine', + removedDatabaseFiles: [], + storage: 'json', + restoredPath: '/root/orca-data.json' +} +const request = { userDataPath: '.', selector: { kind: 'json', revision: 1 } } as const +beforeEach(() => { + mocks.run.mockReset().mockResolvedValue({ + code: 0, + signal: null, + timedOut: false, + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result })}\n`, + stderr: '' + }) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('profile-state recovery launch', () => { + it('preserves direct participation only for plain Node without an explicit Electron executable', () => { + vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) + vi.stubEnv('ORCA_APP_EXECUTABLE', undefined) + expect(canLaunchProfileStateRecovery()).toBe(false) + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + expect(canLaunchProfileStateRecovery()).toBe(true) + vi.stubEnv('ELECTRON_RUN_AS_NODE', undefined) + vi.stubEnv('ORCA_APP_EXECUTABLE', '/explicit/Orca') + expect(canLaunchProfileStateRecovery()).toBe(true) + }) + + it('uses a foreground-safe serve request and binds the canonical recovery root', async () => { + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/root') + expect(await launchProfileStateRecovery(request)).toEqual(result) + expect(mocks.run).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/packaged/Orca', + args: [ + '/application', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ ...request, userDataPath: realpathSync('.') }) + ], + env: expect.objectContaining({ + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_USER_DATA_PATH: realpathSync('.') + }), + timeoutMs: null + }) + ) + expect(mocks.run.mock.calls[0][0].env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') + }) + + it('round-trips current JSON selection without requiring an invented revision', async () => { + const current = { ...result, revision: null } + mocks.run.mockResolvedValue({ + code: 0, + signal: null, + timedOut: false, + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: true, result: current })}\n`, + stderr: '' + }) + expect( + await launchProfileStateRecovery({ userDataPath: '.', selector: { kind: 'current-json' } }) + ).toEqual(current) + expect(mocks.run.mock.calls[0][0].args.at(-1)).toContain('"kind":"current-json"') + }) + + it('preserves a structured refusal from the lock owner', async () => { + mocks.run.mockResolvedValue({ + code: 1, + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify({ ok: false, code: 'invalid_argument', message: 'Backup unavailable' })}` + }) + await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Backup unavailable' + }) + }) + + it.each([ + { code: 1 }, + { signal: 'SIGKILL' }, + { timedOut: true }, + { outputTruncated: true }, + { stdout: '' }, + { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{` }, + { stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{"ok":true,"result":{}}` }, + { + stdout: `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}\n${PROFILE_STATE_RECOVERY_RESULT_PREFIX}{}` + } + ])('rejects incomplete or ambiguous child results %j', async (override) => { + const original = await mocks.run() + mocks.run.mockResolvedValue({ ...original, ...override }) + await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ + code: 'runtime_error' + }) + }) + + it('propagates launch failure without retrying another recovery path', async () => { + mocks.run.mockRejectedValue(new Error('Executable unavailable')) + await expect(launchProfileStateRecovery(request)).rejects.toThrow('Executable unavailable') + expect(mocks.run).toHaveBeenCalledOnce() + }) + + it('retains bounded child diagnostics when recovery exits without a result', async () => { + mocks.run.mockResolvedValue({ + code: null, + signal: 'SIGTRAP', + timedOut: false, + stdout: '', + stderr: `${'x'.repeat(5000)}\nsandbox unavailable\n` + }) + await expect(launchProfileStateRecovery(request)).rejects.toMatchObject({ + data: { + exitCode: null, + signal: 'SIGTRAP', + timedOut: false, + outputTruncated: false, + stderr: `${'x'.repeat(5000)}\nsandbox unavailable`.slice(-4096) + } + }) + expect(mocks.run).toHaveBeenCalledOnce() + }) +}) diff --git a/src/cli/runtime/profile-state-recovery-launch.ts b/src/cli/runtime/profile-state-recovery-launch.ts new file mode 100644 index 00000000000..dd0d68ff2e4 --- /dev/null +++ b/src/cli/runtime/profile-state-recovery-launch.ts @@ -0,0 +1,78 @@ +import { realpathSync } from 'node:fs' +import { runProcess } from '../../shared/child-process/run-process' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX, + profileStateRecoveryResponseSchema, + type ProfileStateRecoveryRequest, + type ProfileStateRollbackResult +} from '../../shared/profile-state-recovery-command' +import { + getExecutableAppArgs, + resolveAppRoot, + resolveForegroundOrcaExecutable, + stripElectronRunAsNode +} from './launch' +import { RuntimeClientError } from './types' + +export function canLaunchProfileStateRecovery(): boolean { + return process.env.ELECTRON_RUN_AS_NODE === '1' || !!process.env.ORCA_APP_EXECUTABLE?.trim() +} + +export async function launchProfileStateRecovery( + request: ProfileStateRecoveryRequest +): Promise { + const executable = resolveForegroundOrcaExecutable() + const userDataPath = realpathSync(request.userDataPath) + const response = await runProcess({ + program: executable, + args: [ + ...getExecutableAppArgs(executable), + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ ...request, userDataPath }) + ], + cwd: resolveAppRoot(), + env: { + ...stripElectronRunAsNode(process.env), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_USER_DATA_PATH: userDataPath + }, + // Recovery may copy large backups; the lock owner must finish or be explicitly terminated. + timeoutMs: null + }) + const lines = response.stdout + .split(/\r?\n/) + .filter((line) => line.startsWith(PROFILE_STATE_RECOVERY_RESULT_PREFIX)) + if (!response.outputTruncated && lines.length === 1) { + let parsed: unknown + try { + parsed = JSON.parse(lines[0].slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length)) + } catch { + throw new RuntimeClientError( + 'runtime_error', + 'Orca recovery returned an invalid response. Inspect retained recovery artifacts before retrying.' + ) + } + const result = profileStateRecoveryResponseSchema.safeParse(parsed) + if (result.success) { + if (!result.data.ok) { + throw new RuntimeClientError(result.data.code, result.data.message) + } + if (response.code === 0 && !response.signal && !response.timedOut) { + return result.data.result + } + } + } + throw new RuntimeClientError( + 'runtime_error', + 'Orca recovery did not complete successfully. Inspect retained recovery artifacts before retrying.', + { + exitCode: response.code, + signal: response.signal, + timedOut: response.timedOut, + outputTruncated: response.outputTruncated ?? false, + stderr: response.stderr.trim().slice(-4096) + } + ) +} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index ee9db22dc1a..b817749f425 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -18,6 +18,7 @@ import { VM_COMMAND_SPECS } from './vm' import { SKILL_COMMAND_SPECS } from './skills' import { ARTIFACT_COMMAND_SPECS } from './artifacts' import { SEARCH_COMMAND_SPECS } from './search' +import { PROFILE_STATE_COMMAND_SPECS } from './profile-state' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, @@ -38,5 +39,6 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...VM_COMMAND_SPECS, ...EMULATOR_COMMAND_SPECS, ...SKILL_COMMAND_SPECS, - ...SEARCH_COMMAND_SPECS + ...SEARCH_COMMAND_SPECS, + ...PROFILE_STATE_COMMAND_SPECS ] diff --git a/src/cli/specs/profile-state.test.ts b/src/cli/specs/profile-state.test.ts new file mode 100644 index 00000000000..ef331390ecf --- /dev/null +++ b/src/cli/specs/profile-state.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { parseArgs, validateCommandAndFlags } from '../args' +import { PROFILE_STATE_COMMAND_SPECS } from './profile-state' + +describe('profile state rollback discovery', () => { + it.each([ + { argv: ['profile', 'state', 'rollback', '--current-json'] }, + { argv: ['--current-json', 'profile', 'state', 'rollback'] }, + { argv: ['profile', '--current-json', 'state', 'rollback'] } + ])('parses the current JSON selector as a boolean: $argv', ({ argv }) => { + const parsed = parseArgs(argv) + expect(parsed.commandPath).toEqual(['profile', 'state', 'rollback']) + expect(parsed.flags.get('current-json')).toBe(true) + expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow() + }) + + it('explains that adoption selects one full state and preserves both copies', () => { + const spec = PROFILE_STATE_COMMAND_SPECS.find((item) => item.path.at(-1) === 'rollback') + expect(spec?.usage).toContain('--current-json') + expect(spec?.notes?.join('\n')).toContain('without merging; both copies are archived') + expect(spec?.examples).toContain('orca profile state rollback --current-json') + }) +}) diff --git a/src/cli/specs/profile-state.ts b/src/cli/specs/profile-state.ts new file mode 100644 index 00000000000..030a5b57b22 --- /dev/null +++ b/src/cli/specs/profile-state.ts @@ -0,0 +1,30 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['profile', 'state', 'exports'], + summary: 'List retained SQLite backups and JSON exports for profile-state recovery', + usage: 'orca profile state exports [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['profile', 'state', 'rollback'], + destructive: true, + summary: 'Restore a SQLite backup, retained JSON export, or current JSON profile', + usage: + 'orca profile state rollback (--backup | --revision | --current-json) [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json'], + notes: [ + 'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.', + '--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.', + '--current-json keeps the current orca-data.json, including edits from an older build. It replaces SQLite state without merging; both copies are archived. The next SQLite-capable start imports the selected JSON.' + ], + examples: [ + 'orca profile state exports', + 'orca profile state rollback --backup ', + 'orca profile state rollback --revision 1', + 'orca profile state rollback --current-json' + ] + } +] diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts index eb6098f8c1d..41115306e95 100644 --- a/src/main/active-view-preference.ts +++ b/src/main/active-view-preference.ts @@ -43,6 +43,7 @@ export class ActiveViewPreference { /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ private quitFlushStarted = false private quitFlushPromise: Promise | null = null + private maintenancePaused = false constructor(dataFile: string, legacyActiveView: unknown) { this.file = getActiveViewPreferenceFile(dataFile) @@ -78,6 +79,9 @@ export class ActiveViewPreference { return } this.writeGeneration += 1 + if (this.maintenancePaused) { + return + } if (this.writeTimer) { clearTimeout(this.writeTimer) } @@ -246,4 +250,21 @@ export class ActiveViewPreference { await this.pendingWrite } } + + pauseForMaintenance(): () => void { + if (this.maintenancePaused || this.quitFlushStarted) { + throw new Error('Active-view persistence is already paused') + } + this.maintenancePaused = true + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + return () => { + this.maintenancePaused = false + if (this.activeView !== this.persistedActiveView) { + this.scheduleSave() + } + } + } } diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 70063128068..291d294d9ba 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -151,12 +151,14 @@ describe('preserveAgentAuthBeforeRestart', () => { ) }) - it('flushes the store when auth services are missing', async () => { + it('checkpoints admitted state without waiting for ongoing edits when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ store: { flushPendingOrThrowAsync } }) - expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledExactlyOnceWith({ + drainToStableGeneration: false + }) }) it('logs secret-free warnings and does not throw when sync fails', async () => { diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index a3e24ef8abe..d00641088cc 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -44,7 +44,7 @@ export async function preserveAgentAuthBeforeRestart({ const storePreservation = store ? runWithinLifecycleTimeout( 'Store persistence', - () => store.flushPendingOrThrowAsync(), + () => store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), remainingLifecycleTime(startedAt) ) : Promise.resolve() diff --git a/src/main/automations/automation-dispatch-request.ts b/src/main/automations/automation-dispatch-request.ts new file mode 100644 index 00000000000..348a1b9ecee --- /dev/null +++ b/src/main/automations/automation-dispatch-request.ts @@ -0,0 +1,154 @@ +import type { WebContents } from 'electron' +import { isDeepStrictEqual } from 'node:util' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { Store } from '../persistence' +import type { AutomationRunWriter } from './automation-run-writer' +import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import type { HeadlessAutomationDispatchContext } from './headless-dispatch-runner' +import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' +import type { AutomationRunTargetResult } from './run-target-resolution' +import { createAutomationDispatchToken } from './dispatch-tokens' +import { NO_DISPATCH_HOST, sendRendererDispatch } from './dispatch-refusal' + +export type AutomationRendererChannel = Pick + +export class AutomationDispatchCancelledError extends Error {} + +type DispatchContext = Pick< + HeadlessAutomationDispatchContext, + 'runPrecheck' | 'markDispatchResult' | 'watchRun' +> & { + store: Store + runs: AutomationRunWriter + isActive(): boolean + getRenderer(): AutomationRendererChannel | null + headlessDispatcher: HeadlessAutomationDispatcher | null + resolveTarget(automation: Automation): AutomationRunTargetResult +} + +function definition(automation: Automation) { + const { lastRunAt: _last, updatedAt: _updated, nextRunAt: _next, ...configured } = automation + return configured +} + +function destination(target: Extract) { + return { + cwd: target.cwd, + repoId: target.repo.id, + repoPath: target.repo.path, + host: getRepoExecutionHostId(target.repo), + setupId: target.setup?.id + } +} + +/** Claim durably, then recheck everything that an acknowledgement wait can invalidate. */ +export async function requestAutomationDispatch( + ctx: DispatchContext, + automation: Automation, + run: AutomationRun, + expectedTarget: AutomationRunTargetResult +): Promise { + const expectedDefinition = structuredClone(definition(automation)) + const expectedDestination = expectedTarget.ok ? destination(expectedTarget) : undefined + const readRun = (): AutomationRun => { + if (!ctx.isActive()) { + throw new AutomationDispatchCancelledError( + 'Orca stopped before this automation could launch.' + ) + } + const current = ctx.store.listAutomationRuns(automation.id).find((entry) => entry.id === run.id) + if (!current || !ctx.store.listAutomations().some((entry) => entry.id === automation.id)) { + throw new AutomationDispatchCancelledError( + 'The automation was removed before it could launch.' + ) + } + return current + } + const resolveCurrentTarget = (): AutomationRunTargetResult => { + const current = ctx.store.listAutomations().find((entry) => entry.id === automation.id) + if (!current || !isDeepStrictEqual(expectedDefinition, definition(current))) { + return { ok: false, error: 'The automation changed before this run could launch.' } + } + const target = ctx.resolveTarget(current) + if ( + target.ok && + expectedDestination && + !isDeepStrictEqual(expectedDestination, destination(target)) + ) { + return { + ok: false, + error: 'The automation destination changed before this run could launch.' + } + } + return target + } + const refuse = (error: string) => + ctx.runs.updateRun({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + error + }) + const returnDurable = async (current: AutomationRun) => { + await ctx.store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return current + } + + run = readRun() + if (run.status !== 'pending') { + return returnDurable(run) + } + let target = resolveCurrentTarget() + if (!target.ok || (!ctx.getRenderer() && !ctx.headlessDispatcher)) { + return refuse(target.ok ? NO_DISPATCH_HOST : target.error) + } + await ctx.runs.updateRun({ + runId: run.id, + status: 'dispatching', + workspaceId: automation.workspaceId, + error: null + }) + + run = readRun() + if (run.status !== 'dispatching') { + return returnDurable(run) + } + target = resolveCurrentTarget() + if (!target.ok) { + return refuse(target.error) + } + const renderer = ctx.getRenderer() + if (renderer) { + return sendRendererDispatch( + renderer, + { + automation, + run, + dispatchToken: createAutomationDispatchToken(automation.id, run.id) + }, + ctx.runs, + run + ) + } + const dispatcher = ctx.headlessDispatcher + if (!dispatcher) { + return refuse(NO_DISPATCH_HOST) + } + return runHeadlessAutomationDispatch({ + ...ctx, + automation, + run, + target, + dispatcher: (request) => { + if (readRun().status !== 'dispatching') { + throw new AutomationDispatchCancelledError('The run changed before its agent could launch.') + } + const latestTarget = resolveCurrentTarget() + if (!latestTarget.ok) { + throw new AutomationDispatchCancelledError(latestTarget.error) + } + return dispatcher({ ...request, target: latestTarget }) + } + }) +} diff --git a/src/main/automations/automation-run-writer.test.ts b/src/main/automations/automation-run-writer.test.ts index a149f28ea38..f6130fbf223 100644 --- a/src/main/automations/automation-run-writer.test.ts +++ b/src/main/automations/automation-run-writer.test.ts @@ -5,20 +5,27 @@ */ import { describe, expect, it, vi } from 'vitest' import { createAutomationRunWriter } from './automation-run-writer' -import { AutomationService } from './service' +import { collectAutomationRunUsage } from './run-usage-collection' +import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture' import type { Store } from '../persistence' -import type { Automation, AutomationRun } from '../../shared/automations-types' const SSH_SELECTOR = { kind: 'ssh', targetId: 'ssh-1' } as const +const data = buildProfileStateCutoverFixture('/fixture') +const automation = { ...data.automations[0], id: 'auto-1' } +const run = { ...data.automationRuns[0], id: 'run-1', automationId: automation.id } + function writerWith(selector: ReturnType) { const publish = vi.fn() const automationChangeSelector = vi.fn(() => selector) const store = { - createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), - updateAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), + flushPendingOrThrowAsync: vi.fn().mockResolvedValue(undefined), + createAutomationRun: vi.fn(() => run), + updateAutomationRun: vi.fn(() => run), + recordRepeatedAutomationSkip: vi.fn(() => null), + advanceAutomationNextRun: vi.fn(() => automation), automationChangeSelector - } as unknown as Store + } return { publish, automationChangeSelector, @@ -28,61 +35,68 @@ function writerWith(selector: ReturnType) { } describe('automation run writer publications', () => { - it('names the host a created run belongs to', () => { + it('waits for durable acknowledgement before publishing or returning a run', async () => { + const { store, writer, publish } = writerWith(SSH_SELECTOR) + const acknowledgement = Promise.withResolvers() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(acknowledgement.promise) + const completed = vi.fn() + const pending = writer.updateRun({ runId: 'run-1', status: 'dispatching' }).then(completed) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + expect(completed).not.toHaveBeenCalled() + acknowledgement.resolve() + await pending + expect(publish).toHaveBeenCalledOnce() + expect(completed).toHaveBeenCalledOnce() + }) + + it('rejects a failed write without publishing success', async () => { + const { store, writer, publish } = writerWith(SSH_SELECTOR) + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(new Error('disk full')) + await expect(writer.updateRun({ runId: 'run-1', status: 'completed' })).rejects.toThrow( + 'disk full' + ) + expect(publish).not.toHaveBeenCalled() + }) + + it('names the host a created run belongs to', async () => { const { writer, publish } = writerWith(SSH_SELECTOR) - writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + await writer.createRun(automation, 0, 'scheduled') expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) }) - it('resolves the host from the written run, which is all a dispatch result names', () => { + it('resolves the host from the written run, which is all a dispatch result names', async () => { const { writer, publish, automationChangeSelector } = writerWith(SSH_SELECTOR) - writer.updateRun({ runId: 'run-1', status: 'completed', usage: null }) + await writer.updateRun({ runId: 'run-1', status: 'completed', usage: null }) expect(automationChangeSelector).toHaveBeenCalledWith('auto-1') expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) }) - it('keeps the usage reason on a usage-bearing write', () => { + it('keeps the usage reason on a usage-bearing write', async () => { const { writer, publish } = writerWith({ kind: 'self' }) - writer.updateRun({ + await writer.updateRun({ runId: 'run-1', status: 'completed', - usage: { status: 'known' } as AutomationRun['usage'] + usage: await collectAutomationRunUsage({ + automation, + run, + claudeUsage: null, + codexUsage: null + }) }) expect(publish).toHaveBeenCalledWith({ reason: 'usage', selector: { kind: 'self' } }) }) // Over-broad beats silent: a subscriber must still hear that something changed. - it('falls back to the whole authority when the record can no longer be named', () => { + it('falls back to the whole authority when the record can no longer be named', async () => { const { writer, publish } = writerWith(null) - writer.createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + await writer.createRun(automation, 0, 'scheduled') expect(publish).toHaveBeenCalledWith({ reason: 'run' }) }) - it('does not project a selector nobody will hear', () => { - const automationChangeSelector = vi.fn(() => SSH_SELECTOR) - const store = { - createAutomationRun: vi.fn(() => ({ id: 'run-1', automationId: 'auto-1' }) as AutomationRun), - automationChangeSelector - } as unknown as Store - createAutomationRunWriter(store, null).createRun({ id: 'auto-1' } as Automation, 0, 'scheduled') + it('does not project a selector nobody will hear', async () => { + const { store, automationChangeSelector } = writerWith(SSH_SELECTOR) + await createAutomationRunWriter(store, null).createRun(automation, 0, 'scheduled') expect(automationChangeSelector).not.toHaveBeenCalled() }) - - // Why the renderer dispatch path no longer emits its own: the scoped event is - // published during the write, so it is queued before the reply the caller awaits. - it('publishes before markDispatchResult settles', async () => { - const publish = vi.fn() - const store = { - updateAutomationRun: vi.fn( - () => ({ id: 'run-1', automationId: 'auto-1', status: 'dispatched' }) as AutomationRun - ), - automationChangeSelector: vi.fn(() => SSH_SELECTOR) - } as unknown as Store - const service = new AutomationService(store, { onAutomationsChanged: publish }) - - const settled = service.markDispatchResult({ runId: 'run-1', status: 'dispatched' }) - - expect(publish).toHaveBeenCalledWith({ reason: 'run', selector: SSH_SELECTOR }) - await settled - }) }) diff --git a/src/main/automations/automation-run-writer.ts b/src/main/automations/automation-run-writer.ts index ef08c84de49..1f813a62d0c 100644 --- a/src/main/automations/automation-run-writer.ts +++ b/src/main/automations/automation-run-writer.ts @@ -2,18 +2,31 @@ import type { Store } from '../persistence' import type { PublishAutomationsChanged } from '../../shared/runtime-client-events' import type { AutomationDispatchResult, AutomationRun } from '../../shared/automations-types' +type DurableWrite unknown> = ( + ...args: Parameters +) => Promise> + export type AutomationRunWriter = { - createRun: Store['createAutomationRun'] - updateRun: Store['updateAutomationRun'] + createRun: DurableWrite + updateRun: DurableWrite /** Null when nothing could be folded — the caller then writes an ordinary run. */ - repeatSkip: Store['recordRepeatedAutomationSkip'] + repeatSkip: DurableWrite + advanceNextRun: DurableWrite } /** Wraps run persistence so every committed write announces itself. Clients with * the Automations page closed — or none attached at all — have no other way to * learn that a run progressed, so the event must follow the write, not a render. */ export function createAutomationRunWriter( - store: Store, + store: Pick< + Store, + | 'createAutomationRun' + | 'updateAutomationRun' + | 'recordRepeatedAutomationSkip' + | 'advanceAutomationNextRun' + | 'automationChangeSelector' + | 'flushPendingOrThrowAsync' + >, publish: PublishAutomationsChanged | null ): AutomationRunWriter { // A run write never moves the record, so its own host is the whole publication. @@ -26,19 +39,27 @@ export function createAutomationRunWriter( publish({ reason, ...(selector ? { selector } : {}) }) } return { - createRun: (automation, scheduledFor, trigger): AutomationRun => { + advanceNextRun: async (id, now) => { + const automation = store.advanceAutomationNextRun(id, now) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return automation + }, + createRun: async (automation, scheduledFor, trigger): Promise => { const run = store.createAutomationRun(automation, scheduledFor, trigger) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) announce(automation.id, 'run') return run }, - updateRun: (result: AutomationDispatchResult): AutomationRun => { + updateRun: async (result: AutomationDispatchResult): Promise => { const run = store.updateAutomationRun(result) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) announce(run.automationId, result.usage ? 'usage' : 'run') return run }, - repeatSkip: (automationId, error, scheduledFor): AutomationRun | null => { + repeatSkip: async (automationId, error, scheduledFor): Promise => { const run = store.recordRepeatedAutomationSkip(automationId, error, scheduledFor) if (run) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) announce(automationId, 'run') } return run diff --git a/src/main/automations/automation-worker-durability.test.ts b/src/main/automations/automation-worker-durability.test.ts new file mode 100644 index 00000000000..22d13899e3b --- /dev/null +++ b/src/main/automations/automation-worker-durability.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest' +import { AutomationService } from './service' +import type { Store } from '../persistence' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function fixture() { + const fixture = await createWorkerMaintenanceFixture() + for (const automation of fixture.store.listAutomations()) { + fixture.store.updateAutomation(automation.id, { enabled: false }) + } + const automation = fixture.store.createAutomation({ + name: 'Durable run', + prompt: 'Check the project', + agentId: 'claude', + projectId: 'repo-local', + workspaceMode: 'existing', + workspaceId: 'repo-local::/fixture/local', + timezone: 'UTC', + rrule: 'FREQ=HOURLY;BYMINUTE=0', + dtstart: Date.now() - 60_000 + }) + await fixture.store.flushPendingOrThrowAsync() + return { ...fixture, automation } +} + +const launch = { workspaceId: 'repo-local::/fixture/local', terminalSessionId: 'run-tab' } + +function blockAcknowledgement(store: Store, index: number) { + const gate = maintenanceBarrier() + const blocked = maintenanceBarrier() + const flush = store.flushPendingOrThrowAsync.bind(store) + let calls = 0 + vi.spyOn(store, 'flushPendingOrThrowAsync').mockImplementation(async (options) => { + calls += 1 + if (calls === index) { + blocked.resolve() + await gate.promise + } + await flush(options) + }) + return { blocked: blocked.promise, release: gate.resolve } +} + +describe('automation background writer durability', () => { + it('claims a shared occurrence once when callers await the same pending row', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) + const { store, automation } = await fixture() + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const runs = await Promise.all([service.runNow(automation.id), service.runNow(automation.id)]) + expect(new Set(runs.map((run) => run.id)).size).toBe(1) + expect(dispatcher).toHaveBeenCalledOnce() + } finally { + service.stop() + } + }) + + it('persists dispatch intent before starting external work', async () => { + const { store, automation, readState } = await fixture() + const gate = maintenanceBarrier() + const flush = store.flushPendingOrThrowAsync.bind(store) + vi.spyOn(store, 'flushPendingOrThrowAsync').mockImplementationOnce(async (options) => { + await gate.promise + await flush(options) + }) + const dispatcher = vi.fn(async () => { + expect(readState().automationRuns).toContainEqual( + expect.objectContaining({ automationId: automation.id, status: 'dispatching' }) + ) + return launch + }) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const run = service.runNow(automation.id) + await Promise.resolve() + expect(dispatcher).not.toHaveBeenCalled() + gate.resolve() + await expect(run).resolves.toMatchObject({ status: 'dispatched' }) + expect(dispatcher).toHaveBeenCalledOnce() + } finally { + service.stop() + } + }) + + it('never dispatches when the writer refuses the durable acknowledgement', async () => { + const { store, automation } = await fixture() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValueOnce(new Error('disk full')) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + await expect(service.runNow(automation.id)).rejects.toThrow('disk full') + expect(dispatcher).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it.each([ + ['dispatching', 1], + ['dispatched', 1], + ['completed', 1], + ['dispatching', 120_001], + ['dispatched', 120_001], + ['completed', 120_001] + ] as const)( + 'preserves a durable %s occurrence after %s ms when next-run advancement was interrupted', + async (status, lateness) => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) + const { store, automation, readState } = await fixture() + store.updateAutomation(automation.id, { missedRunGraceMinutes: 0 }) + const dueAt = automation.nextRunAt + const run = store.createAutomationRun(automation, dueAt) + store.updateAutomationRun({ runId: run.id, status }) + await store.flushPendingOrThrowAsync() + clock.mockReturnValue(dueAt + lateness) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + service.start() + await vi.waitFor(() => { + const stored = readState().automations.find( + (entry: { id: string }) => entry.id === automation.id + ) + expect(stored.nextRunAt).toBeGreaterThan(dueAt + lateness) + }) + expect(dispatcher).not.toHaveBeenCalled() + expect(store.listAutomationRuns(automation.id)).toHaveLength(1) + expect(store.listAutomationRuns(automation.id)[0].status).toBe(status) + } finally { + service.stop() + } + } + ) + + it.each([1, 2])('cancels a pending dispatch stopped during acknowledgement %s', async (index) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, index) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + const pending = service.runNow(automation.id) + const rejected = expect(pending).rejects.toThrow('stopped before') + await acknowledgement.blocked + service.stop() + acknowledgement.release() + await rejected + expect(dispatcher).not.toHaveBeenCalled() + }) + + it('does not resurrect or dispatch an automation deleted during its intent acknowledgement', async () => { + const { store, automation, readState } = await fixture() + const acknowledgement = blockAcknowledgement(store, 2) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const pending = service.runNow(automation.id) + const rejected = expect(pending).rejects.toThrow('removed before') + await acknowledgement.blocked + store.deleteAutomation(automation.id) + acknowledgement.release() + await rejected + expect(dispatcher).not.toHaveBeenCalled() + expect( + readState().automationRuns.some( + (run: { automationId: string }) => run.automationId === automation.id + ) + ).toBe(false) + } finally { + service.stop() + } + }) + + it.each([1, 2])('refuses changed instructions during acknowledgement %s', async (index) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, index) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const pending = service.runNow(automation.id) + await acknowledgement.blocked + store.updateAutomation(automation.id, { prompt: 'Different instructions' }) + acknowledgement.release() + await expect(pending).resolves.toMatchObject({ + status: 'skipped_unavailable', + error: expect.stringContaining('changed before') + }) + expect(dispatcher).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it.each([1, 2])('refuses a moved execution host during acknowledgement %s', async (index) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, index) + const dispatcher = vi.fn(async () => launch) + const service = new AutomationService(store, { headlessDispatcher: dispatcher }) + try { + const pending = service.runNow(automation.id) + await acknowledgement.blocked + store.updateRepo('repo-local', { executionHostId: 'runtime:other-host' }) + acknowledgement.release() + await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' }) + expect(dispatcher).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it.each(['disconnect', 'replacement'] as const)( + 'refuses an unready renderer after %s during acknowledgement', + async (change) => { + const { store, automation } = await fixture() + const acknowledgement = blockAcknowledgement(store, 2) + const renderer = { isDestroyed: () => false, send: vi.fn() } + const replacement = { isDestroyed: () => false, send: vi.fn() } + const service = new AutomationService(store) + service.setWebContents(renderer) + service.setRendererReady() + try { + const pending = service.runNow(automation.id) + await acknowledgement.blocked + service.setWebContents(change === 'disconnect' ? null : replacement) + acknowledgement.release() + await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' }) + expect(renderer.send).not.toHaveBeenCalled() + expect(replacement.send).not.toHaveBeenCalled() + } finally { + service.stop() + } + } + ) +}) diff --git a/src/main/automations/automation-zero-grace-tick-latency.test.ts b/src/main/automations/automation-zero-grace-tick-latency.test.ts index 4639a872887..d6d7cadd118 100644 --- a/src/main/automations/automation-zero-grace-tick-latency.test.ts +++ b/src/main/automations/automation-zero-grace-tick-latency.test.ts @@ -76,7 +76,11 @@ describe('AutomationService zero-grace tick latency', () => { service.setWebContents({ isDestroyed: () => false, send: vi.fn() }) service.start() service.setRendererReady() - await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => { + if (store.listAutomations().some((automation) => automation.nextRunAt <= at)) { + throw new Error('Automation evaluation is still saving its next occurrence') + } + }) service.stop() } diff --git a/src/main/automations/dispatch-refusal.test.ts b/src/main/automations/dispatch-refusal.test.ts index 233deb3e782..27179f1cf56 100644 --- a/src/main/automations/dispatch-refusal.test.ts +++ b/src/main/automations/dispatch-refusal.test.ts @@ -59,13 +59,14 @@ function makeRunWriter(foldsRepeat: boolean): { const created: string[] = [] const updated: { status: string; error?: string | null }[] = [] const writer: AutomationRunWriter = { - repeatSkip: () => (foldsRepeat ? makeRun('folded') : null), - createRun: () => { + advanceNextRun: async () => brokenAutomation, + repeatSkip: async () => (foldsRepeat ? makeRun('folded') : null), + createRun: async () => { const run = makeRun(`run-${created.length + 1}`) created.push(run.id) return run }, - updateRun: (args) => { + updateRun: async (args) => { updated.push({ status: args.status, error: args.error }) return { ...makeRun(args.runId), status: args.status, error: args.error ?? null } } @@ -78,11 +79,11 @@ describe('recordUnevaluableAutomation', () => { vi.restoreAllMocks() }) - it('writes one run and logs once when the record is newly broken', () => { + it('writes one run and logs once when the record is newly broken', async () => { const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) const { writer, created, updated } = makeRunWriter(false) - recordUnevaluableAutomation({ + await recordUnevaluableAutomation({ runs: writer, automation: brokenAutomation, error: new Error('Invalid cron day of month.') @@ -95,12 +96,12 @@ describe('recordUnevaluableAutomation', () => { // The record is retried every tick on purpose, so a repaired schedule resumes on its own. // The fold is what keeps that from writing a row, and logging, once per tick forever. - it('stays silent on a record it has already reported', () => { + it('stays silent on a record it has already reported', async () => { const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) const { writer, created } = makeRunWriter(true) for (let tick = 0; tick < 5; tick += 1) { - recordUnevaluableAutomation({ + await recordUnevaluableAutomation({ runs: writer, automation: brokenAutomation, error: new Error('Invalid cron day of month.') diff --git a/src/main/automations/dispatch-refusal.ts b/src/main/automations/dispatch-refusal.ts index 4a5fffa1b16..286abb2f252 100644 --- a/src/main/automations/dispatch-refusal.ts +++ b/src/main/automations/dispatch-refusal.ts @@ -43,17 +43,17 @@ export function describeScheduledRefusal(input: { * and doc:94 asks for both. Never dispatches: the reason is the one the * scheduler would have written for the same record. */ -export function recordRefusedAutomationRun(input: { +export async function recordRefusedAutomationRun(input: { store: Store runs: AutomationRunWriter automation: Automation allowRemoteHostScheduling: boolean -}): void { +}): Promise { const target = resolveAutomationRunTarget(input.store, input.automation, { allowRemoteHostScheduling: input.allowRemoteHostScheduling }) - const run = input.runs.createRun(input.automation, Date.now(), 'manual') - input.runs.updateRun({ + const run = await input.runs.createRun(input.automation, Date.now(), 'manual') + await input.runs.updateRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: input.automation.workspaceId, @@ -66,21 +66,24 @@ export function recordRefusedAutomationRun(input: { * stalled. Folds on the fixed sentence and the unchanged nextRunAt, so a record that stays * broken writes one row rather than one per tick, and never throws back into the tick. */ -export function recordUnevaluableAutomation(input: { +export async function recordUnevaluableAutomation(input: { runs: AutomationRunWriter automation: Automation error: unknown -}): void { +}): Promise { const { automation } = input try { // nextRunAt deliberately stays put: the record is retried so a repaired schedule resumes // on its own. The fold is what keeps that from writing a row — and logging — every tick. - if (input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) { + if (await input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) { return } console.error('[automations] failed to evaluate automation:', automation.id, input.error) - const run = input.runs.createRun(automation, automation.nextRunAt) - input.runs.updateRun({ + const run = await input.runs.createRun(automation, automation.nextRunAt) + if (run.status !== 'pending') { + return + } + await input.runs.updateRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, @@ -101,12 +104,12 @@ export function recordUnevaluableAutomation(input: { * Sends the dispatch request through the renderer channel, closing the run out as * `dispatch_failed` when the send throws — a failed send is not an unreadable schedule. */ -export function sendRendererDispatch( +export async function sendRendererDispatch( channel: Pick | null, payload: AutomationDispatchRequest, runs: AutomationRunWriter, run: AutomationRun -): AutomationRun { +): Promise { try { channel?.send('automations:dispatchRequested', payload) return run @@ -153,13 +156,16 @@ export function missedBeyondGrace(input: { return input.now - input.scheduledFor > graceMs + jitterMs } -export function recordMissedRun(input: { +export async function recordMissedRun(input: { runs: AutomationRunWriter automation: Automation scheduledFor: number -}): void { - const missed = input.runs.createRun(input.automation, input.scheduledFor) - input.runs.updateRun({ +}): Promise { + const missed = await input.runs.createRun(input.automation, input.scheduledFor) + if (missed.status !== 'pending') { + return + } + await input.runs.updateRun({ runId: missed.id, status: 'skipped_missed', workspaceId: input.automation.workspaceId, diff --git a/src/main/automations/headless-dispatch-durability.test.ts b/src/main/automations/headless-dispatch-durability.test.ts new file mode 100644 index 00000000000..ea5403801e0 --- /dev/null +++ b/src/main/automations/headless-dispatch-durability.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../persistence/profile-state-cutover-fixture' +import type { AutomationRun } from '../../shared/automations-types' +import type { HeadlessAutomationDispatchLaunch } from './headless-dispatch' +import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' + +function fixture(launch: HeadlessAutomationDispatchLaunch) { + const state = buildProfileStateCutoverFixture('/fixture') + const automation = { ...state.automations[0], precheck: null } + const run: AutomationRun = { + ...state.automationRuns[0], + automationId: automation.id, + status: 'dispatching' + } + const dispatched: AutomationRun = { ...run, ...launch, status: 'dispatched' } + return { + automation, + run, + target: { ok: true as const, cwd: state.repos[0].path, repo: state.repos[0] }, + dispatcher: vi.fn(async () => launch), + runs: { + createRun: vi.fn(async () => run), + updateRun: vi.fn(async () => dispatched), + repeatSkip: vi.fn(async () => null), + advanceNextRun: vi.fn(async () => automation) + }, + runPrecheck: vi.fn(async () => null), + markDispatchResult: vi.fn(async () => dispatched), + watchRun: vi.fn() + } +} + +const terminal = { + workspaceId: 'launched-workspace', + terminalSessionId: 'launched-tab', + terminalPaneKey: 'launched-pane', + terminalPtyId: 'launched-pty' +} + +describe('headless automation observation during persistence', () => { + it('handles an early completion rejection while the dispatched write is stalled', async () => { + const completion = Promise.withResolvers() + const acknowledgement = Promise.withResolvers() + const context = fixture({ ...terminal, completion: completion.promise }) + context.runs.updateRun.mockReturnValueOnce(acknowledgement.promise) + const pending = runHeadlessAutomationDispatch(context) + await vi.waitFor(() => expect(context.runs.updateRun).toHaveBeenCalledOnce()) + completion.reject(new Error('agent exited early')) + await vi.waitFor(() => + expect(context.markDispatchResult).toHaveBeenCalledWith({ + runId: context.run.id, + status: 'dispatch_failed', + ...terminal, + workspaceDisplayName: null, + error: 'agent exited early' + }) + ) + acknowledgement.resolve({ ...context.run, ...terminal, status: 'dispatched' }) + await pending + }) + + it('starts terminal observation even when the dispatched write fails after launch', async () => { + const context = fixture(terminal) + const failure = new Error('disk full') + context.runs.updateRun.mockRejectedValueOnce(failure) + await expect(runHeadlessAutomationDispatch(context)).rejects.toBe(failure) + expect(context.watchRun).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + ...terminal, + status: 'dispatched' + }) + ) + expect(context.runs.updateRun).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + ...terminal, + status: 'dispatched' + }) + ) + }) + + it('keeps receiving completion after the launched run fails to persist', async () => { + const completion = Promise.withResolvers<{ status: 'completed' }>() + const context = fixture({ ...terminal, completion: completion.promise }) + context.runs.updateRun.mockRejectedValueOnce(new Error('writer unavailable')) + await expect(runHeadlessAutomationDispatch(context)).rejects.toThrow('writer unavailable') + completion.resolve({ status: 'completed' }) + await vi.waitFor(() => + expect(context.markDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ ...terminal, status: 'completed' }) + ) + ) + expect(context.runs.updateRun).toHaveBeenCalledOnce() + }) + + it('records an actual launch rejection as dispatch failure', async () => { + const context = fixture(terminal) + context.dispatcher.mockRejectedValueOnce(new Error('shell unavailable')) + await runHeadlessAutomationDispatch(context) + expect(context.runs.updateRun).toHaveBeenCalledExactlyOnceWith({ + runId: context.run.id, + status: 'dispatch_failed', + workspaceId: context.automation.workspaceId, + error: 'shell unavailable' + }) + expect(context.watchRun).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/automations/headless-dispatch-runner.ts b/src/main/automations/headless-dispatch-runner.ts index 8d4e3aed290..5a0ed60582c 100644 --- a/src/main/automations/headless-dispatch-runner.ts +++ b/src/main/automations/headless-dispatch-runner.ts @@ -8,7 +8,10 @@ import { didAutomationPrecheckPass, formatAutomationPrecheckFailure } from '../../shared/automation-precheck' -import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import type { + HeadlessAutomationDispatcher, + HeadlessAutomationDispatchLaunch +} from './headless-dispatch' import type { AutomationRunTargetResult } from './run-target-resolution' import type { AutomationRunWriter } from './automation-run-writer' @@ -42,47 +45,9 @@ export async function runHeadlessAutomationDispatch( error: formatAutomationPrecheckFailure(precheckResult) }) } + let launch: HeadlessAutomationDispatchLaunch try { - const launch = await ctx.dispatcher({ automation, run, target }) - const launchRunTarget = { - workspaceId: launch.workspaceId, - workspaceDisplayName: launch.workspaceDisplayName ?? null, - terminalSessionId: launch.terminalSessionId, - terminalPaneKey: launch.terminalPaneKey ?? null, - terminalPtyId: launch.terminalPtyId ?? null - } - const updated = runs.updateRun({ - runId: run.id, - status: 'dispatched', - ...launchRunTarget, - error: null - }) - if (!launch.completion) { - // Why: a dispatcher that reports no completion promise would otherwise - // leave the run at 'dispatched' for the process lifetime. - ctx.watchRun(updated) - return updated - } - void launch.completion - .then((completion) => - ctx.markDispatchResult({ - runId: run.id, - status: completion.status, - ...launchRunTarget, - precheckResult, - outputSnapshot: completion.outputSnapshot ?? null, - error: completion.error ?? null - }) - ) - .catch((error) => - ctx.markDispatchResult({ - runId: run.id, - status: 'dispatch_failed', - ...launchRunTarget, - error: describeDispatchError(error) - }) - ) - return updated + launch = await ctx.dispatcher({ automation, run, target }) } catch (error) { return runs.updateRun({ runId: run.id, @@ -91,4 +56,43 @@ export async function runHeadlessAutomationDispatch( error: describeDispatchError(error) }) } + const launchRunTarget = { + workspaceId: launch.workspaceId, + workspaceDisplayName: launch.workspaceDisplayName ?? null, + terminalSessionId: launch.terminalSessionId, + terminalPaneKey: launch.terminalPaneKey ?? null, + terminalPtyId: launch.terminalPtyId ?? null + } + const updated = runs.updateRun({ + runId: run.id, + status: 'dispatched', + ...launchRunTarget, + error: null + }) + // Observe the launched agent even while persistence is stalled or rejects its acknowledgement. + if (!launch.completion) { + ctx.watchRun({ ...run, ...launchRunTarget, status: 'dispatched', error: null }) + } else { + void launch.completion + .then( + (completion) => + ctx.markDispatchResult({ + runId: run.id, + status: completion.status, + ...launchRunTarget, + precheckResult, + outputSnapshot: completion.outputSnapshot ?? null, + error: completion.error ?? null + }), + (error) => + ctx.markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + ...launchRunTarget, + error: describeDispatchError(error) + }) + ) + .catch((error) => console.error('[automations] Failed to persist run completion:', error)) + } + return updated } diff --git a/src/main/automations/refused-manual-run.ts b/src/main/automations/refused-manual-run.ts index f6a103408c2..b3b60786654 100644 --- a/src/main/automations/refused-manual-run.ts +++ b/src/main/automations/refused-manual-run.ts @@ -19,7 +19,7 @@ import { type RefusableAutomationService = { runNow: (automationId: string) => Promise - recordRefusedRun: (automationId: string) => void + recordRefusedRun: (automationId: string) => void | Promise } export async function runAutomationNowFenced(input: { @@ -34,7 +34,7 @@ export async function runAutomationNowFenced(input: { error instanceof AutomationOwnerConflictError && error.code === AUTOMATION_OWNER_CONFLICT_CODES.targetRemoved ) { - input.service.recordRefusedRun(input.automationId) + await input.service.recordRefusedRun(input.automationId) } throw error } diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 31c1aea0bb4..954c3ee13bb 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -168,6 +168,9 @@ describe('authority-owned automation run completion', () => { it('reconciles stranded runs on startup without claiming completion', async () => { const store = await createStore() const automation = createAutomation(store) + store.updateAutomation(automation.id, { enabled: false }) + const pendingManual = store.createAutomationRun(automation, 3_000, 'manual') + const pendingScheduled = store.createAutomationRun(automation, 4_000, 'scheduled') const dispatched = store.createAutomationRun(automation, 1_000, 'manual') store.updateAutomationRun({ runId: dispatched.id, @@ -199,6 +202,11 @@ describe('authority-owned automation run completion', () => { expect(readRun(store, automation.id, dispatching.id).status).toBe('dispatch_failed') expect(readRun(store, automation.id, dispatched.id).error).toContain('terminal') expect(readRun(store, automation.id, dispatching.id).error).toContain('agent started') + expect(readRun(store, automation.id, pendingManual.id)).toMatchObject({ + status: 'dispatch_failed', + error: 'Orca stopped before this manual run could launch.' + }) + expect(readRun(store, automation.id, pendingScheduled.id).status).toBe('pending') service.stop() vi.useRealTimers() }) @@ -341,7 +349,7 @@ describe('automationsChanged publication', () => { }) const run = await service.runNow(automation.id) - expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run']) + expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run']) expect(seen.at(-1)?.status).toBe('dispatched') await service.markDispatchResult({ @@ -350,7 +358,7 @@ describe('automationsChanged publication', () => { ...LAUNCH_TARGET, error: null }) - expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'usage']) + expect(seen.map((entry) => entry.payload.reason)).toEqual(['run', 'run', 'run', 'run', 'usage']) expect(seen.at(-1)?.status).toBe('completed') // Every run/usage write names its own host, so one automation's run cannot // invalidate the rest of the authority. diff --git a/src/main/automations/run-completion-watcher.ts b/src/main/automations/run-completion-watcher.ts index 4ac125082fb..ab53efaee58 100644 --- a/src/main/automations/run-completion-watcher.ts +++ b/src/main/automations/run-completion-watcher.ts @@ -24,6 +24,9 @@ export type AutomationRunTerminalObserver = { /** Truthful reason for a run this authority can no longer observe; never claims completion. */ export function describeStrandedAutomationRun(run: AutomationRun): string { + if (run.status === 'pending') { + return 'Orca stopped before this manual run could launch.' + } if (run.status === 'dispatching') { return 'Orca stopped before this run reported that its agent started.' } @@ -139,7 +142,12 @@ export class AutomationRunCompletionWatcher { * reported ready and still cannot find it. */ reconcileRetainedRuns(runs: readonly AutomationRun[]): void { this.reconciler.reconcile( - runs.filter((run) => run.status === 'dispatched' || run.status === 'dispatching') + runs.filter( + (run) => + run.status === 'dispatched' || + run.status === 'dispatching' || + (run.status === 'pending' && run.trigger === 'manual') + ) ) } diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 227a290783b..1ff8eafbd6b 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -1,13 +1,13 @@ -import type { WebContents } from 'electron' - -/** All the service asks of the renderer: is it still there, and take this message. Narrower - * than WebContents so a test can supply the real shape instead of casting one. */ -export type AutomationRendererChannel = Pick +import { + AutomationDispatchCancelledError, + requestAutomationDispatch, + type AutomationRendererChannel +} from './automation-dispatch-request' +export type { AutomationRendererChannel } from './automation-dispatch-request' import type { Store } from '../persistence' import { isFinalAutomationRunStatus, type Automation, - type AutomationDispatchRequest, type AutomationDispatchResult, type AutomationPrecheckResult, type AutomationRun @@ -18,8 +18,7 @@ import { runAutomationPrecheck } from './precheck-runner' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' import { writeAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' -import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' -import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' +import { clearAutomationDispatchTokens } from './dispatch-tokens' import { AutomationRunCompletionWatcher, type AutomationRunTerminalObserver @@ -31,9 +30,7 @@ import { missedBeyondGrace, recordMissedRun, recordRefusedAutomationRun, - recordUnevaluableAutomation, - sendRendererDispatch, - NO_DISPATCH_HOST + recordUnevaluableAutomation } from './dispatch-refusal' import type { AutomationsChangedPayload, @@ -49,6 +46,8 @@ export class AutomationService { private webContents: AutomationRendererChannel | null = null private rendererReady = false private evaluating = false + private stopped = false + private dispatchGeneration = 0 private readonly claudeUsage: ClaudeUsageStore | null private readonly codexUsage: CodexUsageStore | null private readonly allowRemoteHostScheduling: boolean @@ -114,6 +113,7 @@ export class AutomationService { if (this.timer) { return } + this.stopped = false this.timer = setInterval(() => { void this.evaluateDueRuns() }, this.tickMs) @@ -130,6 +130,8 @@ export class AutomationService { } stop(): void { + this.stopped = true + this.dispatchGeneration += 1 this.completionWatcher?.dispose() if (!this.timer) { return @@ -139,19 +141,21 @@ export class AutomationService { } async runNow(automationId: string): Promise { + const generation = this.dispatchGeneration const automation = this.store.listAutomations().find((entry) => entry.id === automationId) if (!automation) { throw new Error('Automation not found.') } - const run = this.runs.createRun(automation, Date.now(), 'manual') - return await this.requestDispatch(automation, run, this.resolveTarget(automation)) + const target = this.resolveTarget(automation) + const run = await this.runs.createRun(automation, Date.now(), 'manual') + return await this.requestDispatch(automation, run, target, generation) } /** The run-history row doc:94 pairs with the typed refusal an execute fence throws. */ - recordRefusedRun(automationId: string): void { + async recordRefusedRun(automationId: string): Promise { const automation = this.store.listAutomations().find((entry) => entry.id === automationId) if (automation) { - recordRefusedAutomationRun({ + await recordRefusedAutomationRun({ store: this.store, runs: this.runs, automation, @@ -198,7 +202,7 @@ export class AutomationService { } async markDispatchResult(result: AutomationDispatchResult): Promise { - const run = this.runs.updateRun(result) + const run = await this.runs.updateRun(result) clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalAutomationRunStatus(run.status)) { if (run.status === 'dispatched') { @@ -224,13 +228,17 @@ export class AutomationService { } private async evaluateDueRuns(): Promise { - if (this.evaluating) { + if (this.evaluating || this.stopped) { return } this.evaluating = true + const generation = this.dispatchGeneration try { const now = Date.now() for (const automation of this.store.listAutomations()) { + if (this.stopped || generation !== this.dispatchGeneration) { + break + } if (!automation.enabled || automation.nextRunAt > now) { continue } @@ -239,7 +247,14 @@ export class AutomationService { try { await this.evaluateAutomation(automation, now) } catch (error) { - recordUnevaluableAutomation({ runs: this.runs, automation, error }) + if ( + !(error instanceof AutomationDispatchCancelledError) && + !this.stopped && + generation === this.dispatchGeneration && + this.store.listAutomations().some((current) => current.id === automation.id) + ) { + await recordUnevaluableAutomation({ runs: this.runs, automation, error }) + } } } } finally { @@ -248,14 +263,15 @@ export class AutomationService { } private async evaluateAutomation(automation: Automation, now: number): Promise { + const generation = this.dispatchGeneration const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { - this.store.advanceAutomationNextRun(automation.id, now) + await this.runs.advanceNextRun(automation.id, now) return } if (missedBeyondGrace({ automation, scheduledFor, now, tickMs: this.tickMs })) { - recordMissedRun({ runs: this.runs, automation, scheduledFor }) - this.store.advanceAutomationNextRun(automation.id, now) + await recordMissedRun({ runs: this.runs, automation, scheduledFor }) + await this.runs.advanceNextRun(automation.id, now) return } @@ -263,14 +279,20 @@ export class AutomationService { // */5 automation would otherwise write ~288 identical rows a day — past // retention, which would evict the automation's real history. const target = this.resolveTarget(automation) - const refusal = describeScheduledRefusal({ target, canDispatch: this.canDispatch() }) - if (refusal && this.runs.repeatSkip(automation.id, refusal, scheduledFor)) { - this.store.advanceAutomationNextRun(automation.id, now) + const canDispatch = this.canDispatchToRenderer() || Boolean(this.headlessDispatcher) + const refusal = describeScheduledRefusal({ target, canDispatch }) + if (refusal && (await this.runs.repeatSkip(automation.id, refusal, scheduledFor))) { + await this.runs.advanceNextRun(automation.id, now) return } - await this.requestDispatch(automation, this.runs.createRun(automation, scheduledFor), target) - this.store.advanceAutomationNextRun(automation.id, now) + await this.requestDispatch( + automation, + await this.runs.createRun(automation, scheduledFor), + target, + generation + ) + await this.runs.advanceNextRun(automation.id, now) } private resolveTarget(automation: Automation): AutomationRunTargetResult { @@ -284,55 +306,27 @@ export class AutomationService { return Boolean(webContents && !webContents.isDestroyed() && this.rendererReady) } - /** Headless serve counts: it launches runs with no window at all. */ - private canDispatch(): boolean { - return this.canDispatchToRenderer() || Boolean(this.headlessDispatcher) - } - - private async requestDispatch( + private requestDispatch( automation: Automation, run: AutomationRun, - target: AutomationRunTargetResult + target: AutomationRunTargetResult, + generation: number ): Promise { - if (!target.ok) { - return this.runs.updateRun({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - error: target.error - }) - } - if (!this.canDispatchToRenderer()) { - if (this.headlessDispatcher) { - return await runHeadlessAutomationDispatch({ - automation, - run, - target, - dispatcher: this.headlessDispatcher, - runs: this.runs, - runPrecheck: () => this.runPrecheck(automation.id, run.id), - markDispatchResult: (result) => this.markDispatchResult(result), - watchRun: (dispatched) => this.completionWatcher?.watch(dispatched) - }) - } - return this.runs.updateRun({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - error: NO_DISPATCH_HOST - }) - } - const updated = this.runs.updateRun({ - runId: run.id, - status: 'dispatching', - workspaceId: automation.workspaceId, - error: null - }) - const payload: AutomationDispatchRequest = { + return requestAutomationDispatch( + { + store: this.store, + runs: this.runs, + isActive: () => !this.stopped && generation === this.dispatchGeneration, + getRenderer: () => (this.canDispatchToRenderer() ? this.webContents : null), + headlessDispatcher: this.headlessDispatcher, + resolveTarget: (current) => this.resolveTarget(current), + runPrecheck: () => this.runPrecheck(automation.id, run.id), + markDispatchResult: (result) => this.markDispatchResult(result), + watchRun: (dispatched) => this.completionWatcher?.watch(dispatched) + }, automation, - run: updated, - dispatchToken: createAutomationDispatchToken(automation.id, updated.id) - } - return sendRendererDispatch(this.webContents, payload, this.runs, updated) + run, + target + ) } } diff --git a/src/main/codex-accounts/async-file-rename.test.ts b/src/main/codex-accounts/async-file-rename.test.ts new file mode 100644 index 00000000000..a453378a78d --- /dev/null +++ b/src/main/codex-accounts/async-file-rename.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { writeFileDurable, writeFileDurableIfCurrent } from '../durable-file-write' + +const rename = vi.hoisted(() => vi.fn()) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + rename.mockImplementation(actual.rename) + return { ...actual, rename } +}) + +const platform = process.platform +const roots: string[] = [] +afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + rename.mockClear() + vi.restoreAllMocks() +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-async-rename-')) + roots.push(root) + const target = join(root, 'state.json') + const temporary = join(root, 'temporary.json') + writeFileSync(target, 'old') + return { root, target, temporary } +} + +it.each(['EPERM', 'EACCES', 'EBUSY'])( + 'retries a transient Windows %s without blocking the event loop', + async (code) => { + const { target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: 'win32' }) + rename.mockRejectedValueOnce(Object.assign(new Error('file busy'), { code })) + let ticked = false + const timer = setTimeout(() => { + ticked = true + }, 0) + try { + await writeFileDurable(temporary, target, 'new') + expect(readFileSync(target, 'utf8')).toBe('new') + expect(rename).toHaveBeenCalledTimes(2) + expect(ticked).toBe(true) + } finally { + clearTimeout(timer) + } + } +) + +it.each([ + ['win32', 'EPERM', 6], + ['win32', 'ENOSPC', 1], + ['linux', 'EBUSY', 1] +] as const)('bounds %s %s failures and preserves the old file', async (host, code, attempts) => { + const { root, target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: host }) + for (let attempt = 0; attempt < attempts; attempt++) { + rename.mockRejectedValueOnce(Object.assign(new Error('injected file failure'), { code })) + } + await expect(writeFileDurable(temporary, target, 'new')).rejects.toThrow('injected file failure') + expect(rename).toHaveBeenCalledTimes(attempts) + expect(readFileSync(target, 'utf8')).toBe('old') + expect(readdirSync(root)).toEqual(['state.json']) +}) + +it('does not publish a superseded snapshot after a Windows retry delay', async () => { + const { root, target, temporary } = fixture() + Object.defineProperty(process, 'platform', { value: 'win32' }) + let current = true + rename.mockImplementationOnce(async () => { + current = false + writeFileSync(target, 'newer snapshot') + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + await expect( + writeFileDurableIfCurrent(temporary, target, 'stale snapshot', () => current) + ).resolves.toBe(false) + expect(rename).toHaveBeenCalledOnce() + expect(readFileSync(target, 'utf8')).toBe('newer snapshot') + expect(readdirSync(root)).toEqual(['state.json']) +}) diff --git a/src/main/codex-accounts/codex-account-selection.ts b/src/main/codex-accounts/codex-account-selection.ts index ad97d80696b..24785e79dc1 100644 --- a/src/main/codex-accounts/codex-account-selection.ts +++ b/src/main/codex-accounts/codex-account-selection.ts @@ -28,7 +28,7 @@ type CodexAccountSelectionDependencies = { lifecycle: CodexAccountServiceLifecycle resolveSystemDefault: () => CodexSystemDefaultIdentity removeManagedHome: (candidatePath: string, expectedAccountId: string) => void - discardResetAttempts: (accountId: string) => void + discardResetAttempts: (accountId: string) => Promise } export class CodexAccountSelection { @@ -83,10 +83,13 @@ export class CodexAccountSelection { } this.dependencies.removeManagedHome(account.managedHomePath, account.id) - // Why: a removed account can no longer appear in the switcher dropdown, - // so purge its cached usage to avoid stale entries. this.dependencies.rateLimits.evictInactiveCodexCache(accountId) - this.dependencies.discardResetAttempts(accountId) + try { + await this.dependencies.discardResetAttempts(accountId) + } catch (error) { + // Removal already succeeded; retain the ledger's safety guards if cleanup fails. + console.warn('[codex-accounts] Removed account, but credit ledger cleanup failed:', error) + } const accountTarget = getCodexSelectionTargetForAccount(account) this.startQuotaRefresh( getSelectedCodexAccountIdForTarget(settings, accountTarget) === accountId diff --git a/src/main/codex-accounts/codex-reset-credit-coordinator.ts b/src/main/codex-accounts/codex-reset-credit-coordinator.ts index 5d6233d46eb..cd0c8ad8569 100644 --- a/src/main/codex-accounts/codex-reset-credit-coordinator.ts +++ b/src/main/codex-accounts/codex-reset-credit-coordinator.ts @@ -171,8 +171,8 @@ export class CodexResetCreditCoordinator { }) } - discardForRemovedAccount(accountId: string): void { - this.ledger.discardForRemovedAccount(accountId) + discardForRemovedAccount(accountId: string): Promise { + return this.ledger.discardForRemovedAccount(accountId) } private startAttempt( @@ -182,6 +182,9 @@ export class CodexResetCreditCoordinator { ): Promise { const promise = this.dependencies.serializeMutation( async (): Promise => { + if (this.ledger.error) { + throw this.ledger.error + } const isFresh = attempt.state === 'fresh' let validation: { managedHomePath: string; rateLimits: RateLimitState } try { @@ -204,7 +207,7 @@ export class CodexResetCreditCoordinator { throw error } if (isFresh) { - this.ledger.markProviderPending(idempotencyKey, attempt) + await this.ledger.markProviderPending(idempotencyKey, attempt) } const { outcome, state } = await this.dependencies.rateLimits.consumeCodexRateLimitResetCredit({ @@ -220,7 +223,7 @@ export class CodexResetCreditCoordinator { codex: this.dependencies.getSnapshot(), rateLimits: state } - this.ledger.markSettled(idempotencyKey, attempt, outcome) + await this.ledger.markSettled(idempotencyKey, attempt, outcome) return result } ) diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.test.ts b/src/main/codex-accounts/codex-reset-credit-ledger.test.ts new file mode 100644 index 00000000000..32f13e33d78 --- /dev/null +++ b/src/main/codex-accounts/codex-reset-credit-ledger.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import { ProfileStateWriterError } from '../persistence/profile-state/profile-state-writer-errors' +import { CodexResetCreditLedger } from './codex-reset-credit-ledger' + +function scope(accountId: string): CodexResetCreditExpectedScope { + return { + target: { runtime: 'host', wslDistro: null }, + accountId, + accountRevision: 1, + offerRevision: 'offer-1' + } +} + +function setup() { + let durable: CodexResetCreditAttemptLedger = { version: 1, attempts: [] } + const barrier = vi.fn(async () => {}) + const store = { + getCodexResetCreditAttemptLedger: () => structuredClone(durable), + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn( + async (next: CodexResetCreditAttemptLedger) => { + await barrier() + durable = structuredClone(next) + } + ) + } + return { ledger: new CodexResetCreditLedger(store), store, barrier } +} + +describe('async reset-credit ledger', () => { + it('serializes replacement construction so concurrent account writes survive', async () => { + const { ledger, store, barrier } = setup() + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const first = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + const pendingFirst = ledger.markProviderPending('first', first) + const pendingSecond = ledger.markProviderPending('second', second) + await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce()) + expect(first.state).toBe('fresh') + expect(second.state).toBe('fresh') + expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) + + gate.resolve() + await Promise.all([pendingFirst, pendingSecond]) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: 'first', state: 'providerPending' }, + { idempotencyKey: 'second', state: 'providerPending' } + ]) + expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBe('first') + expect(ledger.getUnresolvedKey(second.accountScopeKey)).toBe('second') + }) + + it('keeps pending guards until settlement commits and can retry a known failure', async () => { + const { ledger, store, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + await ledger.markProviderPending('first', attempt) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const settled = ledger.markSettled('first', attempt, 'reset') + const rejected = expect(settled).rejects.toThrow('disk full') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2)) + expect(attempt.state).toBe('providerPending') + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first') + + gate.reject(new Error('disk full')) + await rejected + expect(attempt.state).toBe('providerPending') + expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending') + expect(ledger.error).toBeNull() + await ledger.markSettled('first', attempt, 'alreadyRedeemed') + expect(attempt.settledOutcome).toBe('alreadyRedeemed') + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBeUndefined() + }) + + it('waits for queued writes before removing an account and retains other accounts', async () => { + const { ledger, store, barrier } = setup() + const first = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + await ledger.markProviderPending('first', first) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const pendingSecond = ledger.markProviderPending('second', second) + const removed = ledger.discardForRemovedAccount('account-1') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledTimes(2)) + expect(ledger.get('first')).toBe(first) + + gate.resolve() + await Promise.all([pendingSecond, removed]) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: 'second', state: 'providerPending' } + ]) + expect(ledger.get('first')).toBeUndefined() + expect(ledger.getUnresolvedKey(first.accountScopeKey)).toBeUndefined() + expect(ledger.get('second')).toBe(second) + }) + + it('retains the removed account guard when its async durability barrier fails', async () => { + const { ledger, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + await ledger.markProviderPending('first', attempt) + barrier.mockRejectedValueOnce(new Error('disk full')) + await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow('disk full') + expect(ledger.get('first')).toBe(attempt) + expect(ledger.getUnresolvedKey(attempt.accountScopeKey)).toBe('first') + }) + + it('fails queued and future mutations closed when a commit outcome is unknown', async () => { + const { ledger, store, barrier } = setup() + const attempt = ledger.createFresh('first', scope('account-1')) + const second = ledger.createFresh('second', scope('account-2')) + const gate = Promise.withResolvers() + barrier.mockImplementationOnce(() => gate.promise) + const pending = ledger.markProviderPending('first', attempt) + const queued = ledger.markProviderPending('second', second) + const rejected = expect(pending).rejects.toThrow('worker stopped') + const blocked = expect(queued).rejects.toThrow('durability is unknown') + await vi.waitFor(() => expect(barrier).toHaveBeenCalledOnce()) + gate.reject(new ProfileStateWriterError('worker-exit', 'worker stopped', 'indeterminate')) + await Promise.all([rejected, blocked]) + + ledger.releaseFresh('first', attempt) + expect(ledger.get('first')).toBe(attempt) + expect(ledger.getClaimedKey(attempt.scopeKey)).toBe('first') + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + await expect(ledger.discardForRemovedAccount('account-1')).rejects.toThrow( + 'durability is unknown' + ) + }) +}) diff --git a/src/main/codex-accounts/codex-reset-credit-ledger.ts b/src/main/codex-accounts/codex-reset-credit-ledger.ts index 625731f7d38..77590bcfe36 100644 --- a/src/main/codex-accounts/codex-reset-credit-ledger.ts +++ b/src/main/codex-accounts/codex-reset-credit-ledger.ts @@ -9,6 +9,7 @@ import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' import type { Store } from '../persistence' +import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' export type CodexResetCreditAttempt = { expectedScope: CodexResetCreditExpectedScope @@ -45,14 +46,20 @@ export class CodexResetCreditLedger { private readonly attemptKeyByOffer = new Map() private readonly unresolvedKeyByAccountScope = new Map() private durableLedger: CodexResetCreditAttemptLedger | null = null - private loadError: Error | null = null + private stateError: Error | null = null + private mutationQueue: Promise = Promise.resolve() - constructor(private readonly store: Store) { + constructor( + private readonly store: Pick< + Store, + 'getCodexResetCreditAttemptLedger' | 'replaceCodexResetCreditAttemptLedgerAndFlush' + > + ) { this.hydrate() } get error(): Error | null { - return this.loadError + return this.stateError } get(idempotencyKey: string): CodexResetCreditAttempt | undefined { @@ -114,32 +121,40 @@ export class CodexResetCreditLedger { ) } - markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - this.persist({ idempotencyKey, expectedScope: attempt.expectedScope, state: 'providerPending' }) - attempt.state = 'providerPending' - this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + markProviderPending(idempotencyKey: string, attempt: CodexResetCreditAttempt): Promise { + return this.serializeMutation(async () => { + await this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'providerPending' + }) + attempt.state = 'providerPending' + this.unresolvedKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + }) } markSettled( idempotencyKey: string, attempt: CodexResetCreditAttempt, outcome: CodexRateLimitResetOutcome - ): void { - this.persist({ - idempotencyKey, - expectedScope: attempt.expectedScope, - state: 'settled', - outcome + ): Promise { + return this.serializeMutation(async () => { + await this.persist({ + idempotencyKey, + expectedScope: attempt.expectedScope, + state: 'settled', + outcome + }) + attempt.state = 'settled' + attempt.settledOutcome = outcome + if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) + } }) - attempt.state = 'settled' - attempt.settledOutcome = outcome - if (this.unresolvedKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { - this.unresolvedKeyByAccountScope.delete(attempt.accountScopeKey) - } } releaseFresh(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { - if (attempt.state !== 'fresh') { + if (attempt.state !== 'fresh' || this.stateError) { return } this.attemptsByKey.delete(idempotencyKey) @@ -151,7 +166,11 @@ export class CodexResetCreditLedger { // Why: a removed account's managed home is gone, so its unresolved providerPending // attempt can never validate or be replayed; drop it so a target-scoped default reset // is not wedged forever by hasPendingResetForTarget matching the orphan. - discardForRemovedAccount(accountId: string): void { + discardForRemovedAccount(accountId: string): Promise { + return this.serializeMutation(() => this.discardAccountAttempts(accountId)) + } + + private async discardAccountAttempts(accountId: string): Promise { const staleAttempts = [...this.attemptsByKey].filter( ([, attempt]) => attempt.expectedScope.accountId === accountId ) @@ -167,7 +186,7 @@ export class CodexResetCreditLedger { const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } // Persist first so a failed durability barrier leaves the in-memory // fail-closed guards aligned with the ledger that will reload. - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) this.durableLedger = structuredClone(nextLedger) } } @@ -202,14 +221,34 @@ export class CodexResetCreditLedger { } } } catch (error) { - this.loadError = + this.stateError = error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt') } } - private persist(nextAttempt: DurableCodexResetCreditAttempt): void { + private serializeMutation(operation: () => Promise): Promise { + const next = this.mutationQueue.then(async () => { + if (this.stateError) { + throw this.stateError + } + try { + await operation() + } catch (error) { + if (profileStateWriterFailureOutcome(error) === 'indeterminate') { + this.stateError = new Error('Codex reset-credit attempt durability is unknown', { + cause: error + }) + } + throw error + } + }) + this.mutationQueue = next.catch(() => {}) + return next + } + + private async persist(nextAttempt: DurableCodexResetCreditAttempt): Promise { if (!this.durableLedger) { - throw this.loadError ?? new Error('Codex reset-credit attempt ledger is unavailable') + throw this.stateError ?? new Error('Codex reset-credit attempt ledger is unavailable') } const index = this.durableLedger.attempts.findIndex( (attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey @@ -221,7 +260,7 @@ export class CodexResetCreditLedger { attempts[index] = nextAttempt } const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } - this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + await this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) this.durableLedger = structuredClone(nextLedger) } } diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index e0610cc79c7..295dfac9b66 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { rename } from 'node:fs/promises' import { dirname } from 'node:path' +import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' @@ -168,7 +170,7 @@ function assertHardLinkPublicationSupported(sourcePath: string, targetPath: stri } } -function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { +export function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean { try { linkSync(sourcePath, targetPath) return true @@ -201,19 +203,38 @@ export function renameFileWithWindowsRetry(source: string, target: string): void runFileOperationWithWindowsRetry(() => renameSync(source, target)) } +export async function renameFileWithWindowsRetryAsync( + source: string, + target: string, + isCurrent: () => boolean = () => true +): Promise { + for (let attempt = 1; ; attempt++) { + if (!isCurrent()) { + return false + } + try { + await rename(source, target) + return true + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + export function copyFileWithWindowsRetry(source: string, target: string): void { runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) } function runFileOperationWithWindowsRetry(operation: () => void): void { - const maxAttempts = process.platform === 'win32' ? 6 : 1 - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + for (let attempt = 1; ; attempt++) { try { operation() return } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) { + if (shouldRetryFileOperation(error, attempt)) { sleepSync(attempt * 50) continue } @@ -222,6 +243,16 @@ function runFileOperationWithWindowsRetry(operation: () => void): void { } } +function shouldRetryFileOperation(error: unknown, attempt: number): boolean { + return ( + process.platform === 'win32' && + attempt < 6 && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) +} + // Why: writeFileAtomically is a sync API called from sync paths, so the retry // backoff must park the thread instead of burning CPU in a Date.now() loop. const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) diff --git a/src/main/codex-accounts/service-account-selection-and-removal.test.ts b/src/main/codex-accounts/service-account-selection-and-removal.test.ts index a2d7d9a4bb7..861859223ea 100644 --- a/src/main/codex-accounts/service-account-selection-and-removal.test.ts +++ b/src/main/codex-accounts/service-account-selection-and-removal.test.ts @@ -215,47 +215,67 @@ describe('CodexAccountService config sync', () => { }) }) - it('removes an account and cleans up managed home', async () => { - const managedHomePath = createManagedHome( - testState.userDataDir, - 'account-1', - '', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const rateLimits = createRateLimits() - const runtimeHome = createRuntimeHome() + it.each(['healthy', 'unreadable'])( + 'removes an account with a %s credit ledger', + async (ledger) => { + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + '', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() - const { CodexAccountService } = await import('./service') - const service = new CodexAccountService( - store as never, - rateLimits as never, - runtimeHome as never - ) + const ledgerError = new Error('credit ledger unreadable') + if (ledger === 'unreadable') { + store.getCodexResetCreditAttemptLedger.mockImplementation(() => { + throw ledgerError + }) + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = await service.removeAccount('account-1') + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) - expect(result.accounts).toHaveLength(0) - expect(result.activeAccountId).toBe(null) - expect(existsSync(managedHomePath)).toBe(false) - expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() - }) + const result = await service.removeAccount('account-1') + + expect(result.accounts).toHaveLength(0) + expect(result.activeAccountId).toBe(null) + expect(existsSync(managedHomePath)).toBe(false) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + expect(rateLimits.evictInactiveCodexCache).toHaveBeenCalledWith('account-1') + if (ledger === 'unreadable') { + expect(warn).toHaveBeenCalledWith( + '[codex-accounts] Removed account, but credit ledger cleanup failed:', + expect.any(Error) + ) + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).not.toHaveBeenCalled() + } + warn.mockRestore() + } + ) it('refuses to remove a managed home owned by a different account', async () => { const otherAccountHome = createManagedHome( diff --git a/src/main/codex-accounts/service-reset-credit-durability.test.ts b/src/main/codex-accounts/service-reset-credit-durability.test.ts index ac6cd8f2ef7..e524bda2aeb 100644 --- a/src/main/codex-accounts/service-reset-credit-durability.test.ts +++ b/src/main/codex-accounts/service-reset-credit-durability.test.ts @@ -146,9 +146,22 @@ describe('CodexAccountService config sync', () => { account, limits })! + const store = createStore(settings) + const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! + const pendingCommit = Promise.withResolvers() + const settledCommit = Promise.withResolvers() + store.replaceCodexResetCreditAttemptLedgerAndFlush + .mockImplementationOnce(async (ledger) => { + await pendingCommit.promise + await persist(ledger) + }) + .mockImplementationOnce(async (ledger) => { + await settledCommit.promise + await persist(ledger) + }) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( - createStore(settings) as never, + store as never, rateLimits as never, createRuntimeHome() as never ) @@ -157,9 +170,20 @@ describe('CodexAccountService config sync', () => { const first = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) const second = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) expect(second).toBe(first) + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + ) + expect(consume).not.toHaveBeenCalled() + pendingCommit.resolve() await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce()) const selectingNextAccount = service.selectAccount(nextAccount.id) finishConsume?.({ outcome: 'reset', state }) + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledTimes(2) + ) + expect(service.listAccounts().activeAccountId).toBe(account.id) + expect(store.getCodexResetCreditAttemptLedger().attempts[0]?.state).toBe('providerPending') + settledCommit.resolve() const resetResults = await Promise.all([first, second]) expect(resetResults).toMatchObject([ @@ -406,9 +430,10 @@ describe('CodexAccountService config sync', () => { const limits = createResetCreditLimits() const state = createResetRateLimitState(limits) const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(() => { - throw new Error('disk full') - }) + const pendingCommit = Promise.withResolvers() + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce( + () => pendingCommit.promise + ) const consume = vi.fn() const expectedScope = buildCodexResetCreditExpectedScope({ target: state.codexTarget, @@ -426,9 +451,15 @@ describe('CodexAccountService config sync', () => { createRuntimeHome() as never ) - await expect( + const rejected = expect( service.consumeRateLimitResetCredit('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', expectedScope) ).rejects.toThrow('disk full') + await vi.waitFor(() => + expect(store.replaceCodexResetCreditAttemptLedgerAndFlush).toHaveBeenCalledOnce() + ) + expect(consume).not.toHaveBeenCalled() + pendingCommit.reject(new Error('disk full')) + await rejected expect(consume).not.toHaveBeenCalled() expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) }) @@ -453,11 +484,11 @@ describe('CodexAccountService config sync', () => { const state = createResetRateLimitState(limits) const store = createStore(settings) const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! - store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation((ledger) => { + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation(async (ledger) => { if (ledger.attempts[0]?.state === 'settled') { throw new Error('settle disk full') } - persist(ledger) + return persist(ledger) }) const expectedScope = buildCodexResetCreditExpectedScope({ target: state.codexTarget, diff --git a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts index b5c534be417..eebc256a8c6 100644 --- a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts +++ b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts @@ -145,7 +145,7 @@ describe('Codex reset-credit managed-home ownership', () => { } ] } - fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) + await fixture.store.replaceCodexResetCreditAttemptLedgerAndFlush(pendingLedger) makeHomeUnsafe(fixture.managedHomePath) const settingsBefore = structuredClone(fixture.store.getSettings()) diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index ebfe4e3ac1c..1485794c5f9 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { existsSync } from 'node:fs' import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import { createManagedHome, @@ -329,7 +330,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -379,7 +380,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -414,7 +415,7 @@ describe('CodexAccountService config sync', () => { expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) }) - it('keeps reset attempts fail-closed when removal cannot persist their purge', async () => { + it('reports account removal while keeping reset attempts guarded after a failed purge', async () => { const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') const account = { id: 'account-1', @@ -438,7 +439,7 @@ describe('CodexAccountService config sync', () => { limits })! const store = createStore(settings) - store.replaceCodexResetCreditAttemptLedgerAndFlush({ + await store.replaceCodexResetCreditAttemptLedgerAndFlush({ version: 1, attempts: [ { @@ -459,11 +460,17 @@ describe('CodexAccountService config sync', () => { } as never, createRuntimeHome() as never ) - vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => { - throw new Error('disk full') - }) + const failure = new Error('disk full') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockRejectedValueOnce(failure) - await expect(service.removeAccount('account-1')).rejects.toThrow('disk full') + await expect(service.removeAccount('account-1')).resolves.toMatchObject({ accounts: [] }) + expect(store.getSettings().codexManagedAccounts).toEqual([]) + expect(existsSync(managedHomePath)).toBe(false) + expect(warning).toHaveBeenCalledWith( + '[codex-accounts] Removed account, but credit ledger cleanup failed:', + failure + ) await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') expect(consume).not.toHaveBeenCalled() }) diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 4587788ec67..8b81cb2125f 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -56,9 +56,11 @@ export function createStore(settings: GlobalSettings) { return settings }), getCodexResetCreditAttemptLedger: vi.fn(() => structuredClone(resetLedger)), - replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn((next: CodexResetCreditAttemptLedger) => { - resetLedger = structuredClone(next) - }) + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn( + async (next: CodexResetCreditAttemptLedger) => { + resetLedger = structuredClone(next) + } + ) } } diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 049800daff4..532bc75c574 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -60,7 +60,10 @@ export function probeDaemonSocket( socketPath: string, timeoutMs = DAEMON_SOCKET_PROBE_TIMEOUT_MS ): Promise { - const { promise, resolve } = Promise.withResolvers() + let resolve!: (alive: boolean) => void + const promise = new Promise((settle) => { + resolve = settle + }) if (process.platform !== 'win32' && !existsSync(socketPath)) { resolve(false) return promise diff --git a/src/main/daemon/daemon-process-identity-query.ts b/src/main/daemon/daemon-process-identity-query.ts index 479aa821ecf..d4eee6b1b84 100644 --- a/src/main/daemon/daemon-process-identity-query.ts +++ b/src/main/daemon/daemon-process-identity-query.ts @@ -16,22 +16,26 @@ export type PsProcessIdentity = { startedAtMs: number | null } -function parsePsProcessIdentity(output: string): PsProcessIdentity { +function parsePsProcessIdentity(output: string, utc = false): PsProcessIdentity { // BSD ps formats lstart as a fixed-width 24-character timestamp. - const startedAtMs = Date.parse(output.slice(0, 24)) + const startedAtMs = Date.parse(output.slice(0, 24) + (utc ? ' UTC' : '')) return { commandLine: output.slice(24).trim(), startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null } } -export function getPsProcessIdentity(pid: number): PsProcessIdentity | null { +export function getPsProcessIdentity( + pid: number, + options?: { utc?: boolean } +): PsProcessIdentity | null { try { const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8', - timeout: 2_000 + timeout: 2_000, + ...(options?.utc ? { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } } : {}) }) - return parsePsProcessIdentity(output) + return parsePsProcessIdentity(output, options?.utc) } catch { return null } diff --git a/src/main/daemon/daemon-process-identity-time-zone.test.ts b/src/main/daemon/daemon-process-identity-time-zone.test.ts new file mode 100644 index 00000000000..aee3e4f4420 --- /dev/null +++ b/src/main/daemon/daemon-process-identity-time-zone.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { getPsProcessIdentity } from './daemon-process-identity-query' + +const { execFileSync } = vi.hoisted(() => ({ execFileSync: vi.fn() })) +vi.mock('node:child_process', () => ({ execFileSync, execFile: vi.fn() })) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() +}) + +it.each(['America/Los_Angeles', 'America/New_York', 'UTC'])( + 'keeps the autumn clock transition unambiguous under %s', + (timezone) => { + vi.stubEnv('TZ', timezone) + execFileSync.mockReturnValue('Sun Nov 1 09:30:00 2026 /path/to/orca\n') + expect(getPsProcessIdentity(42, { utc: true })).toEqual({ + startedAtMs: Date.parse('2026-11-01T09:30:00Z'), + commandLine: '/path/to/orca' + }) + expect(execFileSync).toHaveBeenCalledWith( + 'ps', + ['-p', '42', '-o', 'lstart=', '-o', 'command='], + expect.objectContaining({ env: expect.objectContaining({ TZ: 'UTC', LC_ALL: 'C' }) }) + ) + } +) + +it('treats an unreadable UTC process start as unknown', () => { + execFileSync.mockReturnValue(' /path/to/orca\n') + expect(getPsProcessIdentity(42, { utc: true })?.startedAtMs).toBeNull() +}) diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts index 633f0d1c7f5..af52051a483 100644 --- a/src/main/daemon/daemon-pty-spawn-preparations.ts +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -33,7 +33,14 @@ export class DaemonPtySpawnPreparations { clientId, requestId } - this.cancellationByPreparation.set(preparation, Promise.withResolvers()) + let resolveCancellation!: () => void + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve + }) + this.cancellationByPreparation.set(preparation, { + promise: cancellation, + resolve: resolveCancellation + }) if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { preparation.cancelTimer = setTimeout( () => this.cancelPreparation(preparation), diff --git a/src/main/daemon/terminal-history-permission-repair.ts b/src/main/daemon/terminal-history-permission-repair.ts index 7cb8414026e..ffb4ea852cc 100644 --- a/src/main/daemon/terminal-history-permission-repair.ts +++ b/src/main/daemon/terminal-history-permission-repair.ts @@ -116,7 +116,10 @@ export function scheduleTerminalHistoryPermissionRepair(basePath: string): Promi } scheduledBasePaths.delete(oldest.value) } - const { promise, resolve: settle } = Promise.withResolvers() + let settle!: (repaired: boolean) => void + const promise = new Promise((resolve) => { + settle = resolve + }) const timer = setTimeout(() => { repairTerminalHistoryPermissions(key).then(settle, () => settle(false)) }, REPAIR_START_DELAY_MS) diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts index 1ab19b0c97e..a10d054926c 100644 --- a/src/main/daemon/windows-conpty-warmup.test.ts +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type * as pty from 'node-pty' import { warmWindowsConptyOnce } from './windows-conpty-warmup' +import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' + +vi.mock('../windows/windows-pty-job', () => ({ + assignHostProcessToKillOnCloseJob: vi.fn(() => true) +})) function setPlatform(platform: NodeJS.Platform): () => void { const original = process.platform @@ -17,6 +22,7 @@ afterEach(() => { restorePlatform?.() restorePlatform = null vi.restoreAllMocks() + vi.clearAllMocks() }) function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { @@ -41,6 +47,7 @@ describe('warmWindowsConptyOnce', () => { await flushImmediates() expect(spawnPty).not.toHaveBeenCalled() + expect(assignHostProcessToKillOnCloseJob).not.toHaveBeenCalled() }) it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { @@ -52,6 +59,7 @@ describe('warmWindowsConptyOnce', () => { await flushImmediates() expect(spawnPty).toHaveBeenCalledTimes(1) + expect(assignHostProcessToKillOnCloseJob).toHaveBeenCalledBefore(vi.mocked(spawnPty)) const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] expect(String(file).toLowerCase()).toContain('cmd') expect(args).toEqual(['/c', 'exit']) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index aea1c50b2db..6f39197261f 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -1,5 +1,6 @@ import os from 'node:os' import * as pty from 'node-pty' +import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' const WARMUP_KILL_TIMEOUT_MS = 10_000 @@ -17,6 +18,8 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): v // real spawn arriving first simply does the warming itself. setImmediate(() => { try { + // Warm-up children must die with the daemon, even before its first real terminal. + assignHostProcessToKillOnCloseJob() const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { name: 'xterm-256color', cols: 2, diff --git a/src/main/durable-file-write-syscall-proof.test.ts b/src/main/durable-file-write-syscall-proof.test.ts index bf18d6c0092..34563b0196a 100644 --- a/src/main/durable-file-write-syscall-proof.test.ts +++ b/src/main/durable-file-write-syscall-proof.test.ts @@ -1,6 +1,15 @@ // Empirical proof that the durable write fsyncs the file, and the directory where the platform // allows it. Counted at the module boundary rather than inferred from reading the implementation. -import { closeSync, fsyncSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs' +import { + closeSync, + existsSync, + fsyncSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -9,23 +18,57 @@ import { expect, it, vi } from 'vitest' /** Why the rename is recorded too: an fsync moved after the rename still fsyncs a file, so a * fsync-only log reads identically for the correct and the broken order. The rename is the boundary * the ordering is defined against, so it has to appear in the same sequence. */ -const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = [] +const syscalls: ('fsync:file' | 'fsync:directory' | 'rename' | 'link')[] = [] vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, fsyncSync: (fd: number) => { + actual.fsyncSync(fd) syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file') - return actual.fsyncSync(fd) }, renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + actual.renameSync(from, to) syscalls.push('rename') - return actual.renameSync(from, to) + }, + linkSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + actual.linkSync(from, to) + syscalls.push('link') } } }) +it('publishes a new file durably and cannot replace an existing destination', async () => { + const { publishFileDurableSync } = await import('./durable-file-write') + const dir = mkdtempSync(join(tmpdir(), 'orca-publish-fsync-')) + try { + const supported = directoryFsyncSupported(dir) + const staged = join(dir, 'staged') + const target = join(dir, 'target') + writeFileSync(staged, 'first') + const fd = openSync(staged, 'r+') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } + syscalls.length = 0 + expect(publishFileDurableSync(staged, target)).toBe(true) + expect(syscalls).toEqual(supported ? ['link', 'fsync:directory'] : ['link']) + expect(existsSync(staged)).toBe(false) + expect(readFileSync(target, 'utf8')).toBe('first') + writeFileSync(staged, 'second') + syscalls.length = 0 + expect(publishFileDurableSync(staged, target)).toBe(false) + expect(readFileSync(target, 'utf8')).toBe('first') + expect(readFileSync(staged, 'utf8')).toBe('second') + expect(syscalls).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + /** Windows cannot open a directory for fsync, and some filesystems reject it; probe rather than * assume, so the expectation tracks the real platform instead of a hardcoded OS list. */ function directoryFsyncSupported(directory: string): boolean { diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 83b0eabc5ed..05e849327b7 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,9 +4,13 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises' +import { copyFile, open, readdir, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import { renameFileWithWindowsRetry } from './codex-accounts/fs-utils' +import { + publishFileWithoutOverwrite, + renameFileWithWindowsRetry, + renameFileWithWindowsRetryAsync +} from './codex-accounts/fs-utils' /** * fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a @@ -43,12 +47,28 @@ function syncDirectorySync(directory: string): void { } } +/** Rename an already-fsynced file and make the containing directory durable. */ +export function renameDurableSync(tmpPath: string, finalPath: string): void { + renameFileWithWindowsRetry(tmpPath, finalPath) + syncDirectorySync(dirname(finalPath)) +} + +/** Publish an already-fsynced file without replacing a concurrently created destination. */ +export function publishFileDurableSync(tmpPath: string, finalPath: string): boolean { + if (!publishFileWithoutOverwrite(tmpPath, finalPath)) { + return false + } + syncDirectorySync(dirname(finalPath)) + rmSync(tmpPath) + return true +} + /** * Rename and then fsync the containing directory. For callers that already fsynced the temp file * themselves and need the rename made durable. */ export async function renameDurable(tmpPath: string, finalPath: string): Promise { - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) await syncDirectory(dirname(finalPath)) } @@ -96,7 +116,7 @@ export async function copyFileDurable(sourcePath: string, finalPath: string): Pr } finally { await handle.close() } - await rename(tmpPath, finalPath) + await renameFileWithWindowsRetryAsync(tmpPath, finalPath) renamed = true await syncDirectory(dirname(finalPath)) return true @@ -132,10 +152,9 @@ export async function writeFileDurableIfCurrent( try { // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. await writeTempFileDurable(tmpPath, payload) - if (!isCurrent()) { + if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) { return false } - await rename(tmpPath, finalPath) renamed = true await syncDirectory(dirname(finalPath)) return true @@ -202,9 +221,8 @@ export function writeFileDurableSync( } finally { closeSync(fd) } - renameFileWithWindowsRetry(tmpPath, finalPath) + renameDurableSync(tmpPath, finalPath) renamed = true - syncDirectorySync(dirname(finalPath)) } finally { if (!renamed) { rmSync(tmpPath, { force: true }) diff --git a/src/main/index.ts b/src/main/index.ts index 522e59b908f..d4de6cac7e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ -import { app, type BrowserWindow } from 'electron' +import { app, clipboard, dialog, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' import { createMacAppActivationHandler } from './window/macos-app-activation' +import { isBackgroundLaunch } from './window/foreground-activation-policy' import { focusExistingWindow as focusExistingWindowAction, setMainWindowOpener @@ -13,6 +14,12 @@ import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' +import { + formatProfileStateStartupFailure, + profileStateStartupFailureClass +} from './persistence/profile-state/profile-state-startup-failure' +import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' +import { presentProfileStateStartupRecoveryDialog } from './persistence/profile-state/profile-state-startup-recovery-dialog' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -107,9 +114,37 @@ if (preflightReady) { registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { - await initializeMainProcessReady({ - openMainWindow, - handleMacAppActivation - }) + try { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation + }) + } catch (error) { + const message = + formatProfileStateStartupFailure(error) ?? + `Orca could not finish starting: ${error instanceof Error ? error.message : String(error)}` + const failureClass = profileStateStartupFailureClass(error) + if (failureClass !== undefined) { + recordDurableCrashBreadcrumb('profile_state_startup_failed', { + failure_class: failureClass + }) + } + console.error(`[profile-state] ${message}`) + if (!state.isServeMode && !isBackgroundLaunch()) { + try { + await presentProfileStateStartupRecoveryDialog({ + message, + ...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority' + ? { recoveryCommand: 'orca profile state exports' } + : {}), + showMessageBox: (options) => dialog.showMessageBox(options), + copyToClipboard: (text) => clipboard.writeText(text) + }) + } catch (dialogError) { + console.warn('[profile-state] Recovery dialog failed; exiting safely:', dialogError) + } + } + app.exit(1) + } }) } diff --git a/src/main/ipc/orca-profile-project-transfer-args.ts b/src/main/ipc/orca-profile-project-transfer-args.ts new file mode 100644 index 00000000000..c2882baf6c4 --- /dev/null +++ b/src/main/ipc/orca-profile-project-transfer-args.ts @@ -0,0 +1,25 @@ +import type { TransferOrcaProfileProjectArgs } from '../../shared/orca-profiles' + +export function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { + if ( + typeof args !== 'object' || + args === null || + !('sourceProfileId' in args) || + typeof args.sourceProfileId !== 'string' || + !('targetProfileId' in args) || + typeof args.targetProfileId !== 'string' || + !('repoId' in args) || + typeof args.repoId !== 'string' || + !('mode' in args) || + (args.mode !== 'move' && args.mode !== 'copy') + ) { + throw new Error('invalid_orca_profile_project_transfer') + } + const sourceProfileId = args.sourceProfileId.trim() + const targetProfileId = args.targetProfileId.trim() + const repoId = args.repoId.trim() + if (!sourceProfileId || !targetProfileId || !repoId) { + throw new Error('invalid_orca_profile_project_transfer') + } + return { sourceProfileId, targetProfileId, repoId, mode: args.mode } +} diff --git a/src/main/ipc/orca-profiles-switch-persistence.test.ts b/src/main/ipc/orca-profiles-switch-persistence.test.ts new file mode 100644 index 00000000000..e948d4054e7 --- /dev/null +++ b/src/main/ipc/orca-profiles-switch-persistence.test.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../persistence/loading-store/profile-state-maintenance-fixture' +import { registerOrcaProfileHandlers } from './orca-profiles' + +const { handlers, quit, select } = vi.hoisted(() => ({ + handlers: new Map Promise>(), + quit: vi.fn(), + select: vi.fn() +})) +vi.mock('electron', () => ({ + app: { quit }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => { + handlers.set(channel, handler) + } + } +})) +vi.mock('../app-relaunch', () => ({ relaunchApp: vi.fn() })) +vi.mock('../orca-profiles/profile-index-store', () => ({ + getOrcaProfileListState: () => ({ activeProfileId: 'source', profiles: [] }), + setActiveOrcaProfile: select, + createLocalOrcaProfile: vi.fn(), + seedNewOrcaProfileTelemetryConsent: vi.fn() +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('plain profile switch persistence', () => { + it('preserves shutdown changes when quit starts during the switch checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const hold = async (write: () => Promise) => { + started.resolve() + await release.promise + await write() + } + const selective = authority.writeSerializedDomains.bind(authority) + const complete = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce((domains) => + hold(() => selective(domains)) + ) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce((domains) => + hold(() => complete(domains)) + ) + store.updateSettings({ theme: 'dark' }) + registerOrcaProfileHandlers(store) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const switching = handlers + .get('orcaProfiles:switch')?.( + { sender: { isDestroyed: () => false, send: vi.fn() } }, + { profileId: 'target' } + ) + .catch((error: unknown) => error) + await started.promise + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + const final = store.flushFinalOrThrowAsync() + release.resolve() + await final + await expect(switching).resolves.toEqual({ status: 'relaunching' }) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + }) + + it('admits pre-relaunch writes and includes SSH detach in the final source checkpoint', async () => { + const { store, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const cleanupSaved = vi.fn() + let final: Promise | undefined + quit.mockImplementation(() => { + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + }) + registerOrcaProfileHandlers(store, { + onBeforeRelaunch: async () => { + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + cleanupSaved() + } + }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const switchProfile = handlers.get('orcaProfiles:switch') + expect(switchProfile).toBeDefined() + await switchProfile?.( + { sender: { isDestroyed: () => false, send: vi.fn() } }, + { profileId: 'target' } + ) + await vi.advanceTimersByTimeAsync(150) + expect(final).toBeDefined() + await final + expect(select).toHaveBeenCalledWith('target') + expect(cleanupSaved).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState()) + }) +}) diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 215b559327d..2a8b24851d3 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProfileStoragePaths from '../orca-profiles/profile-storage-paths' const { handlers, @@ -11,7 +12,8 @@ const { getOrcaProfileListStateMock, seedNewOrcaProfileTelemetryConsentMock, setActiveOrcaProfileMock, - transferOrcaProfileProjectMock + transferOrcaProfileProjectMock, + hasOrcaProfileStateDatabaseMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), @@ -23,7 +25,8 @@ const { getOrcaProfileListStateMock: vi.fn(), seedNewOrcaProfileTelemetryConsentMock: vi.fn(), setActiveOrcaProfileMock: vi.fn(), - transferOrcaProfileProjectMock: vi.fn() + transferOrcaProfileProjectMock: vi.fn(), + hasOrcaProfileStateDatabaseMock: vi.fn() })) vi.mock('electron', () => ({ @@ -54,21 +57,36 @@ vi.mock('../orca-profiles/profile-index-store', () => ({ setActiveOrcaProfile: setActiveOrcaProfileMock })) -function makeStoreMock(flushPendingOrThrowAsync = vi.fn()): { - flushPendingOrThrowAsync: typeof flushPendingOrThrowAsync - freezeWrites: ReturnType - getSettings: () => Record -} { - return { flushPendingOrThrowAsync, freezeWrites: vi.fn(), getSettings: () => ({}) } +function makeStoreMock(flushPendingOrThrowAsync = vi.fn()) { + const freezeWrites = vi.fn() + const resumeMaintenance = vi.fn(async () => {}) + return { + flushPendingOrThrowAsync, + freezeWrites, + resumeMaintenance, + beginProfileMaintenance: vi.fn(async (options: unknown) => { + await flushPendingOrThrowAsync(options) + freezeWrites() + return { resume: resumeMaintenance } + }), + getSettings: () => ({}) + } } vi.mock('../orca-profiles/profile-project-transfer', () => ({ transferOrcaProfileProject: transferOrcaProfileProjectMock })) +vi.mock('../orca-profiles/profile-storage-paths', async (importOriginal) => ({ + ...(await importOriginal()), + hasOrcaProfileStateDatabase: hasOrcaProfileStateDatabaseMock +})) + import { registerOrcaProfileHandlers } from './orca-profiles' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +const ipcEvent = { sender: { isDestroyed: () => false, send: vi.fn() } } + describe('registerOrcaProfileHandlers', () => { beforeEach(() => { // Why the port and per-test: userData resolves through AppEnvironment now, and @@ -76,6 +94,7 @@ describe('registerOrcaProfileHandlers', () => { installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' }) vi.useFakeTimers() handlers.clear() + ipcEvent.sender.send.mockClear() appExitMock.mockReset() appQuitMock.mockReset() appRelaunchMock.mockReset() @@ -87,6 +106,7 @@ describe('registerOrcaProfileHandlers', () => { seedNewOrcaProfileTelemetryConsentMock.mockReset() setActiveOrcaProfileMock.mockReset() transferOrcaProfileProjectMock.mockReset() + hasOrcaProfileStateDatabaseMock.mockReset().mockReturnValue(false) }) afterEach(() => { @@ -107,12 +127,12 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) - await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual({ ...listState, multiProfileUi: false }) await expect( - Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' })) + Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(ipcEvent, { name: 'Work' })) ).resolves.toBe(createState) expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' }) }) @@ -127,11 +147,13 @@ describe('registerOrcaProfileHandlers', () => { }) registerOrcaProfileHandlers(makeStoreMock() as never) - await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ - activeProfileId: 'local-default', - profiles: [], - multiProfileUi: true - }) + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(ipcEvent))).resolves.toEqual( + { + activeProfileId: 'local-default', + profiles: [], + multiProfileUi: true + } + ) } finally { if (previous === undefined) { delete process.env.ORCA_MULTI_PROFILE_UI @@ -155,7 +177,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const resultPromise = Promise.resolve( - handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) await expect(resultPromise).resolves.toEqual({ status: 'relaunching' }) @@ -189,7 +211,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })) + Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' })) ).rejects.toThrow('flush_failed') expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -206,10 +228,10 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) const switchProfile = Promise.resolve( - handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-work' }) ) const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout') - await vi.advanceTimersByTimeAsync(20_000) + await vi.advanceTimersByTimeAsync(60_000) await rejection expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -225,7 +247,9 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' })) + Promise.resolve( + handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: 'local-default' }) + ) ).resolves.toEqual({ status: 'already-active' }) expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() @@ -236,7 +260,7 @@ describe('registerOrcaProfileHandlers', () => { registerOrcaProfileHandlers(makeStoreMock() as never) await expect( - Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' })) + Promise.resolve(handlers.get('orcaProfiles:switch')?.(ipcEvent, { profileId: ' ' })) ).rejects.toThrow('invalid_orca_profile_id') }) @@ -260,7 +284,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: ' personal ', targetProfileId: ' work ', repoId: ' repo-1 ', @@ -302,7 +326,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -328,11 +352,56 @@ describe('registerOrcaProfileHandlers', () => { await vi.advanceTimersByTimeAsync(150) expect(appRelaunchMock).toHaveBeenCalledOnce() + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') expect(appQuitMock).toHaveBeenCalledOnce() expect(appExitMock).not.toHaveBeenCalled() }) + it('relaunches the closed source when a completed move cannot update the profile index', async () => { + const store = makeStoreMock() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'move' }) + setActiveOrcaProfileMock.mockImplementationOnce(() => { + throw new Error('profile index disk full') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never) + + await expect( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ).rejects.toThrow('profile index disk full') + + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.resumeMaintenance).not.toHaveBeenCalled() + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') + expect(appQuitMock).toHaveBeenCalledOnce() + }) + + it('keeps the active profile writable during a transfer between inactive profiles', async () => { + const store = makeStoreMock() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'active', profiles: [] }) + transferOrcaProfileProjectMock.mockReturnValue({ status: 'transferred', mode: 'copy' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the Store operations exercised by the handlers. + registerOrcaProfileHandlers(store as never) + await handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }) + expect(store.beginProfileMaintenance).not.toHaveBeenCalled() + expect(store.freezeWrites).not.toHaveBeenCalled() + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock) + }) + it('rejects transfers that would mutate the active target profile offline', async () => { getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'work', @@ -342,7 +411,7 @@ describe('registerOrcaProfileHandlers', () => { await expect( Promise.resolve( - handlers.get('orcaProfiles:transferProject')?.(null, { + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', @@ -353,4 +422,84 @@ describe('registerOrcaProfileHandlers', () => { expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() }) + + it('freezes a newly migrated source after transfer failure and reopens its current profile', async () => { + const store = makeStoreMock() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockImplementation(() => { + hasOrcaProfileStateDatabaseMock.mockReturnValue(true) + throw new Error('source commit interrupted') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('source commit interrupted') + + expect(store.flushPendingOrThrowAsync).toHaveBeenCalledBefore(transferOrcaProfileProjectMock) + expect(store.freezeWrites).toHaveBeenCalledOnce() + expect(store.freezeWrites).toHaveBeenCalledBefore(onBeforeRelaunch) + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(ipcEvent.sender.send).toHaveBeenCalledWith('app:restart-committed') + expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer') + expect(appQuitMock).toHaveBeenCalledOnce() + }) + + it('keeps an active JSON source writable after validation fails without a migration', async () => { + const store = makeStoreMock() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'personal', profiles: [] }) + transferOrcaProfileProjectMock.mockImplementation(() => { + throw new Error('unknown_source_repo') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(ipcEvent, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).rejects.toThrow('unknown_source_repo') + + expect(store.resumeMaintenance).toHaveBeenCalledOnce() + expect(onBeforeRelaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(relaunchAppMock).not.toHaveBeenCalled() + }) + + it.each([ + null, + {}, + { sourceProfileId: 4 }, + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'invalid' + } + ])('rejects malformed transfer arguments before disk work: %j', async (args) => { + const store = makeStoreMock() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every Store operation exercised by these IPC handlers. + registerOrcaProfileHandlers(store as never) + await expect( + Promise.resolve(handlers.get('orcaProfiles:transferProject')?.(ipcEvent, args)) + ).rejects.toThrow('invalid_orca_profile_project_transfer') + expect(store.flushPendingOrThrowAsync).not.toHaveBeenCalled() + expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index 480c6f350f9..694841e544e 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron' +import { app, ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' import { relaunchApp, type AppRelaunchReason } from '../app-relaunch' import type { @@ -33,8 +33,12 @@ import { import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' +import { transferActiveProfileProject } from '../orca-profiles/profile-active-transfer' import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' -import { flushActiveProfileBeforeFileMutation } from '../orca-profiles/profile-persistence-deadline' +import { + flushActiveProfileBeforeFileMutation, + flushActiveProfileBeforeRelaunch +} from '../orca-profiles/profile-persistence-deadline' import { normalizeExecutionHostId } from '../../shared/execution-host' import { createCloudLinkedOrcaProfile, @@ -47,6 +51,7 @@ import { import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation' import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast' +import { transferProjectArgsFromUnknown } from './orca-profile-project-transfer-args' type RegisterOrcaProfileHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -55,40 +60,16 @@ type RegisterOrcaProfileHandlersOptions = { } function profileIdFromArgs(args: unknown): string { - if ( - !args || - typeof args !== 'object' || - typeof (args as SwitchOrcaProfileArgs).profileId !== 'string' - ) { - throw new Error('invalid_orca_profile_id') - } - const profileId = (args as SwitchOrcaProfileArgs).profileId.trim() + const profileId = + args && typeof args === 'object' && 'profileId' in args && typeof args.profileId === 'string' + ? args.profileId.trim() + : '' if (!profileId) { throw new Error('invalid_orca_profile_id') } return profileId } -function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { - if (!args || typeof args !== 'object') { - throw new Error('invalid_orca_profile_project_transfer') - } - const candidate = args as TransferOrcaProfileProjectArgs - const sourceProfileId = candidate.sourceProfileId?.trim() - const targetProfileId = candidate.targetProfileId?.trim() - const repoId = candidate.repoId?.trim() - const mode = candidate.mode - if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) { - throw new Error('invalid_orca_profile_project_transfer') - } - return { - sourceProfileId, - targetProfileId, - repoId, - mode - } -} - function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs { if (!args || typeof args !== 'object') { throw new Error('invalid_orca_profile_project_path') @@ -157,7 +138,12 @@ async function runBeforeProfileRelaunch( } } -function scheduleProfileRelaunch(reason: Extract): void { +type ProfileRelaunchReason = Extract + +function scheduleProfileRelaunch(reason: ProfileRelaunchReason, sender: WebContents): void { + if (!sender.isDestroyed()) { + sender.send('app:restart-committed') + } setTimeout(() => { relaunchApp(reason) // Why: app.quit() (not app.exit) so before-quit/will-quit still run — @@ -197,7 +183,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:switch', - async (_event, args: SwitchOrcaProfileArgs): Promise => { + async (event, args: SwitchOrcaProfileArgs): Promise => { const profileId = profileIdFromArgs(args) const current = getOrcaProfileListState() if (profileId === current.activeProfileId) { @@ -217,11 +203,12 @@ export function registerOrcaProfileHandlers( } // Why: the current profile must be persisted before the global index // points startup at the target profile. - await flushActiveProfileBeforeFileMutation(store) - await runBeforeProfileRelaunch(options.onBeforeRelaunch) + // Switching leaves source files intact; relaunch cleanup still needs its live writer. + await flushActiveProfileBeforeRelaunch(store) setActiveOrcaProfile(profileId) + await runBeforeProfileRelaunch(options.onBeforeRelaunch) - scheduleProfileRelaunch('profile-switch') + scheduleProfileRelaunch('profile-switch', event.sender) return { status: 'relaunching' } } @@ -230,7 +217,7 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:transferProject', async ( - _event, + event, rawArgs: TransferOrcaProfileProjectArgs ): Promise => { const args = transferProjectArgsFromUnknown(rawArgs) @@ -241,19 +228,37 @@ export function registerOrcaProfileHandlers( if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { // Why: transfer before any relaunch side effect so a duplicate-target // or validation failure cannot strand the app in a quitting state. - await flushActiveProfileBeforeFileMutation(store) - const result = transferOrcaProfileProject(args, getProfileUserDataPath()) + const result = await transferActiveProfileProject( + args, + getProfileUserDataPath(), + store, + async () => { + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + scheduleProfileRelaunch('profile-transfer', event.sender) + } + ) if (result.status === 'transferred') { - store.freezeWrites() await runBeforeProfileRelaunch(options.onBeforeRelaunch) - setActiveOrcaProfile(args.targetProfileId) - scheduleProfileRelaunch('profile-transfer') + try { + setActiveOrcaProfile(args.targetProfileId) + } finally { + // The source has already changed and its writer cannot resume. + scheduleProfileRelaunch('profile-transfer', event.sender) + } return { ...result, willRelaunch: true } } return result } - await flushActiveProfileBeforeFileMutation(store) - return transferOrcaProfileProject(args, getProfileUserDataPath()) + if (args.sourceProfileId !== current.activeProfileId) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } + const maintenance = await flushActiveProfileBeforeFileMutation(store) + try { + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } finally { + await maintenance.resume() + } } ) diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 4669d7e5528..7a72c0d53b4 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' @@ -102,7 +103,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-proven-absent-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -113,7 +114,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -226,7 +227,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -237,7 +238,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -350,7 +351,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: {} } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -361,7 +362,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) let runtimeOwnsPane = true const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index b6aba0855e6..30ef457a81d 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -112,7 +113,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-ssh-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn((requestedHostId?: string) => { expect(requestedHostId).toBe(hostId) return session @@ -128,7 +129,7 @@ describe('registerPtyHandlers', () => { removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index e8e91ea7d45..a1ced7904f8 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -137,6 +137,7 @@ function installRestartHarness( session = next }), flushOrThrow: vi.fn(), + runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 743963d0189..c057d6ec5d2 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -272,7 +273,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-dead-persisted-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -293,7 +294,7 @@ describe('registerPtyHandlers', () => { ]), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -434,7 +435,7 @@ describe('registerPtyHandlers', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-unproven-owner' } } - const store = { + const store = withDurableRuntimeStore({ getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn((next) => { session = next @@ -445,7 +446,7 @@ describe('registerPtyHandlers', () => { getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) - } + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 56169791d23..70e358a8014 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -256,7 +256,10 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow(/ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED/) - expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { immediate: true }) + expect(remoteShutdown).toHaveBeenCalledWith(appPtyId, { + immediate: true, + expectedIncarnationId: incarnationId + }) expect(store.upsertSshRemotePtyLease).not.toHaveBeenCalled() expect(store.removeSshRemotePtyLease).not.toHaveBeenCalled() expect(openCodeClearPtyMock).toHaveBeenCalledWith(appPtyId) diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts index 64034d544c2..e03c29e79bc 100644 --- a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -49,8 +49,14 @@ export function beginPtySpawnForWorktree( } } catch (error) { // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. - finishes.toReversed().forEach((finish) => finish()) + for (let index = finishes.length - 1; index >= 0; index -= 1) { + finishes[index]!() + } throw error } - return () => finishes.toReversed().forEach((finish) => finish()) + return () => { + for (let index = finishes.length - 1; index >= 0; index -= 1) { + finishes[index]!() + } + } } diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 7aaa90bf9b8..5d9ad086920 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -1,29 +1,25 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' -import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { pendingByPaneKey, pendingPtyIdBySerializerGeneration, rendererSerializerReadiness } from '../pane/serializer-state' -import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size' -import { clearProviderPtyState } from '../provider/state-cleanup' +import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' -export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ - rendererPreSignaled: boolean - rendererAlreadyRegistered: boolean - committedSize: PtyGrid -}> { +export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { const args = ctx.args try { - ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.deps.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -40,6 +36,53 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ agentSessionOperationOutcome: 'unknown' as const }) } + const committedSize = resolveCommittedPtySize({ + result: ctx.result, + requested: { cols: args.cols, rows: args.rows }, + cachedBeforeAttach: ctx.sessionSizeBeforeAttach + }) + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + // Persist the binding before acknowledging spawn so the renderer debounce cannot orphan history. + if ( + ctx.deps.store && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + ctx.validatedLeafId !== null && + !ctx.stablePaneBindingPersisted + ) { + try { + const binding = { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + origin: spawnCommitBindingOrigin(ctx.result) + } + const persisted = args.connectionId + ? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + : await ctx.deps.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + await discardUnpersistedPtySpawn(ctx.provider, ctx.result, () => { + if (args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + }) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + } + return committedSize +} + +export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: PtyGrid): void { + const args = ctx.args ctx.spawnTiming.log(ctx.result.id, { daemon: ctx.isDaemonHostSpawn, reattach: ctx.result.isReattach ?? false @@ -59,7 +102,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } if (ctx.initiallyHidden) { - // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). + // Refresh the pre-spawn hidden mark only after this incarnation survives its save. ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { // Defense: never strand a mark on an id the provider renamed. @@ -69,88 +112,24 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') closeStartupQueryAuthorityForPty(ctx.result.id) } - // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. - if (ctx.nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(ctx.result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) - if (ctx.deps.store && args.connectionId) { - // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. - ctx.deps.store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) ctx.agentTeamsLeaderHandle = null } } - const committedSize = resolveCommittedPtySize({ - result: ctx.result, - requested: { cols: args.cols, rows: args.rows }, - cachedBeforeAttach: ctx.sessionSizeBeforeAttach - }) ptySizes.set(ctx.result.id, committedSize) if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { ptySizes.delete(ctx.effectiveSessionAppId) } - // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). - if ( - ctx.deps.store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - ctx.validatedLeafId !== null && - !ctx.stablePaneBindingPersisted - ) { - try { - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: ctx.validatedLeafId, - ptyId: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), - origin: spawnCommitBindingOrigin(ctx.result) - } - if (args.connectionId) { - ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) - } else { - ctx.deps.store.persistPtyBinding(binding) - } - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - if (!ctx.result.isReattach) { - try { - await ctx.provider.shutdown(ctx.result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(ctx.result.id) - deletePtyOwnership(ctx.result.id) - } - if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) { - ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - } - // Why here and not at the upsert: this path leases before it binds, so supersession fenced on the - // pane's binding still named the predecessor and bailed on every reconnect — one more reattachable - // lease, and one more `pty.attach`, per reconnect forever. Runs after whichever binding write this - // commit made, so the lease/binding order no longer decides. - if (ctx.deps.store && args.connectionId && ctx.validatedLeafId !== null) { - ctx.deps.store.supersedeSshRemotePtyLeasesForBoundPane(args.connectionId, ctx.validatedLeafId) - } - // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId ?? undefined + }) const rendererPreSignaled = ctx.validatedPaneKey ? pendingByPaneKey.has(ctx.validatedPaneKey) : false @@ -166,5 +145,4 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) } } - return { rendererPreSignaled, rendererAlreadyRegistered, committedSize } } diff --git a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts index 6266faf0c02..aa726fdc685 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts @@ -9,7 +9,7 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id' import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { createPtyIpcSpawnState } from './spawn-state' -import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -20,15 +20,7 @@ const TARGET = 'ssh-1' const WORKTREE = 'repo1::/worktree' const TAB = 'tab-1' -/** - * Drives the shipped IPC spawn commit rather than the store primitives it calls. - * - * The store-level suite could not catch this: it exercised bind-then-upsert, and this path does the - * opposite — it writes the lease row first so a force-quit in the renderer's debounce window cannot - * strand a running remote shell without one, then binds the pane. Supersession is fenced on the - * pane's binding, so under this real order it bailed on the predecessor every time and never re-ran, - * and each reconnect left one more reattachable lease for `reattachKnownPtys` to `pty.attach`. - */ +/** Exercises the shipped binding-then-publication order so reconnects retire earlier leases. */ async function commitSshSpawn( store: ReturnType, args: { relayPtyId: string; leafId: string } @@ -45,7 +37,7 @@ async function commitSshSpawn( const ctx = createPtyIpcSpawnState(deps, spawnArgs) ctx.result = { id: toAppSshPtyId(TARGET, args.relayPtyId) } ctx.validatedLeafId = args.leafId - await persistPtyIpcSpawnCommit(ctx) + publishPtyIpcSpawnCommit(ctx, await persistPtyIpcSpawnCommit(ctx)) } /** One pane's layout, so the two host partitions can be given different bindings for one leaf. */ @@ -150,7 +142,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => state: 'attached' }) expect(bulkReattachPtyIds(store)).toEqual(['pty2:aaa:1', 'pty2:bbb:1']) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, @@ -226,7 +218,7 @@ describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => }) // Production's writer for an SSH pane binding, and the whole point: it updates ONLY the host // partition, so `local` is left naming the predecessor until the renderer republishes. - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, ptyId: successor }, hostId ) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 90b700f24b2..77491c9fb11 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -4,13 +4,7 @@ import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { shouldSkipCodexHomeEnvForWindowsShell, codexReattachedHomeRouteField @@ -23,65 +17,22 @@ import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' -import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist' +import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/spawn-registration' import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' +import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' +import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } = - await persistPtyIpcSpawnCommit(ctx) - - // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). - // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. - if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const snapshotSeedSize = - typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number' - ? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows } - : undefined - if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) { - // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.snapshot, snapshotSeedSize, { - ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } - : {}), - ...(ctx.result.snapshotTerminalOwner - ? { terminalOwner: ctx.result.snapshotTerminalOwner } - : {}) - }) - } else if ( - ctx.result.coldRestore && - typeof ctx.result.coldRestore.scrollback === 'string' && - ctx.result.coldRestore.scrollback.length > 0 - ) { - const coldRestoreSeedSize = - typeof ctx.result.coldRestore.cols === 'number' && - typeof ctx.result.coldRestore.rows === 'number' - ? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows } - : undefined - ctx.deps.runtime.seedHeadlessTerminal( - ctx.result.id, - ctx.result.coldRestore.scrollback, - coldRestoreSeedSize, - { - cwd: ctx.result.coldRestore.cwd, - oscLinks: ctx.result.coldRestore.oscLinks, - preferProviderIfExisting: true - } - ) - } else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) { - // Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment. - ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay) - } + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) + if (ctx.nativeWindowsConptySpawn) { + markNativeWindowsConptyPty(ctx.result.id) } - // Why after the seed: a seed skips an existing model, and live bytes may have lazily created - // one at the 80x24 default before the spawn reply revealed the session's real grid. - reflowHeadlessTerminalToCommittedGrid({ - result: ctx.result, - committedSize, - reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind( - ctx.deps.runtime - ) - }) + // Seed before the first disk await so live output appends to the restored history. + seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.validatedPaneKey) + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + const committedSize = await persistPtyIpcSpawnCommit(ctx) if ( typeof args.worktreeId === 'string' && args.worktreeId.length > 0 && @@ -101,7 +52,9 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') { + throw error + } + }) + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = null + // The renderer drains this incarnation's buffered output and exit without publishing it live. + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + ctx.result + ) + } ctx.pendingRegistrationPtyId = null } else if (ctx.pendingRegistrationPtyId) { ctx.deps.runtime?.cancelPendingPtyRegistration?.( @@ -131,6 +99,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise void +): Promise { + if ( + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isCurrentPtyExit(result) + ) { + return + } + try { + await provider.shutdown(result.id, { + immediate: true, + ...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {}) + }) + } catch (error) { + console.warn('[pty] failed to clean up PTY after persistence failure:', error) + } + // A replacement may arrive while the execution host finishes shutting down the predecessor. + if (isCurrentPtyExit(result)) { + clearProviderPtyState(result.id) + ptyOwnership.delete(result.id) + onDiscarded?.() + } +} + +// Successful registration must not yield before the remaining spawn publication. +export function registerPersistedPtySpawn( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + ...args: Parameters +): Promise | undefined { + try { + runtime?.registerPty(...args) + } catch (error) { + const [ptyId, worktreeId, connectionId, binding] = args + // An exit during the binding write precedes runtime surface registration. + if ( + error instanceof Error && + error.message === 'agent_session_exited_during_start' && + runtime?.getPtyLivenessVerdict?.(ptyId)?.status === 'exited' && + binding + ) { + return retirePersistedStablePaneOwner( + store, + { ...binding, ptyId, persistedIncarnationId: binding.incarnationId }, + worktreeId, + connectionId + ).then(() => { + throw error + }) + } + throw error + } + return undefined +} diff --git a/src/main/ipc/pty/pane/spawn-telemetry.ts b/src/main/ipc/pty/pane/spawn-telemetry.ts new file mode 100644 index 00000000000..ccd3c3d582d --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-telemetry.ts @@ -0,0 +1,24 @@ +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import type { PtySpawnIpcArgs } from '../ipc/spawn-types' + +export function recordPtySpawnTelemetry( + telemetry: NonNullable +): void { + const agentKind = agentKindSchema.safeParse(telemetry.agent_kind) + const launchSource = launchSourceSchema.safeParse(telemetry.launch_source) + const requestKind = requestKindSchema.safeParse(telemetry.request_kind) + if (agentKind.success && launchSource.success && requestKind.success) { + track('agent_started', { + agent_kind: agentKind.data, + launch_source: launchSource.data, + request_kind: requestKind.data, + ...getCohortAtEmit() + }) + } +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 5d25e11f57c..83ebdd50364 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,3 +1,5 @@ +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../../../persistence/restoring-sessions/workspace-session-write-rollback' +import { cloneWorkspaceSessionState } from '../../../persistence/restoring-sessions/session-owner-fields' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' @@ -98,8 +100,7 @@ export function resolveStablePaneOwner( } const registeredConnectionId = ptyOwnership.get(ptyId) const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null - if (ownerConnectionId !== (connectionId ?? null)) { + if ((registeredConnectionId ?? parsedSshId?.connectionId ?? null) !== (connectionId ?? null)) { throw new Error('terminal_pane_owner_host_mismatch') } const runtimeIncarnationId = ptyIncarnationById.get(ptyId) @@ -121,41 +122,50 @@ export function resolveStablePaneOwner( } } -export function retirePersistedStablePaneOwner( +export async function retirePersistedStablePaneOwner( store: Store | undefined, owner: StablePaneOwner, worktreeId: string, connectionId: string | null | undefined -): boolean { +): Promise { if (!store) { return false } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + return store.runDurableMutation(() => { + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // A renderer removal may still be waiting for its debounced write. + return { value: true, persist: 'if-dirty' } + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return { value: false, persist: false } + } + const session = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId)) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return { value: false, persist: false } + } + store.setWorkspaceSession(retired, hostId) + const staged = cloneWorkspaceSessionState(store.getWorkspaceSession(hostId)) + return { + value: true, + rollback: () => { + const current = store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + store.setWorkspaceSession(rolledBack, hostId) + } + } + } }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true } export type StablePaneSpawnContext = { @@ -181,19 +191,19 @@ export function stablePanePersistenceFence( : undefined } -export function persistAdmittedStablePaneBinding(args: { +export async function persistAdmittedStablePaneBinding(args: { store: Store | undefined owner: StablePaneOwner | null result: PtySpawnResult worktreeId: string | undefined startupCwd: string | undefined connectionId: string | null | undefined -}): boolean { +}): Promise { const expectedBinding = stablePanePersistenceFence(args.owner) if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { return false } - const persisted = args.store.persistPtyBinding( + const persisted = await args.store.persistPtyBinding( { worktreeId: args.worktreeId, tabId: args.owner.tabId, @@ -270,7 +280,7 @@ export async function attachStablePaneOwner( ptyOwnership.delete(owner.ptyId) if ( args.worktreeId && - !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + !(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)) ) { throw new Error('terminal_pane_owner_changed') } diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 972f479b492..390bcdd451a 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -9,6 +9,7 @@ // same rule here, and pin that the marked half — the one refusal the relay backed with a pid probe // — still earns the certificate, so a genuinely dead PTY is not left `unverifiable` forever. import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { makePaneKey } from '../../../../shared/stable-pane-id' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' @@ -54,7 +55,8 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { } as unknown as WorkspaceSessionState return { read: () => session, - store: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence methods used by stable-pane retirement and exit bookkeeping. + store: withDurableRuntimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next @@ -75,7 +77,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { removeWorktreeMeta: () => {}, getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), getProjects: () => [] - } as unknown as Store + }) as unknown as Store } } diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index e68bcf6ec99..ce3c578cfa0 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' import { @@ -70,13 +71,14 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe } as unknown as WorkspaceSessionState return { read: () => session, - store: { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every persistence method used by stable-pane retirement. + store: withDurableRuntimeStore({ getWorkspaceSession: () => session, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, flushOrThrow: () => {} - } as unknown as Store + }) as unknown as Store } } diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.ts new file mode 100644 index 00000000000..cbe3d337ae3 --- /dev/null +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.ts @@ -0,0 +1,46 @@ +import type { PtySpawnResult } from '../../../providers/types' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state' + +export function seedHeadlessTerminalFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult, + paneKey: string | null +): void { + // A mounted renderer owns richer history than the provider snapshot. + if ( + !runtime || + (paneKey && pendingByPaneKey.has(paneKey)) || + (result.isReattach === true && rendererSerializerReadiness.has(result.id)) + ) { + return + } + if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { + const size = + typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' + ? { cols: result.snapshotCols, rows: result.snapshotRows } + : undefined + runtime.seedHeadlessTerminal(result.id, result.snapshot, size, { + ...(typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {}), + ...(result.snapshotTerminalOwner ? { terminalOwner: result.snapshotTerminalOwner } : {}) + }) + } else if ( + result.coldRestore && + typeof result.coldRestore.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ) { + const size = + typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number' + ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } + : undefined + runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, { + cwd: result.coldRestore.cwd, + oscLinks: result.coldRestore.oscLinks, + preferProviderIfExisting: true + }) + } else if (typeof result.replay === 'string' && result.replay.length > 0) { + runtime.seedHeadlessTerminal(result.id, result.replay) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 09d41460263..856039e7d5a 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,5 +1,5 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { commitRuntimePtySize } from './spawn-commit-pty-size' import { @@ -16,13 +16,8 @@ import { rendererSerializerReadiness } from '../pane/serializer-state' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' -import { track } from '../../../telemetry/client' -import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../../../shared/telemetry-events' +import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' +import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { @@ -31,17 +26,34 @@ import { } from '../../../runtime/terminal-model-query-authority' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' -import { clearProviderPtyState } from '../provider/state-cleanup' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { RuntimePtySpawnState } from './spawn-state' +import { + admitPtyReattachOwnership, + discardUnpersistedPtySpawn, + registerPersistedPtySpawn +} from '../pane/spawn-registration' export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args + admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(ctx.result.id) + } + // Seed before the first disk await so live output appends to the restored history. + seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.spawnIdentityPaneKey) + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) try { - ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + ctx.stablePaneBindingPersisted = await persistAdmittedStablePaneBinding({ store: ctx.hostSessionBinding?.store, owner: ctx.stablePaneOwner, result: ctx.result, @@ -63,12 +75,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // reply omits isReattach; derive it once so the size commit and the reservation agree. const adoptedResult = { ...ctx.result, isReattach: true } const owner = ctx.result.agentSessionEnsure.owner - ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) - ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } - ctx.deps.runtime?.registerPty( + const rejectedRegistration = registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, owner.surface.worktreeId, args.connectionId ?? null, @@ -80,6 +89,14 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}) } ) + if (rejectedRegistration) { + await rejectedRegistration + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } if (!args.connectionId) { ctx.deps.options?.onCodexHomePtySpawned?.({ id: ctx.result.id, @@ -108,81 +125,29 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionEnsure: ctx.result.agentSessionEnsure } } - ptyOwnership.set(ctx.result.id, args.connectionId ?? null) - if (ctx.result.incarnationId) { - ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) - } - // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: ctx.daemonShellOverride - }) - ) { - markNativeWindowsConptyPty(ctx.result.id) - } - const persistSshLease = (): void => - claimSshPaneLease({ - store: ctx.deps.store, - connectionId: args.connectionId, - ptyId: ctx.result.id, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId - }) - if (!ctx.hostSessionBinding) { - persistSshLease() - } - commitRuntimePtySize(ctx, ctx.result) - if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { - ptySizes.delete(ctx.effectiveSessionAppId) - } - recordCodexPaneAccountForSpawn({ - ptyId: ctx.result.id, - isDaemonHostSpawn: ctx.isDaemonHostSpawn, - isReattach: ctx.result.isReattach === true, - pinnedByResume: ctx.codexResumeHomeSelected, - launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: args.env, - target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() - }) if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { try { + const { store, worktreeId, tabId, leafId, expectedSourceBinding } = ctx.hostSessionBinding const binding = { - worktreeId: ctx.hostSessionBinding.worktreeId, - tabId: ctx.hostSessionBinding.tabId, - leafId: ctx.hostSessionBinding.leafId, + worktreeId, + tabId, + leafId, ptyId: ctx.result.id, hostAdmittedMembership: true, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), - ...(ctx.hostSessionBinding.expectedSourceBinding - ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } - : {}), - origin: spawnCommitBindingOrigin(ctx.result, ctx.hostSessionBinding.expectedSourceBinding) + ...(expectedSourceBinding ? { expectedSourceBinding } : {}), + origin: spawnCommitBindingOrigin(ctx.result, expectedSourceBinding) } const persisted = args.connectionId - ? ctx.hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : ctx.hostSessionBinding.store.persistPtyBinding(binding) + ? await store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + : await store.persistPtyBinding(binding) if (persisted === false) { throw new Error('terminal_split_source_not_found') } } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - if (!ctx.result.isReattach) { - deletePtyOwnership(ctx.result.id) - try { - await ctx.provider.shutdown(ctx.result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(ctx.result.id) - } + await discardUnpersistedPtySpawn(ctx.provider, ctx.result) if (err instanceof Error && err.message === 'terminal_split_source_not_found') { throw err } @@ -190,13 +155,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionOperationOutcome: 'unknown' as const }) } - persistSshLease() - } - if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { - ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } if (args.worktreeId) { - ctx.deps.runtime?.registerPty( + const rejectedRegistration = registerPersistedPtySpawn( + ctx.deps.runtime, + ctx.hostSessionBinding?.store ?? ctx.deps.store, ctx.result.id, args.worktreeId, args.connectionId ?? null, @@ -217,10 +180,42 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) : undefined ) + if (rejectedRegistration) { + await rejectedRegistration + } } else { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId + }) + commitRuntimePtySize(ctx, ctx.result) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). @@ -231,17 +226,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { markClaudePtySpawned(ctx.result.id) } if (args.telemetry && !ctx.stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } + recordPtySpawnTelemetry(args.telemetry) } // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 9fdfd8214b2..855401f96c1 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -31,17 +31,22 @@ export function registerSessionHandlers(store: Store): void { ipcMain.handle('session:flush', () => { // Why: durable lifecycle RPCs must propagate disk failures instead of // returning success through Store.flush(), which intentionally only logs. - store.flushOrThrow() + return store.flushPendingOrThrowAsync() }) - // Synchronous variant for the renderer's beforeunload handler. - // sendSync blocks the renderer until this returns, guaranteeing the - // data (including terminal scrollback buffers) is persisted to disk - // before the window closes — regardless of before-quit ordering. + // Older renderers block on the reply; main remains free to await the writer. ipcMain.on('session:set-sync', (event, args: WorkspaceSessionState, hostId?: string | null) => { - store.setWorkspaceSession(args, hostId) - store.flush() - event.returnValue = true + void (async () => { + try { + store.setWorkspaceSession(args, hostId) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + } catch (error) { + console.error('[persistence] Failed to flush legacy session checkpoint:', error) + } finally { + // This legacy response has always been best effort, including on disk errors. + event.returnValue = true + } + })() }) ipcMain.on( diff --git a/src/main/orca-profiles/profile-active-transfer-worker.test.ts b/src/main/orca-profiles/profile-active-transfer-worker.test.ts new file mode 100644 index 00000000000..2e19eaf73f0 --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer-worker.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import { createWorkerMaintenanceFixture } from '../persistence/loading-store/profile-state-maintenance-fixture' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { transferActiveProfileProject } from './profile-active-transfer' +import * as domainState from './profile-project-domain-state' +import { + profileHasPendingProjectMove, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' +import { readProfileStateWithRevision } from './profile-project-state-file' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-worker-profile-transfer-')) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + const current = await createWorkerMaintenanceFixture({ + directory: join(root, 'profiles', 'source'), + profileId: 'source', + cleanupRoot: root + }) + const target = new ProfileStateSqliteAuthority( + join(root, 'profiles', 'target', 'profile-state.db'), + 'target' + ) + try { + target.writeSerializedState(Buffer.from(JSON.stringify(getDefaultPersistedState(root)))) + } finally { + target.close() + } + const args = { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: 'repo-remote', + mode: 'move' + } as const + const read = (id: string) => readProfileStateWithRevision(id, root) + return { ...current, root, args, read } +} + +describe('active profile transfers with the live writer', () => { + it('resumes the exact source revision after a validation failure', async () => { + const { store, root, args, read } = await fixture() + const reopen = vi.fn(async () => {}) + await expect( + transferActiveProfileProject({ ...args, repoId: 'missing' }, root, store, reopen) + ).rejects.toThrow('unknown_source_repo') + expect(reopen).not.toHaveBeenCalled() + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(read('source').state.settings.theme).toBe('dark') + }) + + it('keeps the source frozen after moving a remote project and its persisted state', async () => { + const { store, root, args, read } = await fixture() + const result = await transferActiveProfileProject(args, root, store, async () => {}) + expect(result.status).toBe('transferred') + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false) + expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + const source = read('source') + store.updateSettings({ theme: 'dark' }) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(read('source')).toEqual(source) + }) + + it('resumes when a copy makes a later move a duplicate', async () => { + const { store, root, args, read } = await fixture() + await transferActiveProfileProject({ ...args, mode: 'copy' }, root, store, async () => {}) + const result = await transferActiveProfileProject(args, root, store, async () => {}) + expect(result.status).toBe('duplicate-target') + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(read('source').state.settings.theme).toBe('dark') + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + }) + + it('leaves an interrupted move frozen until journal recovery runs with the writer closed', async () => { + const { store, root, args, read } = await fixture() + const original = domainState.writeProfileProjectDomainChanges + const fault = vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .mockImplementation((id, ...rest) => { + if (id === args.sourceProfileId) { + throw new Error('source commit interrupted') + } + return original(id, ...rest) + }) + const reopen = vi.fn(async () => {}) + await expect(transferActiveProfileProject(args, root, store, reopen)).rejects.toThrow( + 'source commit interrupted' + ) + expect(reopen).toHaveBeenCalledOnce() + expect(profileHasPendingProjectMove('source', root)).toBe(true) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + fault.mockRestore() + expect(recoverPendingProfileProjectMoves(root)).toBe(1) + expect(read('source').state.repos.some((repo) => repo.id === args.repoId)).toBe(false) + expect(read('target').state.repos.some((repo) => repo.id === args.repoId)).toBe(true) + }) +}) diff --git a/src/main/orca-profiles/profile-active-transfer.test.ts b/src/main/orca-profiles/profile-active-transfer.test.ts new file mode 100644 index 00000000000..0cfd80d7eef --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.test.ts @@ -0,0 +1,232 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import * as stateFiles from './profile-project-state-file' +import * as domainState from './profile-project-domain-state' +import * as moveIntents from './profile-project-move-intent' +import { transferActiveProfileProject } from './profile-active-transfer' +import { transferOrcaProfileProject } from './profile-project-transfer' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('../persistence/loading-store/store') +const stores: InstanceType[] = [] +let directory: string +const args = { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: 'repo-1', + mode: 'move' +} as const + +function snapshot(profileId: string) { + return stateFiles.readProfileStateWithRevision(profileId, directory) +} + +function openStore() { + const profileDirectory = join(directory, 'profiles', 'source') + const store = new Store({ + dataFile: join(profileDirectory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(profileDirectory, 'profile-state.db'), + 'source' + ) + }) + stores.push(store) + store.flushOrThrow() + return store +} + +function interruptSourceCommit() { + const originalWrite = domainState.writeProfileProjectDomainChanges + return vi + .spyOn(domainState, 'writeProfileProjectDomainChanges') + .mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-active-profile-transfer-')) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) + writeFileSync( + join(directory, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + for (const profileId of ['source', 'target']) { + const profileDirectory = join(directory, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + const db = openProfileStateDatabase(join(profileDirectory, 'profile-state.db'), profileId).db + try { + importProfileStateJson( + db, + JSON.stringify({ + ...getDefaultPersistedState('/home/test'), + repos: + profileId === 'source' + ? [{ id: 'repo-1', path: '/projects/folder', kind: 'folder', addedAt: 1 }] + : [] + }) + ) + } finally { + db.close() + } + } +}) + +afterEach(() => { + for (const store of stores.splice(0)) { + store.freezeWrites() + } + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('active profile transfer recovery', () => { + it.each(['source commit', 'intent cleanup'] as const)( + 'fences an existing SQLite Store after interrupted %s until recovery and reopen', + async (failure) => { + const store = openStore() + const before = snapshot('source') + const interrupted = + failure === 'source commit' + ? interruptSourceCommit() + : vi.spyOn(moveIntents, 'removeProfileProjectMoveIntent').mockImplementation(() => { + throw new Error('intent cleanup interrupted') + }) + const reopen = vi.fn(async () => { + const retained = snapshot('source') + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(retained) + }) + + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + `${failure} interrupted` + ) + expect(reopen).toHaveBeenCalledOnce() + expect(snapshot('source').revision).toBe( + (before.revision ?? 0) + (failure === 'source commit' ? 0 : 1) + ) + expect(snapshot('target').state.repos).toHaveLength(1) + interrupted.mockRestore() + + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0) + expect(snapshot('source').state.repos).toHaveLength(0) + expect(snapshot('target').state.repos).toHaveLength(1) + const reloaded = openStore() + reloaded.updateSettings({ theme: 'light' }) + reloaded.flushOrThrow() + expect(snapshot('source').state.settings.theme).toBe('light') + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(0) + } + ) + + it('leaves an unchanged SQLite Store writable after validation fails', async () => { + const store = openStore() + const before = snapshot('source') + const reopen = vi.fn(async () => {}) + await expect( + transferActiveProfileProject({ ...args, repoId: 'missing' }, directory, store, reopen) + ).rejects.toThrow('unknown_source_repo') + expect(reopen).not.toHaveBeenCalled() + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(snapshot('source').revision).toBe((before.revision ?? 0) + 1) + expect(snapshot('source').state.settings.theme).toBe('light') + }) + + it('keeps writes fenced when reopening after a partial transfer fails', async () => { + const store = openStore() + const before = snapshot('source') + const interrupted = interruptSourceCommit() + await expect( + transferActiveProfileProject(args, directory, store, async () => { + throw new Error('reopen failed') + }) + ).rejects.toThrow('reopen failed') + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(before) + interrupted.mockRestore() + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + }) + + it('reopens before an outstanding move can change the active Store behind its revision', async () => { + const store = openStore() + const interrupted = interruptSourceCommit() + expect(() => transferOrcaProfileProject(args, directory)).toThrow('source commit interrupted') + interrupted.mockRestore() + const reopen = vi.fn(async () => { + expect(moveIntents.recoverPendingProfileProjectMoves(directory)).toBe(1) + }) + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + 'active_source_orca_profile_move_requires_recovery' + ) + const recovered = snapshot('source') + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(recovered) + expect(reopen).toHaveBeenCalledOnce() + }) + + it('keeps the Store frozen when an unreadable intent cannot identify its participants', async () => { + const store = openStore() + const before = snapshot('source') + const intentDirectory = join(directory, 'profile-move-intents') + mkdirSync(intentDirectory) + writeFileSync(join(intentDirectory, '11111111-1111-4111-8111-111111111111.json'), '{') + const reopen = vi.fn(async () => { + moveIntents.recoverPendingProfileProjectMoves(directory) + }) + await expect(transferActiveProfileProject(args, directory, store, reopen)).rejects.toThrow( + 'Profile move intent is unreadable' + ) + store.updateSettings({ theme: 'light' }) + expect(() => store.flushOrThrow()).toThrow('final persistence') + expect(snapshot('source')).toEqual(before) + expect(reopen).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orca-profiles/profile-active-transfer.ts b/src/main/orca-profiles/profile-active-transfer.ts new file mode 100644 index 00000000000..53ba8481da5 --- /dev/null +++ b/src/main/orca-profiles/profile-active-transfer.ts @@ -0,0 +1,39 @@ +import type { + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import type { Store } from '../persistence/loading-store/store' +import { transferOrcaProfileProject } from './profile-project-transfer' +import { hasOrcaProfileStateDatabase } from './profile-storage-paths' +import { profileHasPendingProjectMove } from './profile-project-move-intent' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' + +/** Keep the active Store stopped until file mutation either succeeds or proves unchanged. */ +export async function transferActiveProfileProject( + args: TransferOrcaProfileProjectArgs, + userDataPath: string, + store: Pick, + reopenSource: () => Promise +): Promise { + const hadDatabase = hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath) + const pendingMove = profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + const maintenance = await flushActiveProfileBeforeFileMutation(store, { flush: !pendingMove }) + let result: TransferOrcaProfileProjectResult + try { + if (profileHasPendingProjectMove(args.sourceProfileId, userDataPath)) { + throw new Error('active_source_orca_profile_move_requires_recovery') + } + result = transferOrcaProfileProject(args, userDataPath) + } catch (error) { + const needsRecovery = + (!hadDatabase && hasOrcaProfileStateDatabase(args.sourceProfileId, userDataPath)) || + profileHasPendingProjectMove(args.sourceProfileId, userDataPath) + // Further writes would invalidate a retained move's recovery revision. + await (needsRecovery ? reopenSource() : maintenance.resume()) + throw error + } + if (result.status !== 'transferred' || args.mode !== 'move') { + await maintenance.resume() + } + return result +} diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts index 7a28783e9a9..3db3ccb92e9 100644 --- a/src/main/orca-profiles/profile-cloud-auth-status.test.ts +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -38,6 +38,7 @@ function activeProfile(linked: boolean): ActiveOrcaProfileState { profile, index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] }, dataFile: '', + stateDatabaseFile: '', profileDirectory: '' } } diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index 1d02c4d7dcc..09f9ea5b3a0 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' import { createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, @@ -66,6 +67,9 @@ describe('profile index store', () => { expect(activeProfile.dataFile).toBe( join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') ) + expect(activeProfile.stateDatabaseFile).toBe( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'profile-state.db') + ) expect(readJson(activeProfile.dataFile)).toEqual(legacyState) expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup) expect( @@ -115,9 +119,59 @@ describe('profile index store', () => { expect(activeProfile.profile.id).toBe(profileId) expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json')) + expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db')) expect(readJson(activeProfile.dataFile)).toEqual(profileData) }) + it('does not copy legacy JSON into a database-only default profile', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ settings: { theme: 'legacy' } }), + 'utf-8' + ) + const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + mkdirSync(profileDirectory, { recursive: true }) + const database = openProfileStateDatabase( + join(profileDirectory, 'profile-state.db'), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + database.db.close() + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.stateDatabaseFile).toBe(join(profileDirectory, 'profile-state.db')) + expect(existsSync(activeProfile.dataFile)).toBe(false) + expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy') + }) + + it.each([ + 'orca-data.json.sqlite-export.1.json', + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db', + 'profile-state.db-wal', + 'profile-state.db-shm', + 'profile-state.db-journal' + ])('does not seed a stale mirror when %s exists without the database', async (artifact) => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ settings: { theme: 'legacy' } }), + 'utf-8' + ) + const profileDirectory = join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, artifact), + JSON.stringify({ settings: { theme: 'migrated' } }), + 'utf-8' + ) + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(existsSync(activeProfile.dataFile)).toBe(false) + expect(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')).toContain('legacy') + }) + it('creates an empty local profile without copying legacy state into it', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 7897e299a21..9ddc52e1dae 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -22,23 +22,24 @@ import { type OrcaProfileSummary } from '../../shared/orca-profiles' import { - getOrcaProfileBrowserSessionMetaFile, getOrcaProfileDataFile, getOrcaProfileDirectory, getOrcaProfileIndexPath, - getProfileUserDataPath, - LEGACY_BACKUP_COUNT, - legacyBackupPath, - legacyBrowserSessionMetaPath, - legacyDataFilePath, - profileBackupPath + getOrcaProfileStateDatabaseFile, + hasOrcaProfileStateDatabase, + getProfileUserDataPath } from './profile-storage-paths' +import { copyLegacyStateToProfile } from './profile-legacy-state-import' +import { profileStateJsonExportPaths } from '../persistence/profile-state/profile-state-export-path' +import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path' export { getOrcaProfileBrowserSessionMetaFile, getOrcaProfileDataFile, getOrcaProfileDirectory, getOrcaProfileIndexPath, + getOrcaProfileStateDatabaseFile, + hasOrcaProfileStateDatabase, getOrcaProfilesDirectory, initOrcaProfilePaths } from './profile-storage-paths' @@ -47,6 +48,7 @@ export type ActiveOrcaProfileState = { index: OrcaProfileIndex profile: OrcaProfileSummary dataFile: string + stateDatabaseFile: string profileDirectory: string } @@ -136,30 +138,6 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v bestEffortFsyncDirectorySync(dirname(indexPath)) } -function copyIfPresent(source: string, target: string): void { - if (!existsSync(source) || existsSync(target)) { - return - } - mkdirSync(dirname(target), { recursive: true }) - // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that - // the exists() guard above would then treat as a completed migration. - const tmpTarget = `${target}.tmp` - copyFileSync(source, tmpTarget) - renameSync(tmpTarget, target) -} - -function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { - const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) - copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) - copyIfPresent( - legacyBrowserSessionMetaPath(userDataPath), - getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) - ) - for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { - copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) - } -} - // Why: a brand-new profile has no data file, which the telemetry cohort // migration reads as a fresh install and defaults to opted-in. Copying the // active profile's consent block keeps an opted-out user opted out (and keeps @@ -230,7 +208,22 @@ export function ensureActiveOrcaProfile( const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath) mkdirSync(profileDirectory, { recursive: true }) - if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + const profileDatabaseFile = getOrcaProfileStateDatabaseFile(activeProfile.id, userDataPath) + const profileDataFile = getOrcaProfileDataFile(activeProfile.id, userDataPath) + let hasRetainedProfileStateExport = false + try { + hasRetainedProfileStateExport = + profileStateJsonExportPaths(profileDataFile).length > 0 || + profileStateDatabaseBackups(profileDatabaseFile).length > 0 + } catch { + // An unreadable profile directory must never trigger a fallback copy of legacy state. + hasRetainedProfileStateExport = true + } + if ( + activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID && + !hasOrcaProfileStateDatabase(activeProfile.id, userDataPath) && + !hasRetainedProfileStateExport + ) { copyLegacyStateToProfile(userDataPath, activeProfile.id) } @@ -241,7 +234,8 @@ export function ensureActiveOrcaProfile( return { index, profile: activeProfile, - dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath), + dataFile: profileDataFile, + stateDatabaseFile: profileDatabaseFile, profileDirectory } } diff --git a/src/main/orca-profiles/profile-legacy-state-import.ts b/src/main/orca-profiles/profile-legacy-state-import.ts new file mode 100644 index 00000000000..03500f74e26 --- /dev/null +++ b/src/main/orca-profiles/profile-legacy-state-import.ts @@ -0,0 +1,35 @@ +import { copyFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' +import { dirname } from 'node:path' +import { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + LEGACY_BACKUP_COUNT, + legacyBackupPath, + legacyBrowserSessionMetaPath, + legacyDataFilePath, + profileBackupPath +} from './profile-storage-paths' + +function copyIfPresent(source: string, target: string): void { + if (!existsSync(source) || existsSync(target)) { + return + } + mkdirSync(dirname(target), { recursive: true }) + // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that + // the exists() guard above would then treat as a completed migration. + const tmpTarget = `${target}.tmp` + copyFileSync(source, tmpTarget) + renameSync(tmpTarget, target) +} + +export function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { + const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) + copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) + copyIfPresent( + legacyBrowserSessionMetaPath(userDataPath), + getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) + ) + for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { + copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) + } +} diff --git a/src/main/orca-profiles/profile-persistence-deadline.test.ts b/src/main/orca-profiles/profile-persistence-deadline.test.ts new file mode 100644 index 00000000000..87e0507c507 --- /dev/null +++ b/src/main/orca-profiles/profile-persistence-deadline.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' +import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' +import { flushActiveProfileBeforeFileMutation } from './profile-persistence-deadline' + +afterEach(() => vi.useRealTimers()) + +describe('profile persistence deadline', () => { + it('allows the writer request deadline to finish before imposing maintenance cancellation', async () => { + vi.useFakeTimers() + const result = Promise.withResolvers() + const beginProfileMaintenance = vi.fn( + (_options?: ProfileStateMaintenanceOptions) => result.promise + ) + const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance }) + await vi.advanceTimersByTimeAsync(30_000) + expect(beginProfileMaintenance.mock.calls[0]?.[0]?.signal?.aborted).not.toBe(true) + const handle = { resume: vi.fn(async () => {}) } + result.resolve(handle) + await expect(pending).resolves.toBe(handle) + expect(handle.resume).not.toHaveBeenCalled() + }) + + it('resumes a clean pause that finishes after the caller times out', async () => { + vi.useFakeTimers() + const result = Promise.withResolvers() + const beginProfileMaintenance = vi.fn(() => result.promise) + const pending = flushActiveProfileBeforeFileMutation({ beginProfileMaintenance }) + const rejected = expect(pending).rejects.toThrow('orca_profile_persistence_timeout') + await vi.advanceTimersByTimeAsync(60_000) + await rejected + const handle = { resume: vi.fn(async () => {}) } + result.resolve(handle) + await vi.advanceTimersByTimeAsync(0) + expect(handle.resume).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orca-profiles/profile-persistence-deadline.ts b/src/main/orca-profiles/profile-persistence-deadline.ts index 084a53c40fe..73280914e4a 100644 --- a/src/main/orca-profiles/profile-persistence-deadline.ts +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -1,8 +1,33 @@ import type { Store } from '../persistence' +import type { ProfileStateMaintenance } from '../persistence/loading-store/profile-state-authority' +import type { ProfileStateMaintenanceOptions } from '../persistence/loading-store/profile-state-maintenance' -const PROFILE_PERSISTENCE_TIMEOUT_MS = 20_000 +const PROFILE_PERSISTENCE_TIMEOUT_MS = 60_000 -export async function flushActiveProfileBeforeFileMutation(store: Store): Promise { +export async function flushActiveProfileBeforeFileMutation( + store: Pick, + options: Pick = {} +): Promise { + return withinProfilePersistenceDeadline((signal) => + store.beginProfileMaintenance({ ...options, signal }).then(async (handle) => { + if (signal.aborted && options.flush !== false) { + await handle.resume() + throw new Error('orca_profile_persistence_timeout') + } + return handle + }) + ) +} + +export function flushActiveProfileBeforeRelaunch( + store: Pick +): Promise { + return withinProfilePersistenceDeadline((signal) => store.flushPendingOrThrowAsync({ signal })) +} + +async function withinProfilePersistenceDeadline( + operation: (signal: AbortSignal) => Promise +): Promise { const controller = new AbortController() let timeout: ReturnType | null = null const deadline = new Promise((_resolve, reject) => { @@ -12,7 +37,7 @@ export async function flushActiveProfileBeforeFileMutation(store: Store): Promis }, PROFILE_PERSISTENCE_TIMEOUT_MS) }) try { - await Promise.race([store.flushPendingOrThrowAsync({ signal: controller.signal }), deadline]) + return await Promise.race([operation(controller.signal), deadline]) } finally { if (timeout) { clearTimeout(timeout) diff --git a/src/main/orca-profiles/profile-project-domain-changes.ts b/src/main/orca-profiles/profile-project-domain-changes.ts new file mode 100644 index 00000000000..30f5aef805e --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-changes.ts @@ -0,0 +1,144 @@ +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + isRecord, + type ProfileStateParsedDocument +} from '../persistence/profile-state/profile-state-document-validation' +import { prepareProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-write-validation' +import type { ProfileStateDomainMutation } from '../persistence/profile-state/profile-state-domain-writes' +import type { PersistedState } from '../../shared/persisted-state-types' + +export type ProfileProjectDomainDigest = { domain: string; hash: string } + +export type ProfileProjectDomainChanges = { + expectedRevision: number + before: ProfileProjectDomainDigest[] + afterHash: string + replacements: { domain: string; payload: string | null }[] +} + +export function profileProjectDomainFingerprint( + domains: readonly ProfileProjectDomainDigest[] +): string { + const pairs = domains.map(({ domain, hash }) => [domain, hash]) + pairs.sort(([left = ''], [right = '']) => (left < right ? -1 : left > right ? 1 : 0)) + return hashProfileStateJson(`orca-profile-move-domains-v2:${JSON.stringify(pairs)}`) +} + +export function profileProjectDomainDigests( + documents: readonly ProfileStateParsedDocument[] +): ProfileProjectDomainDigest[] { + return documents.map(({ domain, contentHash }) => ({ domain, hash: contentHash })) +} + +export function prepareProfileProjectDomainChanges( + revision: number, + documents: readonly ProfileStateParsedDocument[], + state: PersistedState +): ProfileProjectDomainChanges { + const originals = new Map(documents.map((document) => [document.domain, document])) + const replacements: ProfileProjectDomainChanges['replacements'] = [] + for (const [domain, value] of Object.entries(state)) { + const original = originals.get(domain) + // Transfer projections retain unchanged values; do not serialize unrelated history/output. + if (original && Object.is(original.value, value)) { + continue + } + const payload = JSON.stringify(value) ?? null + if ( + payload === null + ? original !== undefined + : hashProfileStateJson(payload) !== original?.contentHash + ) { + replacements.push({ domain, payload }) + } + } + for (const domain of originals.keys()) { + if (!Object.hasOwn(state, domain)) { + replacements.push({ domain, payload: null }) + } + } + const before = profileProjectDomainDigests(documents) + return { + expectedRevision: revision, + before, + afterHash: profileProjectDomainFingerprint(applyDomainDigests(before, replacements)), + replacements + } +} + +function applyDomainDigests( + before: readonly ProfileProjectDomainDigest[], + replacements: readonly ProfileStateDomainMutation[] +): ProfileProjectDomainDigest[] { + const digests = new Map(before.map(({ domain, hash }) => [domain, hash])) + for (const { domain, payload } of replacements) { + if (payload === null) { + digests.delete(domain) + } else { + digests.set(domain, hashProfileStateJson(payload)) + } + } + return [...digests].map(([domain, hash]) => ({ domain, hash })) +} + +export function validateProfileProjectDomainChanges( + value: unknown +): asserts value is ProfileProjectDomainChanges { + if ( + !isRecord(value) || + typeof value.expectedRevision !== 'number' || + !Number.isSafeInteger(value.expectedRevision) || + value.expectedRevision < 0 || + !Number.isSafeInteger(value.expectedRevision + 1) || + !Array.isArray(value.before) || + !Array.isArray(value.replacements) || + value.replacements.length === 0 || + !isHash(value.afterHash) + ) { + throw new Error('Profile move domain changes are malformed') + } + const before: ProfileProjectDomainDigest[] = [] + const domains = new Set() + for (const digest of value.before) { + if ( + !isRecord(digest) || + typeof digest.domain !== 'string' || + !isHash(digest.hash) || + domains.has(digest.domain) + ) { + throw new Error('Profile move domain manifest is malformed') + } + domains.add(digest.domain) + before.push({ domain: digest.domain, hash: digest.hash }) + } + const replacements: ProfileStateDomainMutation[] = [] + domains.clear() + for (const replacement of value.replacements) { + if ( + !isRecord(replacement) || + !isDomain(replacement.domain) || + (replacement.payload !== null && typeof replacement.payload !== 'string') || + domains.has(replacement.domain) + ) { + throw new Error('Profile move domain replacement is malformed') + } + domains.add(replacement.domain) + const mutation = { domain: replacement.domain, payload: replacement.payload } + prepareProfileStateDomainMutation(mutation) + replacements.push(mutation) + } + if ( + profileProjectDomainFingerprint(applyDomainDigests(before, replacements)) !== value.afterHash || + profileProjectDomainFingerprint(before) === value.afterHash + ) { + throw new Error('Profile move domain changes do not match their fingerprint') + } +} + +function isDomain(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} diff --git a/src/main/orca-profiles/profile-project-domain-move-intent.ts b/src/main/orca-profiles/profile-project-domain-move-intent.ts new file mode 100644 index 00000000000..3441b2d8f3a --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-move-intent.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto' +import type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +export type { ProfileProjectDomainMoveIntent } from './profile-project-move-record' +import { + profileProjectDomainDigests, + profileProjectDomainFingerprint, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' +import { + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' + +export function createProfileProjectDomainMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +}): ProfileProjectDomainMoveIntent { + return { version: 2, id: randomUUID(), ...args } +} + +export function readProfileProjectDomainMoveState( + userDataPath: string, + intent: ProfileProjectDomainMoveIntent +): { sourceBefore: boolean; sourceAfter: boolean; targetBefore: boolean; targetAfter: boolean } { + const source = readProfileProjectTransferState(intent.sourceProfileId, userDataPath) + const target = readProfileProjectTransferState(intent.targetProfileId, userDataPath) + if (source.documents === undefined || target.documents === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + return { + sourceBefore: matches( + source, + intent.source.expectedRevision, + profileProjectDomainFingerprint(intent.source.before) + ), + targetBefore: matches( + target, + intent.target.expectedRevision, + profileProjectDomainFingerprint(intent.target.before) + ), + sourceAfter: matches(source, intent.source.expectedRevision + 1, intent.source.afterHash), + targetAfter: matches(target, intent.target.expectedRevision + 1, intent.target.afterHash) + } +} + +function matches( + snapshot: ReadProfileProjectTransferResult, + revision: number, + hash: string +): boolean { + return ( + snapshot.revision === revision && + snapshot.documents !== undefined && + profileProjectDomainFingerprint(profileProjectDomainDigests(snapshot.documents)) === hash + ) +} diff --git a/src/main/orca-profiles/profile-project-domain-state.ts b/src/main/orca-profiles/profile-project-domain-state.ts new file mode 100644 index 00000000000..e788b4b0def --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-state.ts @@ -0,0 +1,84 @@ +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { + readProfileStateDocuments, + readProfileStateRevision +} from '../persistence/profile-state/profile-state-documents' +import type { ProfileStateParsedDocument } from '../persistence/profile-state/profile-state-document-validation' +import { writeProfileStateDomains } from '../persistence/profile-state/profile-state-domain-writes' +import { withProfileStateReadSnapshot } from '../persistence/profile-state/profile-state-read-snapshot' +import { getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import { + normalizeProfileProjectState, + profileStateStorage, + readProfileStateWithRevision, + type ReadProfileStateResult +} from './profile-project-state-file' +import { + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' + +export type ReadProfileProjectTransferResult = ReadProfileStateResult & { + documents?: readonly ProfileStateParsedDocument[] +} + +/** Keep checked domain values for transfer without joining and reparsing the complete profile. */ +export function readProfileProjectTransferState( + profileId: string, + userDataPath: string +): ReadProfileProjectTransferResult { + if (profileStateStorage(profileId, userDataPath) === 'json') { + return readProfileStateWithRevision(profileId, userDataPath) + } + const opened = openProfileStateDatabaseReadOnly( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + try { + return withProfileStateReadSnapshot(opened.db, () => { + const revision = readProfileStateRevision(opened.db) + const documents = readProfileStateDocuments(opened.db, { + profileRevision: revision, + representation: 'parsed' + }) + return { + revision, + documents, + state: normalizeProfileProjectState( + Object.fromEntries(documents.map(({ domain, value }) => [domain, value])) + ) + } + }) + } finally { + opened.db.close() + } +} + +export function writeProfileProjectDomainChanges( + profileId: string, + userDataPath: string, + changes: ProfileProjectDomainChanges +): void { + validateProfileProjectDomainChanges(changes) + if (profileStateStorage(profileId, userDataPath) !== 'sqlite') { + throw new Error('Profile domain transfer requires an established SQLite participant') + } + const opened = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + ) + try { + const result = writeProfileStateDomains(opened.db, { + expectedRevision: changes.expectedRevision, + replacements: changes.replacements.map(({ domain, payload }) => ({ domain, payload })) + }) + if (!result.changed || result.revision !== changes.expectedRevision + 1) { + throw new Error('Profile domain transfer did not commit its expected revision') + } + } finally { + opened.db.close() + } +} diff --git a/src/main/orca-profiles/profile-project-domain-transfer.test.ts b/src/main/orca-profiles/profile-project-domain-transfer.test.ts new file mode 100644 index 00000000000..00ceb64455e --- /dev/null +++ b/src/main/orca-profiles/profile-project-domain-transfer.test.ts @@ -0,0 +1,546 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSync } from 'esbuild' +import { runProcess } from '../../shared/child-process/run-process' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import type { Repo } from '../../shared/repo-types' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { writeProfileStateDomain } from '../persistence/profile-state/profile-state-domain-writes' +import { + prepareProfileProjectDomainChanges, + profileProjectDomainFingerprint, + validateProfileProjectDomainChanges +} from './profile-project-domain-changes' +import * as domainState from './profile-project-domain-state' +import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves +} from './profile-project-move-intent' +import { normalizeProfileProjectState } from './profile-project-state-file' +import { removeSourceRepo } from './profile-project-source-removal' +import { + applyPayloadToTarget, + createTargetRepo, + createTransferPayload +} from './profile-project-transfer-payload' +import { transferOrcaProfileProject } from './profile-project-transfer' + +let root: string +let crashRoot: string +let crashBundle: string +let crashScript: string +const repo: Repo = { + id: 'repo-1', + path: '/project', + displayName: 'Project', + badgeColor: 'neutral', + addedAt: 1, + kind: 'git', + connectionId: null +} + +function dbPath(id: string): string { + return join(root, 'profiles', id, 'profile-state.db') +} + +function withDatabase( + id: string, + action: (db: ReturnType['db']) => T +): T { + const opened = openProfileStateDatabase(dbPath(id), id) + try { + return action(opened.db) + } finally { + opened.db.close() + } +} + +function seed(id: string, repos: Repo[] = []): void { + mkdirSync(join(root, 'profiles', id), { recursive: true }) + withDatabase(id, (db) => + importProfileStateJson( + db, + JSON.stringify({ + futureOpaque: { z: ['\ud800', null, id], a: 'x'.repeat(100_000) }, + ['']: { keep: true }, + ['__proto__']: { inert: true }, + settings: { opencodeSessionCookie: 'enc:v1:sealed-inactive', unknownSetting: [2, 1] }, + repos, + projects: null, + projectHostSetups: null, + workspaceSessionsByHostId: null, + automationRuns: [], + futureNull: null, + futureDelete: { remove: true } + }) + ) + ) +} + +function raw(id: string) { + return withDatabase(id, (db) => readProfileStateSnapshot(db)) +} + +function transfer(mode: 'copy' | 'move' = 'move') { + return transferOrcaProfileProject( + { sourceProfileId: 'source', targetProfileId: 'target', repoId: repo.id, mode }, + root + ) +} + +function frozen(value: T): T { + if (value !== null && typeof value === 'object') { + for (const nested of Object.values(value)) { + frozen(nested) + } + Object.freeze(value) + } + return value +} + +function preparedMove() { + const source = domainState.readProfileProjectTransferState('source', root) + const target = domainState.readProfileProjectTransferState('target', root) + if ( + source.revision === undefined || + target.revision === undefined || + !source.documents || + !target.documents + ) { + throw new Error('Missing SQL fixture') + } + const sourceRepo = source.state.repos[0] + if (!sourceRepo) { + throw new Error('Missing source repo') + } + const targetRepo = createTargetRepo(sourceRepo, target.state, false) + const payload = createTransferPayload({ + sourceState: source.state, + sourceRepo, + targetRepo, + includeSessions: true + }) + const sourceAfter = removeSourceRepo(source.state, sourceRepo.id) + const targetAfter = applyPayloadToTarget(target.state, payload) + const intent = createProfileProjectDomainMoveIntent({ + sourceProfileId: 'source', + targetProfileId: 'target', + source: prepareProfileProjectDomainChanges(source.revision, source.documents, sourceAfter), + target: prepareProfileProjectDomainChanges(target.revision, target.documents, targetAfter) + }) + return { intent, sourceAfter, targetAfter } +} + +beforeAll(() => { + crashRoot = mkdtempSync(join(tmpdir(), 'orca-domain-move-crash-api-')) + crashBundle = join(crashRoot, 'api.cjs') + crashScript = join(crashRoot, 'crash.cjs') + buildSync({ + stdin: { + contents: + "export { transferOrcaProfileProject } from './src/main/orca-profiles/profile-project-transfer'", + loader: 'ts', + resolveDir: process.cwd() + }, + outfile: crashBundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) + writeFileSync( + crashScript, + ` +const fs = require('node:fs') +const path = require('node:path') +const [bundle, root, stage] = process.argv.slice(2) +const barrier = label => { + if (label !== stage) return + fs.writeSync(1, label + '\\n') + process.kill(process.pid, 'SIGKILL') + throw new Error('SIGKILL returned') +} +const sqlite = require('node:sqlite') +const exec = sqlite.DatabaseSync.prototype.exec +const writers = new WeakSet() +sqlite.DatabaseSync.prototype.exec = function(sql) { + const writing = writers.has(this) + const participant = writing ? this.prepare("SELECT value FROM profile_state_meta WHERE key = 'profile_id'").get().value : '' + if (writing && sql === 'COMMIT') barrier(participant + '-before-commit') + const result = exec.call(this, sql) + if (sql === 'BEGIN IMMEDIATE') writers.add(this) + if (sql === 'COMMIT' || sql === 'ROLLBACK') writers.delete(this) + if (writing && sql === 'COMMIT') barrier(participant + '-committed') + return result +} +let published = false +let removed = false +const rename = fs.renameSync +fs.renameSync = (from, to) => { + const intent = path.dirname(to) === path.join(root, 'profile-move-intents') && to.endsWith('.json') + if (intent) barrier('intent-before-publish') + rename(from, to) + if (intent) { published = true; barrier('intent-published') } +} +const rm = fs.rmSync +fs.rmSync = (target, ...rest) => { + const intent = path.dirname(target) === path.join(root, 'profile-move-intents') && target.endsWith('.json') + if (intent) barrier('cleanup-before-remove') + rm(target, ...rest) + if (intent) { removed = true; barrier('cleanup-removed') } +} +const fsync = fs.fsyncSync +fs.fsyncSync = fd => { + fsync(fd) + if (fs.fstatSync(fd).isDirectory()) { + if (removed) barrier('cleanup-durable') + else if (published) barrier('intent-durable') + } +} +require(bundle).transferOrcaProfileProject({ sourceProfileId: 'source', targetProfileId: 'target', repoId: 'repo-1', mode: 'move' }, root) +throw new Error('Crash boundary not reached: ' + stage) +` + ) +}) + +afterAll(() => rmSync(crashRoot, { recursive: true, force: true })) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-domain-transfer-')) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) + seed('source', [repo]) + seed('target') +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +describe('profile domain transfers', () => { + it.each(['copy', 'move'] as const)( + '%s preserves the full normalized projection and unchanged physical rows', + (mode) => { + const beforeSource = domainState.readProfileProjectTransferState('source', root).state + const beforeTarget = domainState.readProfileProjectTransferState('target', root).state + const physicalBefore = withDatabase('target', (db) => + db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain" + ) + .all() + ) + const result = transfer(mode) + expect(result.status).toBe('transferred') + const afterTarget = JSON.parse(raw('target').json) + const targetRepo: Repo = afterTarget.repos[0] + const payload = createTransferPayload({ + sourceState: beforeSource, + sourceRepo: repo, + targetRepo, + includeSessions: mode === 'move' + }) + expect(afterTarget).toEqual( + JSON.parse(JSON.stringify(applyPayloadToTarget(beforeTarget, payload))) + ) + expect(afterTarget.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive') + expect( + withDatabase('target', (db) => + db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain IN ('futureOpaque', '', '__proto__', 'futureNull') ORDER BY domain" + ) + .all() + ) + ).toEqual(physicalBefore) + expect(raw('target').revision).toBe(2) + expect(raw('source').revision).toBe(mode === 'move' ? 2 : 1) + if (mode === 'move') { + expect(JSON.parse(raw('source').json)).toEqual( + JSON.parse(JSON.stringify(removeSourceRepo(beforeSource, repo.id))) + ) + } + } + ) + + it.each(['git', 'folder', 'ssh'] as const)( + 'normalization and %s projections do not mutate raw nested values', + (kind) => { + const snapshot = domainState.readProfileProjectTransferState('source', root) + const input = Object.fromEntries( + (snapshot.documents ?? []).map(({ domain, value }) => [domain, value]) + ) + input.repos = [ + { + ...repo, + kind: kind === 'folder' ? 'folder' : 'git', + connectionId: kind === 'ssh' ? 'remote' : null + } + ] + input.projects = [ + { + id: 'old-project', + displayName: 'Previous', + badgeColor: 'neutral', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + input.workspaceSession = { + ...getDefaultPersistedState('/test').workspaceSession, + tabsByWorktree: { + 'repo-1::/project/branch': [ + { + id: 'tab', + ptyId: 'pty', + worktreeId: 'repo-1::/project/branch', + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + if (kind === 'ssh') { + input.workspaceSessionsByHostId = { 'runtime:remote': input.workspaceSession } + } + const before = JSON.stringify(input) + frozen(input) + const source = frozen(normalizeProfileProjectState(input)) + const target = frozen(normalizeProfileProjectState({ repos: [] })) + const sourceRepo = source.repos[0] + if (!sourceRepo) { + throw new Error('Missing source repo') + } + const payload = frozen( + createTransferPayload({ + sourceState: source, + sourceRepo, + targetRepo: createTargetRepo(sourceRepo, target, false), + includeSessions: true + }) + ) + expect(() => applyPayloadToTarget(target, payload)).not.toThrow() + expect(() => removeSourceRepo(source, sourceRepo.id)).not.toThrow() + expect(JSON.stringify(input)).toBe(before) + expect(Object.entries(source).find(([domain]) => domain === 'futureOpaque')?.[1]).toBe( + input.futureOpaque + ) + } + ) + + it('journals only changed domains and replays target-first with exact revisions', () => { + const { intent, sourceAfter, targetAfter } = preparedMove() + expect(intent.source.replacements.map(({ domain }) => domain)).not.toContain('futureOpaque') + expect(JSON.stringify(intent).length).toBeLessThan(40_000) + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + expect(recoverPendingProfileProjectMoves(root)).toBe(1) + expect(JSON.parse(raw('source').json)).toEqual(JSON.parse(JSON.stringify(sourceAfter))) + expect(JSON.parse(raw('target').json)).toEqual(JSON.parse(JSON.stringify(targetAfter))) + expect(raw('source').revision).toBe(2) + expect(raw('target').revision).toBe(2) + expect(recoverPendingProfileProjectMoves(root)).toBe(0) + }) + + it.each(['source', 'target'] as const)( + 'refuses an unrelated %s write and retains the move intent', + (participant) => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + withDatabase(participant, (db) => + writeProfileStateDomain(db, { + expectedRevision: participant === 'source' ? 1 : 2, + domain: 'unrelated', + payload: 'true' + }) + ) + expect(() => recoverPendingProfileProjectMoves(root)).toThrow(/conflicts|unrecognized/) + expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`) + expect(JSON.parse(raw('source').json).repos).toHaveLength(1) + } + ) + + it('leaves conflicted moves between inactive profiles for those profiles to recover', () => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + withDatabase('source', (db) => + writeProfileStateDomain(db, { + expectedRevision: 1, + domain: 'unrelated', + payload: 'true' + }) + ) + expect(recoverPendingProfileProjectMoves(root, 'third-profile')).toBe(0) + expect(() => recoverPendingProfileProjectMoves(root, 'source')).toThrow(/conflicts/) + expect(readdirSync(join(root, 'profile-move-intents'))).toContain(`${intent.id}.json`) + }) + + it('refuses a malformed move record even when its header names inactive profiles', () => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + const path = join(root, 'profile-move-intents', `${intent.id}.json`) + writeFileSync(path, JSON.stringify({ ...intent, source: null })) + expect(() => recoverPendingProfileProjectMoves(root, 'third-profile')).toThrow('malformed') + expect(JSON.parse(readFileSync(path, 'utf8')).source).toBeNull() + }) + + it('refuses independently hashed malformed unrelated data before a copy writes anything', () => { + const before = raw('target').json + withDatabase('source', (db) => + db + .prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ) + .run('null,"extra":true', hashProfileStateJson('null,"extra":true'), 'futureNull') + ) + expect(() => transfer('copy')).toThrow(/JSON/) + expect(raw('target').json).toBe(before) + }) + + it('distinguishes deletions and null while ignoring extra executable mutation options', () => { + const snapshot = domainState.readProfileProjectTransferState('target', root) + if (!snapshot.documents || snapshot.revision === undefined) { + throw new Error('Missing SQL fixture') + } + const after = { ...snapshot.state, futureDelete: undefined, futureNull: null, addedNull: null } + const changes = prepareProfileProjectDomainChanges(snapshot.revision, snapshot.documents, after) + const poisoned = { + ...changes, + automationRunsAfter: [{}], + replacements: changes.replacements.map((replacement) => ({ + ...replacement, + domainVersion: -1 + })) + } + domainState.writeProfileProjectDomainChanges('target', root, poisoned) + const saved = JSON.parse(raw('target').json) + expect(saved.futureNull).toBeNull() + expect(saved.addedNull).toBeNull() + expect(saved).not.toHaveProperty('futureDelete') + expect(saved.automationRuns).toEqual([]) + }) + + it.each(['payload', 'afterHash', 'duplicate', 'revision', 'before'] as const)( + 'rejects %s tampering before recovery modifies either participant', + (kind) => { + const { intent } = preparedMove() + persistProfileProjectMoveIntent(root, intent) + domainState.writeProfileProjectDomainChanges('target', root, intent.target) + const before = raw('source').json + if (kind === 'payload') { + intent.source.replacements[0]!.payload = 'null' + } + if (kind === 'afterHash') { + intent.source.afterHash = 'a'.repeat(64) + } + if (kind === 'duplicate') { + intent.source.replacements.push(intent.source.replacements[0]!) + } + if (kind === 'revision') { + intent.source.expectedRevision = Number.MAX_SAFE_INTEGER + } + if (kind === 'before') { + intent.source.before.push(intent.source.before[0]!) + } + writeFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), JSON.stringify(intent)) + expect(() => recoverPendingProfileProjectMoves(root)).toThrow() + expect(raw('source').json).toBe(before) + expect(readFileSync(join(root, 'profile-move-intents', `${intent.id}.json`), 'utf8')).toBe( + JSON.stringify(intent) + ) + } + ) + + it('canonicalizes only domain ordering in fingerprints', () => { + const a = { domain: 'a', hash: hashProfileStateJson('{"x":1,"y":2}') } + const b = { domain: 'b', hash: hashProfileStateJson('[2,1]') } + expect(profileProjectDomainFingerprint([a, b])).toBe(profileProjectDomainFingerprint([b, a])) + expect(profileProjectDomainFingerprint([a, b])).not.toBe( + profileProjectDomainFingerprint([{ ...a, hash: hashProfileStateJson('{"y":2,"x":1}') }, b]) + ) + const { intent } = preparedMove() + expect(() => validateProfileProjectDomainChanges(intent.source)).not.toThrow() + }) + + const crashStages = [ + 'intent-before-publish', + 'intent-published', + ...(process.platform === 'win32' ? [] : ['intent-durable']), + 'target-before-commit', + 'target-committed', + 'source-before-commit', + 'source-committed', + 'cleanup-before-remove', + 'cleanup-removed', + ...(process.platform === 'win32' ? [] : ['cleanup-durable']) + ] + it.each(crashStages)('recovers exact state after actual SIGKILL at %s', async (stage) => { + const sourceBefore = raw('source').json + const targetBefore = raw('target').json + const { sourceAfter, targetAfter } = preparedMove() + const child = await runProcess({ + program: process.execPath, + args: [crashScript, crashBundle, root, stage], + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + NODE_PATH: join(process.cwd(), 'node_modules') + }, + timeoutMs: 10_000, + maxOutputBytes: 16_384 + }) + expect(child.timedOut, child.stderr).toBe(false) + expect(child.stdout, child.stderr).toBe(`${stage}\n`) + expect(child.code).not.toBe(0) + if (process.platform !== 'win32') { + expect(child.signal).toBe('SIGKILL') + } + recoverPendingProfileProjectMoves(root) + const targetCommitted = ![ + 'intent-before-publish', + 'intent-published', + 'intent-durable', + 'target-before-commit' + ].includes(stage) + expect(JSON.parse(raw('source').json)).toEqual( + targetCommitted ? JSON.parse(JSON.stringify(sourceAfter)) : JSON.parse(sourceBefore) + ) + expect(JSON.parse(raw('target').json)).toEqual( + targetCommitted ? JSON.parse(JSON.stringify(targetAfter)) : JSON.parse(targetBefore) + ) + expect(raw('source').revision).toBe(targetCommitted ? 2 : 1) + expect(raw('target').revision).toBe(targetCommitted ? 2 : 1) + expect(recoverPendingProfileProjectMoves(root)).toBe(0) + }) +}) diff --git a/src/main/orca-profiles/profile-project-move-intent.ts b/src/main/orca-profiles/profile-project-move-intent.ts new file mode 100644 index 00000000000..51fd796e5ff --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-intent.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file' +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { + readProfileStateWithRevision, + writeSerializedProfileState, + type ReadProfileStateResult +} from './profile-project-state-file' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { readProfileProjectDomainMoveState } from './profile-project-domain-move-intent' +import { writeProfileProjectDomainChanges } from './profile-project-domain-state' +import { + profileProjectMoveIntentPath, + readPendingProfileProjectMoveIntents, + validateProfileProjectMoveIntent, + type ProfileProjectMoveIntent, + type ProfileProjectMoveIntentV1 +} from './profile-project-move-record' +export { profileHasPendingProjectMove } from './profile-project-move-record' +export type { + ProfileProjectMoveIdentity, + ProfileProjectMoveIntent +} from './profile-project-move-record' + +export function persistProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + validateProfileProjectMoveIntent(intent) + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const path = profileProjectMoveIntentPath(userDataPath, intent.id) + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` + writeFileSync(temporaryPath, JSON.stringify(intent), { encoding: 'utf8', mode: 0o600 }) + fsyncFileSync(temporaryPath) + renameSync(temporaryPath, path) + bestEffortFsyncDirectorySync(directory) +} + +export function removeProfileProjectMoveIntent(userDataPath: string, intentId: string): void { + rmSync(profileProjectMoveIntentPath(userDataPath, intentId), { force: true }) + bestEffortFsyncDirectorySync(getOrcaProfileMoveIntentDirectory(userDataPath)) +} + +export function recoverPendingProfileProjectMoves( + userDataPath: string, + profileId?: string +): number { + const intents = readPendingProfileProjectMoveIntents(userDataPath).filter( + (intent) => + profileId === undefined || + intent.sourceProfileId === profileId || + intent.targetProfileId === profileId + ) + for (const intent of intents) { + recoverProfileProjectMoveIntent(userDataPath, intent) + } + return intents.length +} + +function recoverProfileProjectMoveIntent( + userDataPath: string, + intent: ProfileProjectMoveIntent +): void { + const { sourceBefore, targetBefore, sourceAfter, targetAfter } = + intent.version === 2 + ? readProfileProjectDomainMoveState(userDataPath, intent) + : readLegacyMoveState(userDataPath, intent) + + if (sourceAfter && targetAfter) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetBefore) { + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && targetAfter) { + if (intent.version === 2) { + writeProfileProjectDomainChanges(intent.sourceProfileId, userDataPath, intent.source) + } else { + writeSerializedProfileState(intent.sourceProfileId, userDataPath, intent.sourceAfterJson, { + expectedRevision: intent.expectedSourceRevision + }) + } + removeProfileProjectMoveIntent(userDataPath, intent.id) + return + } + if (sourceBefore && !targetAfter && !targetBefore) { + throw new Error(`Profile move ${intent.id} has an unrecognized target state`) + } + if (targetAfter && !sourceAfter) { + // Preserve the journal when an independent write makes replay unsafe. + throw new Error(`Profile move ${intent.id} conflicts with a source profile write`) + } + if (sourceAfter && targetBefore) { + throw new Error(`Profile move ${intent.id} has a source commit without its target commit`) + } + throw new Error(`Profile move ${intent.id} has an unrecognized participant state`) +} + +function readLegacyMoveState(userDataPath: string, intent: ProfileProjectMoveIntentV1) { + const source = readProfileStateWithRevision(intent.sourceProfileId, userDataPath) + const target = readProfileStateWithRevision(intent.targetProfileId, userDataPath) + if (source.revision === undefined || target.revision === undefined) { + throw new Error(`Profile move ${intent.id} no longer has two SQLite participants`) + } + + return { + sourceBefore: matches(source, intent.expectedSourceRevision, intent.sourceBeforeHash), + targetBefore: matches(target, intent.expectedTargetRevision, intent.targetBeforeHash), + sourceAfter: matches(source, intent.expectedSourceRevision + 1, intent.sourceAfterHash), + targetAfter: matches(target, intent.expectedTargetRevision + 1, intent.targetAfterHash) + } +} + +function matches(snapshot: ReadProfileStateResult, revision: number, hash: string): boolean { + return ( + snapshot.revision === revision && + snapshot.serialized !== undefined && + hashProfileStateJson(snapshot.serialized) === hash + ) +} diff --git a/src/main/orca-profiles/profile-project-move-record.ts b/src/main/orca-profiles/profile-project-move-record.ts new file mode 100644 index 00000000000..68391cd117d --- /dev/null +++ b/src/main/orca-profiles/profile-project-move-record.ts @@ -0,0 +1,141 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { hashProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { isRecord } from '../persistence/profile-state/profile-state-document-validation' +import { getOrcaProfileMoveIntentDirectory } from './profile-storage-paths' +import { + validateProfileProjectDomainChanges, + type ProfileProjectDomainChanges +} from './profile-project-domain-changes' + +const PROFILE_MOVE_INTENT_VERSION = 1 +const INTENT_FILE_PATTERN = /^[0-9a-f-]{36}\.json$/ +const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ + +export type ProfileProjectMoveIdentity = { + id: string + sourceProfileId: string + targetProfileId: string +} + +export type ProfileProjectMoveIntentV1 = ProfileProjectMoveIdentity & { + version: typeof PROFILE_MOVE_INTENT_VERSION + expectedSourceRevision: number + expectedTargetRevision: number + sourceBeforeHash: string + targetBeforeHash: string + sourceAfterHash: string + targetAfterHash: string + sourceAfterJson: string + targetAfterJson: string +} + +export type ProfileProjectDomainMoveIntent = ProfileProjectMoveIdentity & { + version: 2 + source: ProfileProjectDomainChanges + target: ProfileProjectDomainChanges +} + +export type ProfileProjectMoveIntent = ProfileProjectMoveIntentV1 | ProfileProjectDomainMoveIntent + +export function profileHasPendingProjectMove(profileId: string, userDataPath: string): boolean { + try { + return readPendingProfileProjectMoveIntents(userDataPath).some( + (intent) => intent.sourceProfileId === profileId || intent.targetProfileId === profileId + ) + } catch { + // An unreadable intent cannot rule this profile out as a participant. + return true + } +} + +export function readPendingProfileProjectMoveIntents( + userDataPath: string +): ProfileProjectMoveIntent[] { + const directory = getOrcaProfileMoveIntentDirectory(userDataPath) + return existsSync(directory) + ? readdirSync(directory) + .filter((file) => INTENT_FILE_PATTERN.test(file)) + .map((file) => readProfileProjectMoveIntent(join(directory, file))) + : [] +} + +export function profileProjectMoveIntentPath(userDataPath: string, intentId: string): string { + if (!/^[0-9a-f-]{36}$/.test(intentId)) { + throw new Error('Invalid profile move intent ID') + } + return join(getOrcaProfileMoveIntentDirectory(userDataPath), `${intentId}.json`) +} + +function readProfileProjectMoveIntent(path: string): ProfileProjectMoveIntent { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Profile move intent is unreadable: ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (!isRecord(parsed)) { + throw new Error(`Profile move intent is malformed: ${path}`) + } + validateProfileProjectMoveIntent(parsed) + if (basename(path) !== `${parsed.id}.json`) { + throw new Error(`Profile move intent ID does not match its file: ${path}`) + } + return parsed +} + +export function validateProfileProjectMoveIntent( + value: unknown +): asserts value is ProfileProjectMoveIntent { + validateMoveIdentity(value) + if (value.version === 2) { + validateProfileProjectDomainChanges(value.source) + validateProfileProjectDomainChanges(value.target) + return + } + const intent = value + const expectedSourceRevision = intent.expectedSourceRevision + const expectedTargetRevision = intent.expectedTargetRevision + if ( + intent.version !== PROFILE_MOVE_INTENT_VERSION || + !Number.isSafeInteger(expectedSourceRevision) || + !Number.isSafeInteger(expectedTargetRevision) || + typeof expectedSourceRevision !== 'number' || + typeof expectedTargetRevision !== 'number' || + expectedSourceRevision < 0 || + expectedTargetRevision < 0 || + !isHash(intent.sourceBeforeHash) || + !isHash(intent.targetBeforeHash) || + !isHash(intent.sourceAfterHash) || + !isHash(intent.targetAfterHash) || + typeof intent.sourceAfterJson !== 'string' || + typeof intent.targetAfterJson !== 'string' || + hashProfileStateJson(intent.sourceAfterJson) !== intent.sourceAfterHash || + hashProfileStateJson(intent.targetAfterJson) !== intent.targetAfterHash + ) { + throw new Error('Profile move intent is malformed') + } +} + +function validateMoveIdentity( + value: unknown +): asserts value is ProfileProjectMoveIdentity & Record { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + !/^[0-9a-f-]{36}$/.test(value.id) || + typeof value.sourceProfileId !== 'string' || + typeof value.targetProfileId !== 'string' || + !PROFILE_ID_PATTERN.test(value.sourceProfileId) || + !PROFILE_ID_PATTERN.test(value.targetProfileId) || + value.sourceProfileId === value.targetProfileId + ) { + throw new Error('Profile move intent is malformed') + } +} + +function isHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index d4f291a09e4..13b8d1afae6 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -15,29 +15,116 @@ import type { Repo } from '../../shared/repo-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { SparsePreset } from '../../shared/worktree/create-types' import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry' -import { getOrcaProfileDataFile } from './profile-index-store' +import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-index-store' +import { + importProfileStateJson, + profileStateJsonMatchesAcceptance, + readProfileStateRevision, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { parseProfileStateRoot } from '../persistence/profile-state/profile-state-document-validation' +import { assertProfileStateCanInitialize } from '../persistence/profile-state/profile-state-recovery-required' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' export type TransferProfileState = PersistedState -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) +export type ReadProfileStateResult = { + state: TransferProfileState + /** SQLite profile revision observed with the state snapshot; absent for legacy JSON. */ + revision?: number + /** Exact compact JSON projection observed with the state snapshot. */ + serialized?: string } -function arrayOrEmpty(value: unknown): T[] { - return Array.isArray(value) ? value : [] -} +/** A profile must have one unambiguous transfer source. */ +export class AmbiguousProfileStateStorageError extends Error { + readonly code = 'ambiguous_profile_state_storage' as const -function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? value : {} -} - -export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { - const defaults = getDefaultPersistedState(homedir()) - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - if (!existsSync(dataFile)) { - return structuredClone(defaults) + constructor(profileId: string, message?: string) { + super(message ?? `Profile ${profileId} has both SQLite and legacy JSON state`) + this.name = 'AmbiguousProfileStateStorageError' } - const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) +} + +export type ProfileStateStorage = 'json' | 'sqlite' + +export function profileStateStorage(profileId: string, userDataPath: string): ProfileStateStorage { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const hasJson = existsSync(dataFile) + const hasDatabase = hasProfileStateDatabaseFiles(databaseFile) + if (hasJson && hasDatabase) { + assertAcceptedLegacyJsonMirror(profileId, dataFile, databaseFile) + return 'sqlite' + } + if (!hasDatabase) { + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) + } + return hasDatabase ? 'sqlite' : 'json' +} + +/** + * A migrated profile may retain its JSON export during the rollback window. + * Select SQLite only when its acceptance marker still names the exact export; + * any edit, missing marker, or corrupt database remains fail-closed. + */ +function assertAcceptedLegacyJsonMirror( + profileId: string, + dataFile: string, + databaseFile: string +): void { + const rawJson = readFileSync(dataFile, 'utf-8') + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) { + throw new AmbiguousProfileStateStorageError(profileId) + } + } finally { + opened.db.close() + } +} + +/** Read one profile state and retain the SQLite revision that fenced that snapshot. */ +export function readProfileStateWithRevision( + profileId: string, + userDataPath: string +): ReadProfileStateResult { + const storage = profileStateStorage(profileId, userDataPath) + if (storage === 'json') { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + const serialized = existsSync(dataFile) ? readFileSync(dataFile, 'utf-8') : undefined + return { state: parseProfileState(serialized), ...(serialized ? { serialized } : {}) } + } + + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const opened = openProfileStateDatabaseReadOnly(databaseFile, profileId) + try { + const snapshot = readProfileStateSnapshot(opened.db) + return { + state: parseProfileState(snapshot.json), + revision: snapshot.revision, + serialized: snapshot.json + } + } finally { + opened.db.close() + } +} + +function parseProfileState(rawJson: string | undefined): TransferProfileState { + if (rawJson === undefined) { + return structuredClone(getDefaultPersistedState(homedir())) + } + return normalizeProfileProjectState(parseProfileStateRoot(rawJson)) +} + +export function normalizeProfileProjectState( + parsed: Partial +): TransferProfileState { + const defaults = getDefaultPersistedState(homedir()) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, @@ -91,15 +178,56 @@ export function readProfileState(profileId: string, userDataPath: string): Trans }) } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? value : [] +} + +function recordOrEmpty(value: unknown): Record { + return isRecord(value) ? value : {} +} + +export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { + return readProfileStateWithRevision(profileId, userDataPath).state +} + export function writeProfileState( profileId: string, userDataPath: string, - state: TransferProfileState + state: TransferProfileState, + options: { expectedRevision?: number } = {} ): void { + writeSerializedProfileState(profileId, userDataPath, JSON.stringify(state), options) +} + +/** Write an already validated JSON projection while preserving its exact bytes in SQLite. */ +export function writeSerializedProfileState( + profileId: string, + userDataPath: string, + serialized: string, + options: { expectedRevision?: number } = {} +): void { + const storage = profileStateStorage(profileId, userDataPath) + if (storage === 'sqlite') { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + importProfileStateJson(opened.db, serialized, { + expectedRevision: options.expectedRevision ?? readProfileStateRevision(opened.db) + }) + } finally { + opened.db.close() + } + return + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) mkdirSync(dirname(dataFile), { recursive: true }) const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp` - writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') + writeFileSync(tmpPath, serialized, 'utf-8') renameSync(tmpPath, dataFile) } diff --git a/src/main/orca-profiles/profile-project-transfer-migration.test.ts b/src/main/orca-profiles/profile-project-transfer-migration.test.ts new file mode 100644 index 00000000000..582c569dcc7 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.test.ts @@ -0,0 +1,314 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { ORCA_PROFILE_INDEX_SCHEMA_VERSION } from '../../shared/orca-profiles' +import type { Repo } from '../../shared/repo-types' +import * as profileStateDocuments from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../persistence/profile-state/profile-state-backup-path' +import { transferOrcaProfileProject } from './profile-project-transfer' +import { readProfileStateWithRevision } from './profile-project-state-file' +import { recoverPendingProfileProjectMoves } from './profile-project-move-intent' +import * as profileProjectDomainState from './profile-project-domain-state' + +vi.mock('../persistence/loading-store/store', () => { + throw new Error('Profile transfers must not load inactive Stores') +}) + +const repo: Repo = { + id: 'repo-1', + path: '/projects/folder', + displayName: 'Folder', + badgeColor: 'neutral', + addedAt: 1, + kind: 'folder', + connectionId: null +} +let directory: string + +function paths(profileId: string): { dataFile: string; databaseFile: string } { + return { + dataFile: join(directory, 'profiles', profileId, 'orca-data.json'), + databaseFile: join(directory, 'profiles', profileId, 'profile-state.db') + } +} + +function writeState(profileId: string, sqlite: boolean, repos: Repo[] = []): string { + const defaults = getDefaultPersistedState('/home/test') + const source = JSON.stringify( + { + ...defaults, + repos, + futureDomain: { profileId }, + settings: { ...defaults.settings, opencodeSessionCookie: 'enc:v1:sealed-inactive-secret' } + }, + null, + 2 + ) + const location = paths(profileId) + mkdirSync(join(location.dataFile, '..'), { recursive: true }) + if (sqlite) { + const opened = openProfileStateDatabase(location.databaseFile, profileId) + try { + profileStateDocuments.importProfileStateJson(opened.db, source) + } finally { + opened.db.close() + } + } else { + writeFileSync(location.dataFile, source) + } + return source +} + +function transfer(mode: 'copy' | 'move' = 'move') { + return transferOrcaProfileProject( + { + sourceProfileId: 'source', + targetProfileId: 'target', + repoId: repo.id, + mode + }, + directory + ) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-profile-transfer-migration-')) + writeFileSync( + join(directory, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: 'source', + profiles: ['source', 'target'].map((id) => ({ + id, + name: id, + avatar: { kind: 'initials', initials: id[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + })) + }) + ) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('profile transfer migration', () => { + it.each(['copy', 'move'] as const)( + '%s adopts an unopened JSON target without loading Store', + (mode) => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + expect(transfer(mode)).toMatchObject({ status: 'transferred', mode }) + + const target = readProfileStateWithRevision('target', directory) + expect(target.revision).toBe(2) + expect(target.state.repos).toHaveLength(1) + expect(target.state.repos[0]).toMatchObject({ kind: 'folder', path: repo.path }) + expect(target.state.settings.opencodeSessionCookie).toBe('enc:v1:sealed-inactive-secret') + expect(JSON.parse(target.serialized ?? '{}').futureDomain).toEqual({ profileId: 'target' }) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(existsSync(`${paths('target').dataFile}.sqlite-export.1.json`)).toBe(true) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength( + mode === 'move' ? 0 : 1 + ) + } + ) + + it('initializes a target with no saved JSON before its move intent is created', () => { + writeState('source', true, [repo]) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(existsSync(paths('target').dataFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(true) + }) + + it.each(['copy', 'move'] as const)( + '%s refuses an apparently empty target that retains a legacy JSON backup', + (mode) => { + writeState('source', true, [repo]) + const sourceRevision = readProfileStateWithRevision('source', directory).revision + const targetJson = writeState('target', false) + const target = paths('target') + writeFileSync(`${target.dataFile}.bak.0`, targetJson) + rmSync(target.dataFile) + + expect(() => transfer(mode)).toThrow('restore a selected backup') + expect(readProfileStateWithRevision('source', directory)).toMatchObject({ + revision: sourceRevision, + state: { repos: [repo] } + }) + expect(existsSync(target.dataFile)).toBe(false) + expect(existsSync(target.databaseFile)).toBe(false) + expect(readFileSync(`${target.dataFile}.bak.0`, 'utf8')).toBe(targetJson) + } + ) + + it.each(['[]', '7', '"invalid"', 'true'])( + 'refuses a non-object JSON target (%s) without replacing its contents', + (raw) => { + writeState('source', false, [repo]) + writeState('target', false) + const target = paths('target') + writeFileSync(target.dataFile, raw) + + expect(() => transfer()).toThrow('Profile state JSON root must be an object') + expect(readFileSync(target.dataFile, 'utf8')).toBe(raw) + expect(existsSync(target.databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + } + ) + + it('migrates a JSON source before moving into SQLite and retains its exact rollback bytes', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(readProfileStateWithRevision('source', directory)).toMatchObject({ + revision: 2, + state: { repos: [] } + }) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + }) + + it('copies from JSON into SQLite without migrating or changing the source', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + expect(transfer('copy')).toMatchObject({ status: 'transferred' }) + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(existsSync(paths('source').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + }) + + it('keeps JSON-only transfers compatible on runtimes without SQLite', () => { + writeState('source', false, [repo]) + writeState('target', false) + const getBuiltin = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((name) => + name === 'node:sqlite' ? undefined : getBuiltin(name) + ) + expect(transfer()).toMatchObject({ status: 'transferred' }) + expect(existsSync(paths('source').databaseFile)).toBe(false) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(0) + }) + + it('refuses mixed storage on a runtime without SQLite before migrating or editing JSON', () => { + const sourceJson = writeState('source', false, [repo]) + writeState('target', true) + const getBuiltin = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((name) => + name === 'node:sqlite' ? undefined : getBuiltin(name) + ) + expect(() => transfer()).toThrow('Unable to open profile state database') + expect(readFileSync(paths('source').dataFile, 'utf8')).toBe(sourceJson) + expect(existsSync(paths('source').databaseFile)).toBe(false) + }) + + it('refuses to adopt stale target JSON when a retained database backup proves missing authority', () => { + writeState('source', true, [repo]) + writeState('target', false) + const backupPath = profileStateDatabaseBackupPath( + paths('target').databaseFile, + createProfileStateDatabaseBackupId(1) + ) + writeFileSync(backupPath, 'retained recovery evidence') + expect(() => transfer()).toThrowError( + expect.objectContaining({ + code: 'profile-state-recovery-required', + backupPaths: [backupPath] + }) + ) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + }) + + it('does not migrate a duplicate target', () => { + writeState('source', true, [repo]) + writeState('target', false, [repo]) + expect(transfer()).toMatchObject({ status: 'duplicate-target' }) + expect(existsSync(paths('target').databaseFile)).toBe(false) + }) + + it('leaves both participants untouched when the import fails before publication', () => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + vi.spyOn(profileStateDocuments, 'importProfileStateJson').mockImplementation(() => { + throw new Error('import failure') + }) + expect(() => transfer()).toThrow('import failure') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readdirSync(join(paths('target').dataFile, '..'))).toEqual(['orca-data.json']) + }) + + it('rejects a JSON edit during migration before publishing SQLite', () => { + writeState('source', true, [repo]) + writeState('target', false) + const originalImport = profileStateDocuments.importProfileStateJson + vi.spyOn(profileStateDocuments, 'importProfileStateJson').mockImplementation((...args) => { + const revision = originalImport(...args) + writeFileSync(paths('target').dataFile, '{"settings":{"theme":"light"}}') + return revision + }) + expect(() => transfer()).toThrow('JSON changed while importing') + expect(existsSync(paths('target').databaseFile)).toBe(false) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + }) + + it('keeps a valid migrated participant after export failure without moving the project', () => { + writeState('source', true, [repo]) + const targetJson = writeState('target', false) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'writeJsonExport').mockImplementation(() => { + throw new Error('export failure') + }) + expect(() => transfer()).toThrow('export failure') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(0) + expect(readFileSync(paths('target').dataFile, 'utf8')).toBe(targetJson) + }) + + it.each([false, true])( + 'replays an interrupted move after migrating JSON source=%s', + (sourceJson) => { + writeState('source', !sourceJson, [repo]) + writeState('target', sourceJson) + const originalWrite = profileProjectDomainState.writeProfileProjectDomainChanges + const write = vi + .spyOn(profileProjectDomainState, 'writeProfileProjectDomainChanges') + .mockImplementation((profileId, ...rest) => { + if (profileId === 'source') { + throw new Error('source commit interrupted') + } + return originalWrite(profileId, ...rest) + }) + expect(() => transfer()).toThrow('source commit interrupted') + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(1) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + write.mockRestore() + expect(recoverPendingProfileProjectMoves(directory)).toBe(1) + expect(readProfileStateWithRevision('source', directory).state.repos).toHaveLength(0) + expect(readProfileStateWithRevision('target', directory).state.repos).toHaveLength(1) + expect(recoverPendingProfileProjectMoves(directory)).toBe(0) + } + ) +}) diff --git a/src/main/orca-profiles/profile-project-transfer-migration.ts b/src/main/orca-profiles/profile-project-transfer-migration.ts new file mode 100644 index 00000000000..1b380030c65 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-migration.ts @@ -0,0 +1,27 @@ +import { migrateProfileStateToSqlite } from '../persistence/profile-state/profile-state-migration' +import { getOrcaProfileDataFile, getOrcaProfileStateDatabaseFile } from './profile-storage-paths' +import type { ReadProfileStateResult } from './profile-project-state-file' +import { + readProfileProjectTransferState, + type ReadProfileProjectTransferResult +} from './profile-project-domain-state' + +/** Adopt inactive storage without running Store's active-profile listeners or secret transforms. */ +export function migrateProfileProjectTransferParticipant( + profileId: string, + userDataPath: string, + snapshot: ReadProfileStateResult +): ReadProfileProjectTransferResult { + const migrated = migrateProfileStateToSqlite({ + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId, + expectedLegacyJson: snapshot.serialized, + serializedState: snapshot.serialized ?? '{}' + }) + try { + return readProfileProjectTransferState(profileId, userDataPath) + } finally { + migrated.authority.close() + } +} diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts index fc40cd03bb6..67c780f7208 100644 --- a/src/main/orca-profiles/profile-project-transfer.test.ts +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +19,50 @@ import type { PersistedState } from '../../shared/persisted-state-types' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { SshTarget } from '../../shared/ssh-types' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson +} from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves, + type ProfileProjectMoveIntent +} from './profile-project-move-intent' +import type { ReadProfileStateResult } from './profile-project-state-file' + +function createProfileProjectMoveIntent(args: { + sourceProfileId: string + targetProfileId: string + source: ReadProfileStateResult + target: ReadProfileStateResult + sourceAfterJson: string + targetAfterJson: string +}): Extract { + if ( + args.source.revision === undefined || + args.target.revision === undefined || + args.source.serialized === undefined || + args.target.serialized === undefined + ) { + throw new Error('Legacy move fixture requires two serialized SQLite snapshots') + } + return { + version: 1, + id: '11111111-1111-1111-1111-111111111111', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + expectedSourceRevision: args.source.revision, + expectedTargetRevision: args.target.revision, + sourceBeforeHash: hashProfileStateJson(args.source.serialized), + targetBeforeHash: hashProfileStateJson(args.target.serialized), + sourceAfterHash: hashProfileStateJson(args.sourceAfterJson), + targetAfterHash: hashProfileStateJson(args.targetAfterJson), + sourceAfterJson: args.sourceAfterJson, + targetAfterJson: args.targetAfterJson + } +} const testState = { dir: '' } @@ -50,12 +102,49 @@ function profileDataPath(profileId: string): string { return join(testState.dir, 'profiles', profileId, 'orca-data.json') } +function profileDatabasePath(profileId: string): string { + return join(testState.dir, 'profiles', profileId, 'profile-state.db') +} + function writeProfileState(profileId: string, state: PersistedState): void { const dataFile = profileDataPath(profileId) mkdirSync(join(dataFile, '..'), { recursive: true }) writeFileSync(dataFile, JSON.stringify(state, null, 2), 'utf-8') } +function writeProfileStateDatabase(profileId: string, state: PersistedState): void { + const databasePath = profileDatabasePath(profileId) + mkdirSync(join(databasePath, '..'), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(opened.db, JSON.stringify(state)) + } finally { + opened.db.close() + } +} + +function writeProfileStateDatabaseWithAcceptedLegacyJson(profileId: string, rawJson: string): void { + const databasePath = profileDatabasePath(profileId) + mkdirSync(join(databasePath, '..'), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(opened.db, rawJson, { + acceptedLegacyJsonHash: hashProfileStateJson(rawJson) + }) + } finally { + opened.db.close() + } +} + +function readProfileStateDatabase(profileId: string): PersistedState { + const opened = openProfileStateDatabase(profileDatabasePath(profileId), profileId) + try { + return JSON.parse(exportProfileStateJson(opened.db)) + } finally { + opened.db.close() + } +} + function readProfileState(profileId: string): PersistedState { return JSON.parse(readFileSync(profileDataPath(profileId), 'utf-8')) as PersistedState } @@ -324,4 +413,352 @@ describe('profile project transfer', () => { }) expect(readProfileState('work').repos.map((repo) => repo.id)).toEqual(['repo-existing']) }) + + it('transfers between SQLite-backed profiles without creating legacy JSON or touching sidecars', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileStateDatabase('personal', sourceState) + writeProfileStateDatabase('work', makeState()) + const sidecarPath = join(testState.dir, 'profiles', 'work', 'browser-session-meta.json') + writeFileSync(sidecarPath, '{"preserve":true}', 'utf-8') + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDataPath('personal'))).toBe(false) + expect(existsSync(profileDataPath('work'))).toBe(false) + expect(readFileSync(sidecarPath, 'utf-8')).toBe('{"preserve":true}') + }) + + it('keeps the legacy JSON backend when neither profile has a database', async () => { + writeProfileState('personal', makeState({ repos: [makeRepo()] })) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileState('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDatabasePath('personal'))).toBe(false) + expect(existsSync(profileDatabasePath('work'))).toBe(false) + }) + + it('fails closed when a profile has both database and legacy JSON state', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + writeProfileStateDatabase('personal', sourceState) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + ).toThrowError(expect.objectContaining({ code: 'ambiguous_profile_state_storage' })) + }) + + it('uses SQLite when the retained legacy JSON is the accepted migration export', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(readFileSync(profileDataPath('personal'), 'utf-8')).toBe(sourceJson) + expect(readProfileStateDatabase('personal').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + }) + + it('rejects a missing SQLite database when a retained export proves JSON is stale', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + rmSync(profileDatabasePath('personal')) + writeFileSync(`${profileDataPath('personal')}.sqlite-export.1.json`, sourceJson) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + ).toThrowError(expect.objectContaining({ code: 'profile-state-recovery-required' })) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'refuses a transfer from an orphaned %s', + async (suffix) => { + writeProfileState('personal', makeState({ repos: [makeRepo()] })) + writeProfileState('work', makeState()) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf8') + const targetJson = readFileSync(profileDataPath('work'), 'utf8') + const sidecar = `${profileDatabasePath('personal')}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + const { transferOrcaProfileProject } = await loadTransferModule() + expect(() => + transferOrcaProfileProject( + { sourceProfileId: 'personal', targetProfileId: 'work', repoId: 'repo-1', mode: 'move' }, + testState.dir + ) + ).toThrow() + expect(readFileSync(profileDataPath('personal'), 'utf8')).toBe(sourceJson) + expect(readFileSync(profileDataPath('work'), 'utf8')).toBe(targetJson) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + expect(existsSync(profileDatabasePath('personal'))).toBe(false) + expect(existsSync(profileDatabasePath('work'))).toBe(false) + } + ) + + it.each(['copy', 'move'] as const)( + '%s transfers between SQLite-only profiles while retaining rollback exports', + async (mode) => { + const sourceState = makeState({ repos: [makeRepo()] }) + const targetState = makeState() + writeProfileStateDatabase('personal', sourceState) + writeProfileStateDatabase('work', targetState) + const sourceExport = JSON.stringify(sourceState) + const targetExport = JSON.stringify(targetState) + const sourceExportPath = `${profileDataPath('personal')}.sqlite-export.1.json` + const targetExportPath = `${profileDataPath('work')}.sqlite-export.1.json` + writeFileSync(sourceExportPath, sourceExport) + writeFileSync(targetExportPath, targetExport) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(mode === 'move' ? 0 : 1) + expect(readProfileStateDatabase('work').repos).toEqual([ + expect.objectContaining({ path: '/workspace/orca' }) + ]) + expect(existsSync(profileDataPath('personal'))).toBe(false) + expect(existsSync(profileDataPath('work'))).toBe(false) + expect(readFileSync(sourceExportPath, 'utf8')).toBe(sourceExport) + expect(readFileSync(targetExportPath, 'utf8')).toBe(targetExport) + } + ) + + it('fences a SQLite transfer write against the revision that was read', async () => { + writeProfileStateDatabase('work', makeState()) + + await loadTransferModule() + const stateFile = await import('./profile-project-state-file') + const observed = stateFile.readProfileStateWithRevision('work', testState.dir) + expect(observed.revision).toBeGreaterThan(0) + + const opened = openProfileStateDatabase(profileDatabasePath('work'), 'work') + try { + importProfileStateJson( + opened.db, + JSON.stringify(makeState({ settings: { ...makeState().settings, theme: 'dark' } })) + ) + } finally { + opened.db.close() + } + + expect(() => + stateFile.writeProfileState('work', testState.dir, makeState(), { + expectedRevision: observed.revision + }) + ).toThrowError(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateDatabase('work').settings.theme).toBe('dark') + }) + + it('moves between SQLite-backed profiles through a durable cross-profile intent', async () => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect( + readdirSync(join(testState.dir, 'profile-move-intents')).filter((file) => + file.endsWith('.json') + ) + ).toEqual([]) + }) + + it('moves between rollback-window profiles while retaining their JSON exports', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileState('personal', sourceState) + writeProfileState('work', makeState()) + const sourceJson = readFileSync(profileDataPath('personal'), 'utf-8') + const targetJson = readFileSync(profileDataPath('work'), 'utf-8') + writeProfileStateDatabaseWithAcceptedLegacyJson('personal', sourceJson) + writeProfileStateDatabaseWithAcceptedLegacyJson('work', targetJson) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect(readFileSync(profileDataPath('personal'), 'utf-8')).toBe(sourceJson) + expect(readFileSync(profileDataPath('work'), 'utf-8')).toBe(targetJson) + }) + + it('migrates a legacy JSON target before moving a SQLite-backed project', async () => { + const sourceState = makeState({ repos: [makeRepo()] }) + writeProfileStateDatabase('personal', sourceState) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + expect( + transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + testState.dir + ) + ).toMatchObject({ status: 'transferred', mode: 'move' }) + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect(readProfileState('work').repos).toHaveLength(0) + }) + + it.each([undefined, 'prepared', 'target-committed'])( + 'replays a move after the target commit with legacy phase=%s', + async (phase) => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + const stateFile = await import('./profile-project-state-file') + const source = stateFile.readProfileStateWithRevision('personal', testState.dir) + const target = stateFile.readProfileStateWithRevision('work', testState.dir) + const sourceAfter = makeState() + const targetAfter = makeState({ repos: [makeRepo()] }) + const intent = createProfileProjectMoveIntent({ + sourceProfileId: 'personal', + targetProfileId: 'work', + source, + target, + sourceAfterJson: JSON.stringify(sourceAfter), + targetAfterJson: JSON.stringify(targetAfter) + }) + const historicalIntent = phase === undefined ? intent : { ...intent, phase } + persistProfileProjectMoveIntent(testState.dir, historicalIntent) + stateFile.writeProfileState('work', testState.dir, targetAfter, { + expectedRevision: target.revision + }) + + expect(recoverPendingProfileProjectMoves(testState.dir)).toBe(1) + expect(recoverPendingProfileProjectMoves(testState.dir)).toBe(0) + expect(readProfileStateDatabase('personal').repos).toHaveLength(0) + expect(readProfileStateDatabase('work').repos).toHaveLength(1) + expect( + readdirSync(join(testState.dir, 'profile-move-intents')).filter((file) => + file.endsWith('.json') + ) + ).toEqual([]) + } + ) + + it('refuses a move intent whose after-state bytes no longer match their hashes', async () => { + writeProfileStateDatabase('personal', makeState({ repos: [makeRepo()] })) + writeProfileStateDatabase('work', makeState()) + const stateFile = await import('./profile-project-state-file') + const source = stateFile.readProfileStateWithRevision('personal', testState.dir) + const target = stateFile.readProfileStateWithRevision('work', testState.dir) + const intent = createProfileProjectMoveIntent({ + sourceProfileId: 'personal', + targetProfileId: 'work', + source, + target, + sourceAfterJson: JSON.stringify(makeState()), + targetAfterJson: JSON.stringify(makeState({ repos: [makeRepo()] })) + }) + persistProfileProjectMoveIntent(testState.dir, intent) + stateFile.writeProfileState('work', testState.dir, makeState({ repos: [makeRepo()] }), { + expectedRevision: target.revision + }) + const intentPath = join(testState.dir, 'profile-move-intents', `${intent.id}.json`) + const tampered = JSON.parse(readFileSync(intentPath, 'utf8')) + tampered.sourceAfterJson = JSON.stringify( + makeState({ settings: { ...makeState().settings, theme: 'light' } }) + ) + writeFileSync(intentPath, JSON.stringify(tampered), 'utf8') + + expect(() => recoverPendingProfileProjectMoves(testState.dir)).toThrow(/intent is malformed/) + expect(readProfileStateDatabase('personal').repos).toHaveLength(1) + expect(existsSync(intentPath)).toBe(true) + }) }) diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts index 75022d3ad65..18e3a3e90fe 100644 --- a/src/main/orca-profiles/profile-project-transfer.ts +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -3,7 +3,13 @@ import type { TransferOrcaProfileProjectResult } from '../../shared/orca-profiles' import { getOrcaProfileListState } from './profile-index-store' -import { readProfileState, writeProfileState } from './profile-project-state-file' +import { writeSerializedProfileState } from './profile-project-state-file' +import { + readProfileProjectTransferState, + writeProfileProjectDomainChanges +} from './profile-project-domain-state' +import { prepareProfileProjectDomainChanges } from './profile-project-domain-changes' +import { createProfileProjectDomainMoveIntent } from './profile-project-domain-move-intent' import { removeSourceRepo } from './profile-project-source-removal' import { applyPayloadToTarget, @@ -11,6 +17,13 @@ import { createTransferPayload } from './profile-project-transfer-payload' import { repoPhysicalKey } from './profile-project-worktree-identity' +import { migrateProfileProjectTransferParticipant } from './profile-project-transfer-migration' +import { + persistProfileProjectMoveIntent, + recoverPendingProfileProjectMoves, + removeProfileProjectMoveIntent, + type ProfileProjectMoveIntent +} from './profile-project-move-intent' function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void { const profiles = getOrcaProfileListState(userDataPath).profiles @@ -30,14 +43,15 @@ export function transferOrcaProfileProject( args: TransferOrcaProfileProjectArgs, userDataPath: string ): TransferOrcaProfileProjectResult { + recoverPendingProfileProjectMoves(userDataPath) assertKnownProfiles(args, userDataPath) - const sourceState = readProfileState(args.sourceProfileId, userDataPath) - const targetState = readProfileState(args.targetProfileId, userDataPath) - const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId) + let sourceSnapshot = readProfileProjectTransferState(args.sourceProfileId, userDataPath) + let targetSnapshot = readProfileProjectTransferState(args.targetProfileId, userDataPath) + const sourceRepo = sourceSnapshot.state.repos.find((repo) => repo.id === args.repoId) if (!sourceRepo) { throw new Error('unknown_source_repo') } - const duplicate = targetState.repos.find( + const duplicate = targetSnapshot.state.repos.find( (repo) => repoPhysicalKey(repo) === repoPhysicalKey(sourceRepo) ) if (duplicate) { @@ -50,6 +64,27 @@ export function transferOrcaProfileProject( } } + let moveIntent: ProfileProjectMoveIntent | undefined + if (sourceSnapshot.revision !== undefined && targetSnapshot.revision === undefined) { + targetSnapshot = migrateProfileProjectTransferParticipant( + args.targetProfileId, + userDataPath, + targetSnapshot + ) + } else if ( + args.mode === 'move' && + sourceSnapshot.revision === undefined && + targetSnapshot.revision !== undefined + ) { + sourceSnapshot = migrateProfileProjectTransferParticipant( + args.sourceProfileId, + userDataPath, + sourceSnapshot + ) + } + + const sourceState = sourceSnapshot.state + const targetState = targetSnapshot.state const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') const payload = createTransferPayload({ sourceState, @@ -57,14 +92,62 @@ export function transferOrcaProfileProject( targetRepo, includeSessions: args.mode === 'move' }) - writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload)) - if (args.mode === 'move') { - writeProfileState( - args.sourceProfileId, + const targetAfterState = applyPayloadToTarget(targetState, payload) + const sourceAfterState = + args.mode === 'move' ? removeSourceRepo(sourceState, sourceRepo.id) : undefined + const targetChanges = + targetSnapshot.documents !== undefined && targetSnapshot.revision !== undefined + ? prepareProfileProjectDomainChanges( + targetSnapshot.revision, + targetSnapshot.documents, + targetAfterState + ) + : undefined + const sourceChanges = + sourceAfterState !== undefined && + sourceSnapshot.documents !== undefined && + sourceSnapshot.revision !== undefined + ? prepareProfileProjectDomainChanges( + sourceSnapshot.revision, + sourceSnapshot.documents, + sourceAfterState + ) + : undefined + if (sourceChanges !== undefined) { + if (targetChanges === undefined) { + throw new Error('SQLite profile move requires two SQLite participants') + } + moveIntent = createProfileProjectDomainMoveIntent({ + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + source: sourceChanges, + target: targetChanges + }) + persistProfileProjectMoveIntent(userDataPath, moveIntent) + } + if (targetChanges !== undefined) { + writeProfileProjectDomainChanges(args.targetProfileId, userDataPath, targetChanges) + } else { + writeSerializedProfileState( + args.targetProfileId, userDataPath, - removeSourceRepo(sourceState, sourceRepo.id) + JSON.stringify(targetAfterState) ) } + if (sourceAfterState !== undefined) { + if (sourceChanges !== undefined) { + writeProfileProjectDomainChanges(args.sourceProfileId, userDataPath, sourceChanges) + } else { + writeSerializedProfileState( + args.sourceProfileId, + userDataPath, + JSON.stringify(sourceAfterState) + ) + } + if (moveIntent) { + removeProfileProjectMoveIntent(userDataPath, moveIntent.id) + } + } return { status: 'transferred', mode: args.mode, diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts index 81dc990b0e1..1e8de1a367e 100644 --- a/src/main/orca-profiles/profile-storage-paths.ts +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -1,12 +1,17 @@ import { getAppEnvironment } from '../../shared/app-environment' +import { + getOrcaProfileDataFile as getSharedOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile as getSharedOrcaProfileStateDatabaseFile +} from '../../shared/profile-state-storage-paths' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' import { join } from 'node:path' const LEGACY_DATA_FILE_NAME = 'orca-data.json' const LEGACY_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const PROFILE_INDEX_FILE_NAME = 'orca-profile-index.json' -const PROFILE_DATA_FILE_NAME = 'orca-data.json' const PROFILE_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const PROFILE_DIRECTORY_NAME = 'profiles' +const PROFILE_MOVE_INTENT_DIRECTORY_NAME = 'profile-move-intents' export const LEGACY_BACKUP_COUNT = 5 @@ -31,6 +36,11 @@ export function getOrcaProfilesDirectory(userDataPath = getProfileUserDataPath() return join(userDataPath, PROFILE_DIRECTORY_NAME) } +/** Durable cross-profile move intents live outside either profile database. */ +export function getOrcaProfileMoveIntentDirectory(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_MOVE_INTENT_DIRECTORY_NAME) +} + export function getOrcaProfileDirectory( profileId: string, userDataPath = getProfileUserDataPath() @@ -42,7 +52,26 @@ export function getOrcaProfileDataFile( profileId: string, userDataPath = getProfileUserDataPath() ): string { - return join(getOrcaProfileDirectory(profileId, userDataPath), PROFILE_DATA_FILE_NAME) + return getSharedOrcaProfileDataFile(profileId, userDataPath) +} + +/** + * Return the future profile-state database path without changing the legacy + * JSON path used by the current Store and its sidecars. + */ +export function getOrcaProfileStateDatabaseFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return getSharedOrcaProfileStateDatabaseFile(profileId, userDataPath) +} + +export function hasOrcaProfileStateDatabase( + profileId: string, + userDataPath = getProfileUserDataPath() +): boolean { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + return hasProfileStateDatabaseFiles(databaseFile) } export function getOrcaProfileBrowserSessionMetaFile( diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts new file mode 100644 index 00000000000..63ec99ca627 --- /dev/null +++ b/src/main/orcad/orcad-entry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushOrcadProfileStoreForShutdown } from './orcad-lifecycle' + +describe('orcad profile-state shutdown', () => { + it('flushes durably before closing the profile store', async () => { + const events: string[] = [] + const store = { + flushFinalOrThrowAsync: vi.fn(async () => { + events.push('flush') + }), + freezeWritesAsync: vi.fn(async () => { + events.push('freeze') + }) + } + + await flushOrcadProfileStoreForShutdown(store) + + expect(store.flushFinalOrThrowAsync).toHaveBeenCalledExactlyOnceWith({ + exportJsonCompatibility: true + }) + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() + expect(events).toEqual(['flush', 'freeze']) + }) + + it('closes the profile store even when the durable flush fails', async () => { + const flushError = new Error('profile flush failed') + const freezeWritesAsync = vi.fn(async () => {}) + const store = { + flushFinalOrThrowAsync: vi.fn(async () => { + throw flushError + }), + freezeWritesAsync + } + + await expect(flushOrcadProfileStoreForShutdown(store)).rejects.toBe(flushError) + expect(freezeWritesAsync).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index edc4426a2a9..70a028419ce 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -15,17 +15,16 @@ import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' -import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' import { describeOrcadBindExposure, OrcadBindAddressError, resolveOrcadBindHost } from './orcad-bind-address' -import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' -import { startOrcadWithLifecycle } from './orcad-lifecycle' +import { OrcadInstanceLockError } from './orcad-instance-lock' +import { flushOrcadProfileStoreForShutdown, startOrcadWithHost } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' +import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -110,27 +109,10 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const userDataPath = resolveUserDataPath() - // Why before anything else touches the root: the profile index, the store and the daemon - // runtime dir all live under it, and two orcads sharing them corrupt state silently. This - // is also the last point at which refusing costs nothing. - const instanceLock = acquireOrcadInstanceLock(userDataPath) - const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) - setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { - headless: browserProvider !== null, - ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) - }) - return startOrcadWithLifecycle( + return startOrcadWithHost( + resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), - async () => { - try { - await browserProvider?.stop() - } finally { - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - } - } + () => runOrcadQuitHandlers() ) } @@ -145,10 +127,7 @@ async function startOrcadRuntime( const { getAppEnvironment } = await import('../../shared/app-environment') const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') - const { Store } = await import('../persistence/loading-store/store') - const { ensureActiveOrcaProfile, initOrcaProfilePaths } = - await import('../orca-profiles/profile-index-store') - const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') const { collectOrcadHealth } = await import('./orcad-health') @@ -162,6 +141,9 @@ async function startOrcadRuntime( await import('../runtime/agent-status-observed-pane-identity') let rpc: InstanceType | null = null + let profileStoreForShutdown: + | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } + | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} registerCleanup(async () => { @@ -169,13 +151,21 @@ async function startOrcadRuntime( await rpc?.stop() } finally { try { - // Why disconnect and not shut down: the daemon must outlive this process, or an - // orcad restart goes back to killing every running terminal. - await stopOrcadDaemon() + // Stop accepting RPC writes before the final persistence barrier. A SQLite-backed + // orcad has no JSON mirror to absorb a debounced write after SIGTERM. + if (profileStoreForShutdown) { + await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + } } finally { - uninstallObservedStatusIdentity() - uninstallHookStatusRepublish() - agentHookServer.stop() + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } } } }) @@ -183,8 +173,8 @@ async function startOrcadRuntime( const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') const runtimeUserDataPath = getAppEnvironment().getPath('userData') - initOrcaProfilePaths() - const profile = ensureActiveOrcaProfile(runtimeUserDataPath) + const { store: profileStore, authority: profileStateAuthority } = + await createOrcadProfileStateStartup(runtimeUserDataPath) const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` @@ -192,15 +182,14 @@ async function startOrcadRuntime( // a server that pairs and lists nothing looks healthy and is not. // Why: orcad IS the runtime authority — loading as 'desktop' would classify its // own runtime-scheduled automations as ambiguous mirrors and orphan them. - const store = new Store({ dataFile: profile.dataFile, storageAuthority: 'runtime' }) + profileStoreForShutdown = profileStore // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted, // which is safe but silently discards accept records on every launch. - initSshHostKeyStoreFile(profile.dataFile) uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(store.getSettings())) { + if (isAgentStatusHooksEnabled(profileStore.getSettings())) { await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) } @@ -213,7 +202,7 @@ async function startOrcadRuntime( // constructed, and the deps hook is only ever called later, from an RPC. let sessionSearch: { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null = null - const runtime = new OrcaRuntimeService(store, undefined, { + const runtime = new OrcaRuntimeService(profileStore, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. getLocalProvider: () => getLocalPtyProvider(), @@ -252,7 +241,7 @@ async function startOrcadRuntime( reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs // unconditionally, so an unchanged value would restart a healthy index. applySessionSearchSettings: (before, after) => { @@ -266,7 +255,7 @@ async function startOrcadRuntime( const { installOrcadSessionSearchService } = await import('./orcad-session-search') sessionSearch = await installOrcadSessionSearchService({ userDataPath: runtimeUserDataPath, - getSettings: () => store.getSettings() + getSettings: () => profileStore.getSettings() }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) @@ -284,7 +273,13 @@ async function startOrcadRuntime( // Codex-home and Claude-auth preparation are left unset: both are desktop account // flows. A launch that needs one fails with its own message rather than silently // spawning an unauthenticated agent. - await registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + await registerHeadlessPtyRuntime( + runtime, + undefined, + () => profileStore.getSettings(), + undefined, + profileStore + ) // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves // restored orchestration rows claiming an authority this host never took over. @@ -356,7 +351,7 @@ async function startOrcadRuntime( // Why in the readiness payload: this is the one message a supervisor and a deploy // transaction both read, and a green orcad with a dead daemon is exactly the // looks-healthy-but-useless state they must not activate. - health: await collectOrcadHealth(getAppEnvironment().getVersion()) + health: await collectOrcadHealth(getAppEnvironment().getVersion(), profileStateAuthority) } await new ServeReadinessPublisher().publish(readiness, { @@ -382,7 +377,9 @@ export const ORCAD_EXIT_CONFIGURATION = 78 export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 export function resolveOrcadExitCode(error: unknown): number { - return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + return error instanceof OrcadInstanceLockError || + error instanceof OrcadBindAddressError || + error instanceof ProfileStateAccessError ? ORCAD_EXIT_CONFIGURATION : ORCAD_EXIT_FAILED } diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index c77f18477f0..70e02b37e87 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -134,6 +134,24 @@ describe('collectOrcadHealth', () => { expect(health.platform).toBe(process.platform) expect(health.terminalDaemon.state).toBe('live') }) + + it('includes bounded profile-state authority metadata when supplied', async () => { + const health = await collectOrcadHealth('1.2.3', { + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: false + }) + + expect(health.profileStateAuthority).toEqual({ + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'orcad', + migrated: false + }) + }) }) describe('computeOrcadBuildHash', () => { diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index d9a567531ac..0c3a3525171 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -17,6 +17,7 @@ import { getDaemonEndpointFacts, readDaemonPidRecord } from '../daemon/daemon-init' +import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' /** * How much a green self-test actually proves. @@ -64,6 +65,8 @@ export type OrcadHealth = { arch: string pid: number terminalDaemon: TerminalDaemonHealth + /** The low-cardinality profile-state authority selected during startup, when available. */ + profileStateAuthority?: OrcadProfileStateAuthoritySelection } /** @@ -146,7 +149,10 @@ export async function collectTerminalDaemonHealth(): Promise { +export async function collectOrcadHealth( + buildVersion: string, + profileStateAuthority?: OrcadProfileStateAuthoritySelection +): Promise { return { buildHash: computeOrcadBuildHash(), buildVersion, @@ -155,6 +161,7 @@ export async function collectOrcadHealth(buildVersion: string): Promise { it('accepts --bind and leaves it unset when absent', () => { @@ -38,6 +39,9 @@ describe('resolveOrcadExitCode', () => { resolveOrcadExitCode(new OrcadInstanceLockError('orcad_instance_lock_held', 'held')) ).toBe(ORCAD_EXIT_CONFIGURATION) expect(resolveOrcadExitCode(new OrcadBindAddressError('bad'))).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new ProfileStateAccessError('recovery interrupted'))).toBe( + ORCAD_EXIT_CONFIGURATION + ) expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) @@ -57,7 +61,7 @@ describe('orcad lifecycle cleanup', () => { ).rejects.toThrow('startup failed') expect(cleanupRuntime).toHaveBeenCalledOnce() - expect(cleanupHost).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledExactlyOnceWith(true) }) it('preserves the startup error when rollback also fails', async () => { @@ -96,4 +100,17 @@ describe('orcad lifecycle cleanup', () => { expect(cleanupRuntime).toHaveBeenCalledOnce() expect(cleanupHost).toHaveBeenCalledOnce() }) + + it('keeps the host aware of failed runtime teardown so it cannot release profile admission', async () => { + const failure = new Error('profile writer still running') + const cleanupHost = vi.fn(async () => {}) + const handle = await startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(async () => { + throw failure + }) + return {} + }, cleanupHost) + await expect(handle.stop()).rejects.toBe(failure) + expect(cleanupHost).toHaveBeenCalledExactlyOnceWith(false) + }) }) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 913a21c4874..208b805ec79 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,3 +1,11 @@ +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { acquireOrcadInstanceLock } from './orcad-instance-lock' +import { + acquireProfileStateRuntimeAdmission, + type ProfileStateRuntimeAdmission +} from '../persistence/profile-state/profile-state-access' + function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { let completion: Promise | null = null return () => { @@ -8,14 +16,16 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi export async function startOrcadWithLifecycle( start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, - cleanupHost: () => Promise + cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise ): Promise }> { let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { + let runtimeCleanupSucceeded = false try { await cleanupRuntime() + runtimeCleanupSucceeded = true } finally { - await cleanupHost() + await cleanupHost(runtimeCleanupSucceeded) } }) try { @@ -33,3 +43,53 @@ export async function startOrcadWithLifecycle( throw error } } + +/** Keep profile admission until every runtime writer has stopped. */ +export async function startOrcadWithHost( + userDataPath: string, + start: (registerCleanup: (cleanup: () => Promise) => void) => Promise, + runQuitHandlers: () => void +): Promise }> { + const instanceLock = acquireOrcadInstanceLock(userDataPath) + let admission: ProfileStateRuntimeAdmission | undefined + let browserProvider: Awaited> | undefined + return startOrcadWithLifecycle( + async (registerCleanup) => { + admission = acquireProfileStateRuntimeAdmission(userDataPath) + browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + const provider = browserProvider + setRuntimeBrowserCommandsFactory(provider?.factory ?? null, { + headless: provider !== null, + ...(provider ? { isAvailable: () => provider.isAvailable() } : {}) + }) + return start(registerCleanup) + }, + async (runtimeCleanupSucceeded) => { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runQuitHandlers() + try { + // Failed teardown excludes recovery until the process actually exits. + if (runtimeCleanupSucceeded) { + admission?.release() + } + } finally { + instanceLock.release() + } + } + } + ) +} + +export async function flushOrcadProfileStoreForShutdown(store: { + flushFinalOrThrowAsync(options?: { exportJsonCompatibility?: boolean }): Promise + freezeWritesAsync(): Promise +}): Promise { + try { + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + } finally { + await store.freezeWritesAsync() + } +} diff --git a/src/main/orcad/orcad-profile-state-startup.test.ts b/src/main/orcad/orcad-profile-state-startup.test.ts new file mode 100644 index 00000000000..c0bea5de6e3 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createProfileStateStoreForStartupMock, + orcadProfileStateAuthorityModeMock, + emitMock, + ensureActiveOrcaProfileMock, + initOrcaProfilePathsMock, + initSshHostKeyStoreFileMock +} = vi.hoisted(() => ({ + createProfileStateStoreForStartupMock: vi.fn(), + orcadProfileStateAuthorityModeMock: vi.fn(), + emitMock: vi.fn(), + ensureActiveOrcaProfileMock: vi.fn(), + initOrcaProfilePathsMock: vi.fn(), + initSshHostKeyStoreFileMock: vi.fn() +})) + +vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ + createProfileStateStoreForStartup: createProfileStateStoreForStartupMock, + orcadProfileStateAuthorityMode: orcadProfileStateAuthorityModeMock +})) +vi.mock('../orca-profiles/profile-index-store', () => ({ + ensureActiveOrcaProfile: ensureActiveOrcaProfileMock, + initOrcaProfilePaths: initOrcaProfilePathsMock +})) +vi.mock('../ssh/ssh-host-key-store', () => ({ + initSshHostKeyStoreFile: initSshHostKeyStoreFileMock +})) +vi.mock('./orcad-profile-state-telemetry', () => ({ + emitOrcadProfileStateAuthoritySelected: emitMock +})) + +const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup') + +beforeEach(() => { + vi.resetAllMocks() + ensureActiveOrcaProfileMock.mockReturnValue({ + dataFile: '/tmp/profile/orca-data.json', + stateDatabaseFile: '/tmp/profile/profile-state.db', + profile: { id: 'profile-1' } + }) + orcadProfileStateAuthorityModeMock.mockReturnValue('sqlite-candidate') +}) + +describe('orcad profile-state startup', () => { + it('selects the capable authority once and publishes bounded metadata', async () => { + const store = { getSettings: vi.fn() } + createProfileStateStoreForStartupMock.mockReturnValue({ + store, + authority: { readSerializedState: vi.fn() }, + backend: 'sqlite', + classification: 'json-only', + migrated: true + }) + + const result = await createOrcadProfileStateStartup('/tmp/user-data') + + expect(initOrcaProfilePathsMock).toHaveBeenCalledOnce() + expect(ensureActiveOrcaProfileMock).toHaveBeenCalledWith('/tmp/user-data') + expect(initSshHostKeyStoreFileMock).toHaveBeenCalledWith('/tmp/profile/orca-data.json') + + expect(createProfileStateStoreForStartupMock).toHaveBeenCalledWith({ + dataFile: '/tmp/profile/orca-data.json', + databaseFile: '/tmp/profile/profile-state.db', + profileId: 'profile-1', + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + expect(result.store).toBe(store) + expect(result.authority).toEqual({ + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: true + }) + expect(emitMock).toHaveBeenCalledWith(result.authority) + }) + + it('publishes nothing before the profile writer is ready', async () => { + let refuse = (_error: Error) => {} + createProfileStateStoreForStartupMock.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + refuse = reject + }) + ) + const startup = createOrcadProfileStateStartup('/tmp/user-data') + const failure = new Error('writer startup refused') + const rejected = expect(startup).rejects.toBe(failure) + expect(initSshHostKeyStoreFileMock).not.toHaveBeenCalled() + expect(emitMock).not.toHaveBeenCalled() + refuse(failure) + await rejected + }) + + it('closes a ready writer if sidecar initialization fails', async () => { + const store = { freezeWritesAsync: vi.fn(async () => {}) } + createProfileStateStoreForStartupMock.mockResolvedValueOnce({ + store, + backend: 'sqlite', + classification: 'sqlite-only', + migrated: false + }) + const failure = new Error('sidecar initialization refused') + initSshHostKeyStoreFileMock.mockImplementationOnce(() => { + throw failure + }) + await expect(createOrcadProfileStateStartup('/tmp/user-data')).rejects.toBe(failure) + expect(store.freezeWritesAsync).toHaveBeenCalledOnce() + expect(emitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-profile-state-startup.ts b/src/main/orcad/orcad-profile-state-startup.ts new file mode 100644 index 00000000000..78b0339c715 --- /dev/null +++ b/src/main/orcad/orcad-profile-state-startup.ts @@ -0,0 +1,64 @@ +import { + createProfileStateStoreForStartup, + orcadProfileStateAuthorityMode +} from '../persistence/profile-state/profile-state-startup-authority' +import type { ProfileStateStoreFactoryResult } from '../persistence/profile-state/profile-state-store-factory' +import { ensureActiveOrcaProfile, initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { emitOrcadProfileStateAuthoritySelected } from './orcad-profile-state-telemetry' + +export type OrcadProfileStateProfile = { + dataFile: string + stateDatabaseFile: string + profile: { id: string } +} + +export type OrcadProfileStateStartup = { + store: ProfileStateStoreFactoryResult['store'] + authority: { + backend: ProfileStateStoreFactoryResult['backend'] + classification: ProfileStateStoreFactoryResult['classification'] + authority_mode: ReturnType + runtime: 'orcad' + migrated: boolean + } +} + +/** Build the headless Store and publish its authority selection at one Node-only seam. */ +export async function createOrcadProfileStateStartup( + userDataPath: string +): Promise { + initOrcaProfilePaths() + const profile = ensureActiveOrcaProfile(userDataPath) + const authorityMode = orcadProfileStateAuthorityMode() + const result = await createProfileStateStoreForStartup({ + dataFile: profile.dataFile, + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'orcad', + authorityMode, + storageAuthority: 'runtime' + }) + const authority = { + backend: result.backend, + classification: result.classification, + authority_mode: authorityMode, + runtime: 'orcad' as const, + migrated: result.migrated + } + try { + initSshHostKeyStoreFile(profile.dataFile) + emitOrcadProfileStateAuthoritySelected(authority) + return { store: result.store, authority } + } catch (error) { + try { + await result.store.freezeWritesAsync() + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } +} diff --git a/src/main/orcad/orcad-profile-state-telemetry.test.ts b/src/main/orcad/orcad-profile-state-telemetry.test.ts new file mode 100644 index 00000000000..9d79952a14c --- /dev/null +++ b/src/main/orcad/orcad-profile-state-telemetry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { + emitOrcadProfileStateAuthoritySelected, + formatOrcadProfileStateAuthoritySelected, + type OrcadProfileStateAuthoritySelection +} from './orcad-profile-state-telemetry' + +const selection: OrcadProfileStateAuthoritySelection = { + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: true +} + +describe('orcad profile-state telemetry', () => { + it('formats a bounded machine-readable authority selection event', () => { + expect(JSON.parse(formatOrcadProfileStateAuthoritySelected(selection).slice(18))).toEqual({ + event: 'profile_state_authority_selected', + ...selection + }) + }) + + it('strips unexpected runtime fields before writing the record', () => { + const selectionWithRuntimeFields = Object.assign({}, selection, { + database_path: '/private/profile-state.db' + }) + const line = formatOrcadProfileStateAuthoritySelected(selectionWithRuntimeFields) + expect(line).not.toContain('database_path') + }) + + it('sends the event to the supplied sink', () => { + const sink = vi.fn() + emitOrcadProfileStateAuthoritySelected(selection, sink) + expect(sink).toHaveBeenCalledOnce() + expect(sink).toHaveBeenCalledWith(formatOrcadProfileStateAuthoritySelected(selection)) + }) + + it('never lets a failing sink block startup', () => { + expect(() => + emitOrcadProfileStateAuthoritySelected(selection, () => { + throw new Error('closed stderr') + }) + ).not.toThrow() + }) +}) diff --git a/src/main/orcad/orcad-profile-state-telemetry.ts b/src/main/orcad/orcad-profile-state-telemetry.ts new file mode 100644 index 00000000000..e1cec920b6b --- /dev/null +++ b/src/main/orcad/orcad-profile-state-telemetry.ts @@ -0,0 +1,47 @@ +import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' +import type { ProfileStateStoreAuthorityMode } from '../persistence/profile-state/profile-state-store-factory' + +/** + * The low-cardinality profile-state selection facts that a headless host can publish safely. + * Paths, profile IDs, and serialized state deliberately stay out of this record. + */ +export type OrcadProfileStateAuthoritySelection = { + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + authority_mode: ProfileStateStoreAuthorityMode + runtime: 'orcad' + migrated: boolean +} + +export type OrcadProfileStateTelemetrySink = (line: string) => void + +/** Render one machine-readable stderr line for fleet log collection. */ +export function formatOrcadProfileStateAuthoritySelected( + selection: OrcadProfileStateAuthoritySelection +): string { + // Keep the wire shape explicit even if a future caller passes a structurally-compatible + // object with extra runtime fields. Paths, IDs, and serialized state must never leak here. + return `[orcad-telemetry] ${JSON.stringify({ + event: 'profile_state_authority_selected', + backend: selection.backend, + classification: selection.classification, + authority_mode: selection.authority_mode, + runtime: selection.runtime, + migrated: selection.migrated + })}` +} + +/** + * Publish authority selection without importing Electron or the desktop PostHog client. + * Logging is best-effort: observability must never prevent an orcad host from serving. + */ +export function emitOrcadProfileStateAuthoritySelected( + selection: OrcadProfileStateAuthoritySelection, + sink: OrcadProfileStateTelemetrySink = (line) => console.error(line) +): void { + try { + sink(formatOrcadProfileStateAuthoritySelected(selection)) + } catch { + // A closed stderr or custom supervisor sink cannot turn a successful startup into a failure. + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index a8fbbc9fe16..21ad507c016 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -6,12 +6,15 @@ import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' import { createPushHostKeypair } from '../runtime/push/push-host-challenge-fixtures' +import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' +import { profileStateAccessPaths } from '../persistence/profile-state/profile-state-access-owner' const state = vi.hoisted(() => ({ root: '', controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) })) @@ -20,7 +23,7 @@ vi.mock('./orcad-app-paths', () => ({ resolveOrcadPath: () => state.root, resolveUserDataPath: () => state.root })) -vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: async () => null })) +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ startOrcadDaemon: async () => {}, @@ -33,16 +36,29 @@ vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: () => null, getSshPtyProvider: () => null })) -vi.mock('../persistence/loading-store/store', () => ({ - Store: class { - getSettings() { - return {} +vi.mock('./orcad-profile-state-startup', () => ({ + createOrcadProfileStateStartup: async () => ({ + store: { + getSettings: () => ({}), + flushFinalOrThrowAsync: async () => {}, + freezeWritesAsync: async () => {} + }, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false } - } + }) })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, - ensureActiveOrcaProfile: () => ({ dataFile: join(state.root, 'profile.json') }) + ensureActiveOrcaProfile: () => ({ + dataFile: join(state.root, 'profile.json'), + stateDatabaseFile: join(state.root, 'profile-state.db'), + profile: { id: 'headless-profile' } + }) })) vi.mock('../ssh/ssh-host-key-store', () => ({ initSshHostKeyStoreFile() {} })) vi.mock('../server/serve-readiness', () => ({ @@ -109,6 +125,19 @@ afterEach(() => { vi.clearAllMocks() }) +it('refuses recovery overlap before initializing the browser provider or runtime', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-recovery-')) + const maintenance = acquireProfileStateMaintenance(state.root) + const { startOrcad } = await import('./orcad-entry') + try { + await expect(startOrcad({ noPairing: true, json: true })).rejects.toThrow() + expect(state.browserProvider).not.toHaveBeenCalled() + expect(state.rpcStarted).toBe(false) + } finally { + maintenance.release() + } +}) + it('starts push after RPC identity is available and stops dispatch on shutdown', async () => { state.root = mkdtempSync(join(tmpdir(), 'orca-headless-push-')) state.controller = new RuntimeMobileNotificationController() @@ -140,8 +169,19 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', } finally { await host.stop() } + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) }) + +it('releases admission when host setup fails before a runtime exists', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-setup-failure-')) + state.browserProvider.mockRejectedValueOnce(new Error('browser setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('browser setup failed') + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/persistence-flush-and-save-scheduling.test.ts b/src/main/persistence-flush-and-save-scheduling.test.ts index 2d6180d5fa8..c40b092a02a 100644 --- a/src/main/persistence-flush-and-save-scheduling.test.ts +++ b/src/main/persistence-flush-and-save-scheduling.test.ts @@ -85,6 +85,26 @@ describe('Store', () => { expect(persisted.repos[0].id).toBe('r1') }) + it('durably commits an exact JSON operation before a following microtask changes generation', async () => { + const store = await createStore() + const originalTabId = store.getWorkspaceSession().activeTabId + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + queueMicrotask(() => { + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'newer-tab' }) + }) + return { value: undefined } + }) + expect(readDataFile()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: originalTabId } + }) + expect(store.getWorkspaceSession().activeTabId).toBe('newer-tab') + await store.flushPendingOrThrowAsync() + expect(readDataFile()).toHaveProperty(['workspaceSession', 'activeTabId'], 'newer-tab') + store.freezeWrites() + }) + it('flush remains safe when a debounced save is also pending', async () => { vi.useFakeTimers() try { @@ -220,11 +240,11 @@ describe('Store', () => { leafId: TEST_LEAF_1, ptyId: 'daemon-pty' } - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) const inoBefore = statSync(dataFile()).ino // Warm-restart re-bind storm: an identical binding re-asserted with a sync flush must not rewrite. - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) expect(statSync(dataFile()).ino).toBe(inoBefore) }) @@ -432,14 +452,14 @@ describe('Store', () => { async (hostId) => { const store = await createStore() store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) const inoBefore = statSync(dataFile()).ino - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration const cloneSpy = vi.spyOn(globalThis, 'structuredClone') - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(cloneSpy).not.toHaveBeenCalled() expect(statSync(dataFile()).ino).toBe(inoBefore) } @@ -448,31 +468,37 @@ describe('Store', () => { it('flushes while a save is pending, and the sync hash match makes the next call durable', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) const inoBefore = statSync(dataFile()).ino // Bumps the write generation without changing any binding. store.setWorkspaceSession({ ...store.getWorkspaceSession() }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(statSync(dataFile()).ino).toBe(inoBefore) // Without the writeToDiskSync counter fix the hash-match flush leaves the durable // generation one behind and this third bind would flush again. - expect(store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('falls through on an incarnation change and persists the new incarnation', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding({ ...binding, incarnationId: 'a' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, incarnationId: 'b' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toHaveProperty( ['workspaceSession', 'terminalPtyIncarnationsByPaneKey', paneKey], 'b' @@ -482,18 +508,19 @@ describe('Store', () => { it('does not acknowledge an unpersisted binding published after the final flush', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) await store.flushAsync() const next = boundSession() next.tabsByWorktree[WORKTREE][0].ptyId = 'pty-after-quit' next.terminalLayoutsByTabId.tab1.ptyIdsByLeafId = { [TEST_LEAF_1]: 'pty-after-quit' } - store.setWorkspaceSession(next) + expect(() => store.setWorkspaceSession(next)).toThrow('finalization') + Object.assign(store.getWorkspaceSession(), next) expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE][0].ptyId).toBe('pty-after-quit') - expect(() => store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' })).toThrow( - 'Cannot synchronously flush after final persistence has started' - ) + await expect( + store.persistPtyBinding({ ...binding, ptyId: 'pty-after-quit' }) + ).rejects.toThrow('Cannot mutate finalized profile persistence') expect(readDataFile()).toHaveProperty( ['workspaceSession', 'tabsByWorktree', WORKTREE, '0', 'ptyId'], 'pty-1' @@ -503,12 +530,14 @@ describe('Store', () => { it('treats an undefined incarnation against a recorded one as a miss', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding({ ...binding, incarnationId: 'a' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'a' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('falls through on a tombstone and lets the write path clear it', async () => { @@ -532,11 +561,13 @@ describe('Store', () => { expect( store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] ).toBeDefined() - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect( store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey] ).toBeUndefined() @@ -547,20 +578,22 @@ describe('Store', () => { store.setWorkspaceSession( boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) ) - store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-stale' }) const revisionBefore = store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1 ?? 0 - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-live', expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } }) ).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo1).toBe( revisionBefore + 1 ) @@ -571,8 +604,8 @@ describe('Store', () => { store.setWorkspaceSession( boundSession({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-1' } }) ) - store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + await store.persistPtyBinding({ ...binding, incarnationId: 'inc-1' }) + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration const refusals = [ { @@ -583,9 +616,9 @@ describe('Store', () => { { ...binding, tabId: 'missing-tab', mayCreate: false } ] for (const refusal of refusals) { - expect(store.persistPtyBinding(refusal)).toBe(false) + expect(await store.persistPtyBinding(refusal)).toBe(false) } - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) }) it.each([undefined, 'ssh:ssh-1', 'runtime:runtime-1'])( @@ -593,14 +626,16 @@ describe('Store', () => { async (hostId) => { const store = await createStore() store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding, hostId)).toBe(true) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toMatchObject({ repos: expect.arrayContaining([expect.objectContaining({ id: 'r-dirty' })]) }) @@ -610,27 +645,31 @@ describe('Store', () => { it('flushes again once the session object is replaced', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) // A renderer publish schedules another save, so global durability must be re-established. store.setWorkspaceSession({ ...store.getWorkspaceSession() }) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) }) it('flushes a changed pty for a pane whose old binding was durable', async () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) store.addRepo(makeRepo({ id: 'r-dirty', path: '/dirty' })) - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) + expect(await store.persistPtyBinding({ ...binding, ptyId: 'pty-next' })).toBe(true) - expect(flushSpy).toHaveBeenCalledTimes(1) + expect(runtimeCounters(store).lastDurableWriteGeneration).toBeGreaterThan( + durableGenerationBefore + ) expect(readDataFile()).toHaveProperty( ['workspaceSession', 'terminalLayoutsByTabId', 'tab1', 'ptyIdsByLeafId', TEST_LEAF_1], 'pty-next' @@ -658,16 +697,16 @@ describe('Store', () => { ) const sibling = { ...binding, leafId: TEST_LEAF_2, ptyId: 'pty-2' } // First remount after a cold park: both panes reattach back to back. - expect(store.persistPtyBinding(binding)).toBe(true) - expect(store.persistPtyBinding(sibling)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(sibling)).toBe(true) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration // Second remount: neither pane may rewrite the tab row, so neither flushes. - expect(store.persistPtyBinding(sibling)).toBe(true) - expect(store.persistPtyBinding(binding)).toBe(true) + expect(await store.persistPtyBinding(sibling)).toBe(true) + expect(await store.persistPtyBinding(binding)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]?.[0]?.ptyId).toBe('pty-1') }) @@ -675,14 +714,14 @@ describe('Store', () => { const store = await createStore() const hostId = 'ssh:ssh-1' store.setWorkspaceSession(boundSession(), hostId) - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) const partitionBefore = store.getWorkspaceSession(hostId) const partitionsBefore = store['runtime'].state.workspaceSessionsByHostId - const flushSpy = vi.spyOn(store, 'flushOrThrow') + const durableGenerationBefore = runtimeCounters(store).lastDurableWriteGeneration - expect(store.persistPtyBinding(binding, hostId)).toBe(true) + expect(await store.persistPtyBinding(binding, hostId)).toBe(true) - expect(flushSpy).not.toHaveBeenCalled() + expect(runtimeCounters(store).lastDurableWriteGeneration).toBe(durableGenerationBefore) expect(store.getWorkspaceSession(hostId)).toBe(partitionBefore) expect(store['runtime'].state.workspaceSessionsByHostId).toBe(partitionsBefore) expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE]).toBeUndefined() @@ -716,9 +755,9 @@ describe('Store', () => { const store = await createStore() store.setWorkspaceSession(boundSession()) - store.persistPtyBinding(binding) - store.persistPtyBinding(binding) - store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) + await store.persistPtyBinding(binding) + await store.persistPtyBinding(binding) + await store.persistPtyBinding({ ...binding, tabId: 'missing-tab', mayCreate: false }) const spans = records.filter( (record) => diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-host-admitted-terminal-membership.test.ts index d71dbc9882c..6e75013c7ab 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-host-admitted-terminal-membership.test.ts @@ -57,7 +57,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( // `orca terminal create`: the host mints a tab the renderer has never seen. expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, @@ -77,7 +77,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: OTHER_WORKTREE, tabId: 'host-tab-other', leafId: TEST_LEAF_2, @@ -94,7 +94,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( store.setWorkspaceSession(rendererSession()) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, @@ -118,7 +118,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'renderer-second-tab', leafId: TEST_LEAF_2, @@ -135,7 +135,7 @@ describe('host-admitted terminal membership survives a stale renderer replay', ( it('still lets the authoritative retirement path close the host-admitted tab', async () => { const store = await createStore() store.setWorkspaceSession(rendererSession()) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: 'host-tab', leafId: TEST_LEAF_2, diff --git a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts index ff819432c54..e3f75b3763f 100644 --- a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts +++ b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' + +vi.mock('node:fs', { spy: true }) +import { rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' @@ -80,7 +82,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', tabId: 'tab-1', @@ -102,11 +104,11 @@ describe('Store SSH remote PTY bindings across host partitions', () => { const store = await createStore() store.setWorkspaceSession(makeBoundHostSession(null), 'local') store.setWorkspaceSession(makeBoundHostSession(null), 'ssh:ssh-1') - const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + const flush = vi.mocked(writeFileSync).mockImplementationOnce(() => { throw new Error('disk unavailable') }) - expect(() => + await expect( store.persistPtyBinding( { worktreeId: 'repo-1::/worktree', @@ -116,7 +118,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { }, 'ssh:ssh-1' ) - ).toThrow('disk unavailable') + ).rejects.toThrow('disk unavailable') flush.mockRestore() expect( diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 934ab44e1cb..43a4f3e0561 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -16,6 +16,7 @@ import { makeProjectHostSetup } from './persistence-test-harness' import { TEST_LEAF_1 } from './persistence-session-fixtures' +import * as durableFileWrite from './durable-file-write' import { getLocalWorktreeScanGeneration, isLocalWorktreeScanGenerationCurrent @@ -79,7 +80,7 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) - it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => { + it('clone-reads and durably persists the main-owned Codex reset ledger', async () => { const store = await createStore() const ledger = { version: 1 as const, @@ -97,7 +98,7 @@ describe('Store', () => { ] } - store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + await store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) const firstRead = store.getCodexResetCreditAttemptLedger() firstRead.attempts.splice(0, 1) @@ -105,32 +106,35 @@ describe('Store', () => { expect((readDataFile() as PersistedState).codexResetCreditAttemptLedger).toEqual(ledger) }) - it('rolls the in-memory Codex reset ledger back when its sync flush fails', async () => { + it('rolls the in-memory Codex reset ledger back when its durable write fails', async () => { const store = await createStore() const before = store.getCodexResetCreditAttemptLedger() - vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + const write = vi.spyOn(durableFileWrite, 'writeFileDurableSync').mockImplementationOnce(() => { throw new Error('disk full') }) - expect(() => - store.replaceCodexResetCreditAttemptLedgerAndFlush({ - version: 1, - attempts: [ - { - idempotencyKey: '11111111-1111-4111-8111-111111111111', - expectedScope: { - target: { runtime: 'host', wslDistro: null }, - accountId: 'account-host', - accountRevision: 42, - offerRevision: 'v1:offer' - }, - state: 'providerPending' - } - ] - }) - ).toThrow('disk full') - - expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + try { + await expect( + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-host', + accountRevision: 42, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + }) + ).rejects.toThrow('disk full') + expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + } finally { + write.mockRestore() + } }) it('preserves a corrupt Codex reset ledger as a fail-closed read error', async () => { diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts index a3c5e248909..094987a91bf 100644 --- a/src/main/persistence-loading-store-extraction.test.ts +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsModule from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + writeSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants' import type { PersistedState } from '../shared/persisted-state-types' -import type * as StartupDiagnosticsModule from './startup/startup-diagnostics' import type { Store as PersistenceStore } from './persistence/loading-store/store' import { createStore, @@ -14,10 +22,9 @@ import { writeDataFile } from './persistence-test-harness' -const { trackMock, getCohortAtEmitMock, logStartupDiagnosticMock } = vi.hoisted(() => ({ +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), - getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })), - logStartupDiagnosticMock: vi.fn() + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) })) vi.mock('electron', () => ({ @@ -41,11 +48,21 @@ vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(() => ({ hosts: [] })), sshConfigHostsToTargets: vi.fn(() => []) })) -vi.mock('./startup/startup-diagnostics', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, logStartupDiagnostic: logStartupDiagnosticMock } +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, writeSync: vi.fn(actual.writeSync) } }) +function getLoadDoneLines(): string[] { + return vi + .mocked(writeSync) + .mock.calls.flatMap(([fd, text]) => + fd === 2 && typeof text === 'string' && text.startsWith('[startup] persistence-load-done ') + ? [text] + : [] + ) +} + describe('loading Store extraction seams', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) @@ -53,7 +70,7 @@ describe('loading Store extraction seams', () => { afterEach(() => { vi.unstubAllEnvs() - logStartupDiagnosticMock.mockReset() + vi.mocked(writeSync).mockClear() rmSync(testState.dir, { recursive: true, force: true }) }) @@ -78,9 +95,7 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) expect(workspaceSessionStringifyCalls).toHaveLength(0) - expect( - logStartupDiagnosticMock.mock.calls.some(([event]) => event === 'persistence-load-done') - ).toBe(false) + expect(getLoadDoneLines()).toEqual([]) }) it('reports the unchanged workspace-session byte count when startup diagnostics are enabled', () => { @@ -104,16 +119,14 @@ describe('loading Store extraction seams', () => { expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) expect(workspaceSessionStringifyCalls).toHaveLength(1) - const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( - ([event]) => event === 'persistence-load-done' - ) - expect(loadDoneCall).toBeDefined() - const details = loadDoneCall?.[1] as Record | undefined - expect(details).toEqual({ - t: expect.any(Number), - repos: state.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) - }) + const expectedBytes = Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) + expect(getLoadDoneLines()).toEqual([ + expect.stringMatching( + new RegExp( + `^\\[startup\\] persistence-load-done t=\\d+ repos=${state.repos.length} workspaceSessionBytes=${expectedBytes}\\n$` + ) + ) + ]) }) it('timestamps persistence-load-done before resolving its details closure', () => { @@ -149,12 +162,11 @@ describe('loading Store extraction seams', () => { nowSpy.mockRestore() } - const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( - ([event]) => event === 'persistence-load-done' - ) - const details = loadDoneCall?.[1] as Record | undefined - expect(details?.workspaceSessionBytes).toEqual(expect.any(Number)) - expect(details?.t).toBe(0) + expect(getLoadDoneLines()).toEqual([ + expect.stringMatching( + /^\[startup\] persistence-load-done t=0 repos=\d+ workspaceSessionBytes=\d+\n$/ + ) + ]) }) it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { diff --git a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 2f0dc41daaf..7cccd060c44 100644 --- a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -46,7 +46,7 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBe('tab1') expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -73,7 +73,7 @@ describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBeUndefined() expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index f72234b6823..47e4bd4ae76 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -204,7 +204,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -228,7 +228,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { wt1: 1 } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_1, @@ -330,7 +330,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -370,7 +370,7 @@ describe('Store', () => { const staleRendererSession = structuredClone(store.getWorkspaceSession(hostId)) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'different-target-tab', @@ -393,7 +393,7 @@ describe('Store', () => { ).toBe(false) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt-canonical', tabId: 'tab1', @@ -425,7 +425,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'rejected-tab', diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index b092c3d7d9e..25c12fd07b1 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { rmSync, mkdtempSync } from 'node:fs' + +vi.mock('node:fs', { spy: true }) +import { rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' @@ -77,7 +79,7 @@ describe('Store', () => { }) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -127,7 +129,7 @@ describe('Store', () => { store.setWorkspaceSession(sourceSession, hostId) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -174,7 +176,7 @@ describe('Store', () => { ) expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -213,7 +215,7 @@ describe('Store', () => { { ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' } ]) { expect( - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, @@ -253,7 +255,7 @@ describe('Store', () => { store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -302,7 +304,7 @@ describe('Store', () => { ) expect( - store.persistPtyBinding( + await store.persistPtyBinding( { worktreeId: 'wt1', tabId: 'tab1', @@ -348,11 +350,11 @@ describe('Store', () => { }, terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } }) - vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + vi.mocked(writeFileSync).mockImplementationOnce(() => { throw new Error('disk full') }) - expect(() => + await expect( store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', @@ -361,7 +363,7 @@ describe('Store', () => { incarnationId: 'inc-live', expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } }) - ).toThrow('disk full') + ).rejects.toThrow('disk full') expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( 'inc-stale' ) diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 1b092293c68..afecbf56883 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -782,4 +782,39 @@ describe('Store', () => { const store = await createStore() expect(store.getUI().browserKagiSessionLink).toBe(sessionLink) }) + + it.each(['shutdown', 'freeze', 'maintenance'] as const)( + 'rejects legacy SSH mutations before changing memory during %s', + async (gate) => { + const store = await createStore() + const recovery = { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + } + await store.upsertSshPtyConsumerRecovery(recovery) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }) + await store.flushPendingOrThrowAsync() + const closing = + gate === 'shutdown' + ? store.flushAsync() + : gate === 'freeze' + ? store.freezeWritesAsync() + : store.beginProfileMaintenance() + await Promise.all( + [ + store.upsertSshPtyConsumerRecovery({ ...recovery, clientInstanceId: 'refused-owner' }), + store.removeSshPtyConsumerRecovery('ssh-1'), + store.markSshRemotePtyLeasesAsync('ssh-1', 'terminated'), + store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1']) + ].map((operation) => expect(operation).rejects.toThrow('finalized profile persistence')) + ) + expect(store.getSshPtyConsumerRecovery('ssh-1')).toEqual(recovery) + expect(store.getSshRemotePtyLeases('ssh-1')[0]?.state).toBe('detached') + await closing + } + ) }) diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 62619bbd05b..7674d0aeceb 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -87,7 +87,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -132,7 +132,7 @@ describe('Store', () => { terminalTopologyRevisionByRepoId: { wt1: 1 } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_2, @@ -179,7 +179,7 @@ describe('Store', () => { store.setWorkspaceSession(stale) expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([]) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'fresh-tab', leafId: TEST_LEAF_2, @@ -261,7 +261,7 @@ describe('Store', () => { expect(store.getWorkspaceSession().tabsByWorktree[worktreeA]?.[0]?.id).toBe('tab-a') expect(store.getWorkspaceSession().tabsByWorktree[worktreeB]?.[0]?.id).toBe('tab-b') - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: worktreeB, tabId: 'fresh-tab', leafId: TEST_LEAF_1, @@ -281,7 +281,7 @@ describe('Store', () => { for (let index = 0; index < 25; index += 1) { const worktreeId = `repo::/worktree-${index}` store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId, tabId: `tab-${index}`, leafId: TEST_LEAF_1, @@ -364,7 +364,7 @@ describe('Store', () => { } }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: 'wt1', tabId: 'tab1', leafId: TEST_LEAF_1, diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 2a29bc64f66..662e79bd40f 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -12,7 +12,7 @@ import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { state: PersistedState - scheduleSave: () => void + scheduleSave: (domains?: readonly string[]) => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] } @@ -49,7 +49,7 @@ export function recordFeatureInteraction( operations.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets - operations.scheduleSave() + operations.scheduleSave(['ui', 'featureInteractionTelemetryBuckets']) // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. if (!existing) { operations.notifyUIChanged() diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index db06a2738fd..5aad240e554 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -57,7 +57,9 @@ export function updatePersistedUI( operations: UIUpdateOperations, updates: Partial ): void { - if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + const clearsProtectedSecret = + 'browserKagiSessionLink' in updates && !updates.browserKagiSessionLink + if (clearsProtectedSecret) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) @@ -192,7 +194,8 @@ export function updatePersistedUI( ) : normalizeFeatureInteractions(operations.state.ui?.featureInteractions) } - if (persistedUIValuesEqual(previousUI, nextUI)) { + // A sealed secret looks empty in memory; an explicit clear must still reach disk. + if (!clearsProtectedSecret && persistedUIValuesEqual(previousUI, nextUI)) { if (activeViewChanged) { operations.notifyUIChanged() } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index 3b259989906..07770fe70b9 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,3 +1,4 @@ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' @@ -7,16 +8,7 @@ import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { state: PersistedState protectedSecrets: Pick - flushDurableStateOrThrowAsync: () => Promise -} - -async function flushSshPtyConsumerRecovery( - operations: SshPtyConsumerRecoveryOperations -): Promise { - // Why: ownership must be durable before relay setup continues, but this runs on the live - // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. - // Why not caught here: the failure must reach the awaiting caller. - await operations.flushDurableStateOrThrowAsync() + runDurableMutation: StoreRuntimeState['runDurableMutation'] } export function getSshPtyConsumerRecovery( @@ -46,24 +38,37 @@ export async function upsertSshPtyConsumerRecovery( if (!normalized) { throw new Error('Invalid SSH PTY consumer recovery record') } - const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] - operations.state.sshPtyConsumerRecoveries = [ - ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), - normalized - ] - await flushSshPtyConsumerRecovery(operations) + await operations.runDurableMutation(() => { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + operations.state.sshPtyConsumerRecoveries = [ + ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), + normalized + ] + return { value: undefined } + }) } export async function removeSshPtyConsumerRecovery( operations: SshPtyConsumerRecoveryOperations, - targetId: string + targetId: string, + expectedClientInstanceId?: string ): Promise { - const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] - const next = recoveries.filter((record) => record.targetId !== targetId) - if (next.length === recoveries.length) { - return + await operations.runDurableMutation(() => { + const recoveries = operations.state.sshPtyConsumerRecoveries ?? [] + const current = recoveries.find((record) => record.targetId === targetId) + if ( + expectedClientInstanceId !== undefined && + current && + current.clientInstanceId !== expectedClientInstanceId + ) { + return { value: undefined, persist: false } + } + operations.state.sshPtyConsumerRecoveries = recoveries.filter( + (record) => record.targetId !== targetId + ) + return { value: undefined } + }) + if (!operations.state.sshPtyConsumerRecoveries?.some((record) => record.targetId === targetId)) { + operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) } - operations.state.sshPtyConsumerRecoveries = next - operations.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) - await flushSshPtyConsumerRecovery(operations) } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index f06e5b0ece7..b2e3cef030b 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,3 +1,4 @@ +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' @@ -12,7 +13,7 @@ export type SshPtyLeaseOperations = { clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean flush: () => void - flushDurableStateOrThrowAsync: () => Promise + runDurableMutation: StoreRuntimeState['runDurableMutation'] } /** @@ -93,7 +94,8 @@ function updateSshRemotePtyLeaseStates( operations: SshPtyLeaseOperations, targetId: string, state: SshRemotePtyLease['state'], - ptyIds?: ReadonlySet + ptyIds?: ReadonlySet, + admittedLeases?: ReadonlySet ): boolean { const now = Date.now() let changed = false @@ -101,7 +103,11 @@ function updateSshRemotePtyLeaseStates( const leasesToClear: SshRemotePtyLease[] = [] operations.state.sshRemotePtyLeases ??= [] for (const lease of operations.state.sshRemotePtyLeases) { - if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { + if ( + lease.targetId !== targetId || + (ptyIds && !ptyIds.has(lease.ptyId)) || + (admittedLeases && !admittedLeases.has(lease)) + ) { continue } if (state === 'attached' && lease.state === 'terminated') { @@ -179,9 +185,12 @@ export async function markSshRemotePtyLeasesAsync( targetId: string, state: SshRemotePtyLease['state'] ): Promise { - if (updateSshRemotePtyLeaseStates(operations, targetId, state)) { - await operations.flushDurableStateOrThrowAsync() - } + // A newer connection can replace a lease while this operation waits for the writer. + const admittedLeases = new Set(getSshRemotePtyLeases(operations.state, targetId)) + await operations.runDurableMutation(() => { + updateSshRemotePtyLeaseStates(operations, targetId, state, undefined, admittedLeases) + return { value: undefined } + }) } export async function markSshRemotePtyLeasesAttachedAsync( @@ -190,9 +199,12 @@ export async function markSshRemotePtyLeasesAttachedAsync( ptyIds: readonly string[] ): Promise { const relayPtyIds = new Set(ptyIds.map((ptyId) => operations.toStoredPtyId(targetId, ptyId))) - if (updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds)) { - await operations.flushDurableStateOrThrowAsync() - } + // A newer connection can replace a lease while this operation waits for the writer. + const admittedLeases = new Set(getSshRemotePtyLeases(operations.state, targetId)) + await operations.runDurableMutation(() => { + updateSshRemotePtyLeaseStates(operations, targetId, 'attached', relayPtyIds, admittedLeases) + return { value: undefined } + }) } /** `relayIdRecycled` is the pending-stop replay's evidence that the host now lists this id under a diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index 2bed89ff09b..64abd8ed2a9 100644 --- a/src/main/persistence/loading-store/automation-persistence.ts +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -53,7 +53,11 @@ import type { ProfilePreferences } from './profile-preferences' type AutomationPersistenceRuntime = Pick< StoreRuntimeState, - 'automationListProjectionCache' | 'state' | 'storageAuthority' + | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' + | 'pendingAutomationRunsAfter' + | 'state' + | 'storageAuthority' > const automationPersistenceContext = Symbol('AutomationPersistence') @@ -195,7 +199,10 @@ export class AutomationPersistence { advanceAutomationNextRun(id: string, now = Date.now()): Automation { return advanceAutomationNextRunOperation( this[automationPersistenceContext].runtime.state, - () => this[automationPersistenceContext].flushBarriers.flush(), + () => { + markAutomationDefinitionDomain(this) + this[automationPersistenceContext].flushBarriers.flush() + }, id, now ) @@ -212,16 +219,31 @@ export function getAutomationDefinitionOperations( return { state: owner[automationPersistenceContext].runtime.state, storageAuthority: owner[automationPersistenceContext].runtime.storageAuthority, - flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + flush: () => { + markAutomationDefinitionDomain(owner) + owner[automationPersistenceContext].flushBarriers.flush() + }, recordCreated: () => - owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + owner[automationPersistenceContext].preferences.recordFeatureInteraction( + 'automation-created' + ), + recordAutomationRunsMutation: (runs) => { + owner[automationPersistenceContext].runtime.pendingAutomationRunsAfter = runs + owner[automationPersistenceContext].runtime.dirtyProfileStateDomains?.add('automationRuns') + } } } export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { return { state: owner[automationPersistenceContext].runtime.state, - flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + flush: () => { + markAutomationDomains(owner) + owner[automationPersistenceContext].flushBarriers.flush() + }, + recordAutomationRunsMutation: (runs) => { + owner[automationPersistenceContext].runtime.pendingAutomationRunsAfter = runs + }, recordManualRun: () => owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), getWorkspaceDisplayName: (workspaceId) => @@ -242,6 +264,18 @@ export function getAutomationRunWorkspaceDisplayName( ) } +function markAutomationDomains(owner: AutomationPersistence): void { + const dirtyDomains = owner[automationPersistenceContext].runtime.dirtyProfileStateDomains + if (dirtyDomains !== null) { + dirtyDomains.add('automations') + dirtyDomains.add('automationRuns') + } +} + +function markAutomationDefinitionDomain(owner: AutomationPersistence): void { + owner[automationPersistenceContext].runtime.dirtyProfileStateDomains?.add('automations') +} + export function installAutomationPersistenceContext( target: AutomationPersistence, source: AutomationPersistence diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts index 7cf0a7a8078..7ed6b21249e 100644 --- a/src/main/persistence/loading-store/backup-recovery-rotation.ts +++ b/src/main/persistence/loading-store/backup-recovery-rotation.ts @@ -11,12 +11,13 @@ import { import { access, copyFile, rename, rm, stat } from 'node:fs/promises' import { dirname } from 'node:path' -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 +import { + PROFILE_STATE_LEGACY_BACKUP_COUNT as BACKUP_COUNT, + profileStateLegacyBackupPath as backupPath +} from '../profile-state/profile-state-legacy-backup-path' +export { hasStateBackup } from '../profile-state/profile-state-legacy-backup-path' -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 /** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ async function exists(path: string): Promise { @@ -26,18 +27,7 @@ async function exists(path: string): Promise { ) } -export function hasStateBackup(dataFile: string): boolean { - for (let index = 0; index < BACKUP_COUNT; index += 1) { - if (existsSync(backupPath(dataFile, index))) { - return true - } - } - return false -} - -import type { StoreRuntimeState } from './store-runtime-state' - -type BackupRecoveryRotationOperationsRuntime = Pick +type BackupRecoveryRotationOperationsRuntime = { backupRotationInFlight: boolean } export class BackupRecoveryRotationOperations { constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index d2a31419a03..f2d46301f74 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -9,10 +9,7 @@ import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-s import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' +import { logStartupMilestone } from '../../startup/startup-diagnostics' import { PROTECTED_SECRET_SLOT, sshPtyOwnerLeaseSecretSlot @@ -43,21 +40,6 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' -type PersistenceStartupDetails = Record | (() => Record) - -function logPersistenceStartupMilestone( - event: string, - details: PersistenceStartupDetails = {} -): void { - if (!isStartupDiagnosticsEnabled()) { - return - } - // Why: snapshot `t` before resolving lazy details — otherwise an expensive details closure is billed to the milestone it measures. - const t = Math.round(performance.now()) - const resolvedDetails = typeof details === 'function' ? details() : details - logStartupDiagnostic(event, { t, ...resolvedDetails }) -} - import type { StoreRuntimeState } from './store-runtime-state' import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' @@ -79,27 +61,67 @@ export class LoadedStateParsingOperations { private readonly cohorts: LoadedCohortMigrationOperations ) {} + /** + * Load the legacy storage representation supplied by a migration/importer. + * + * This deliberately uses the same decrypt, normalization, migration, and + * sidecar handling as a file load. An invalid imported document must fail + * closed instead of falling back to an unrelated on-disk backup. + */ + loadSerialized(raw: string): PersistedState { + return this.loadInternal(true, raw) + } + + /** Load only from an injected authority; never consult the legacy JSON path. */ + loadFromAuthority(raw: string | undefined): PersistedState { + return this.loadInternal(false, raw, true) + } + + loadParsedFromAuthority(parsed: Record | undefined): PersistedState { + return this.loadInternal(false, undefined, true, parsed) + } + load(allowBackupRecovery = true): PersistedState { + return this.loadInternal(allowBackupRecovery) + } + + private loadInternal( + fileRecovery: boolean, + serialized?: string, + authoritySource = false, + parsedInput?: Record + ): PersistedState { // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. const dataFile = this.runtime.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { + const fileExistedOnLoad = authoritySource + ? serialized !== undefined || parsedInput !== undefined + : serialized !== undefined || existsSync(dataFile) + logStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad }) let result: PersistedState | null = null + let parsed: PersistedState | undefined try { if (fileExistedOnLoad) { const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - + const raw = + parsedInput === undefined ? (serialized ?? readFileSync(dataFile, 'utf-8')) : undefined + if (raw !== undefined) { + logStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logStartupMilestone('persistence-json-parse-start') + parsed = JSON.parse(raw) + logStartupMilestone('persistence-json-parse-done') + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Legacy partial records enter the existing domain normalizers through this loader type. + parsed = parsedInput as PersistedState + } + if (parsed === undefined) { + throw new Error('Profile state startup snapshot is missing') + } // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( @@ -188,11 +210,15 @@ export class LoadedStateParsingOperations { }) } } catch (err) { + if (serialized !== undefined || authoritySource) { + console.error('[persistence] Failed to load imported profile state:', err) + throw new Error('Failed to load imported profile state', { cause: err }) + } console.error('[persistence] Failed to load primary state, trying backups:', err) } // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { + if (result === null && fileRecovery && !authoritySource) { const hasBackup = hasStateBackup(dataFile) if (fileExistedOnLoad || hasBackup) { if (this.backups.restoreFromBackup(dataFile)) { @@ -216,7 +242,6 @@ export class LoadedStateParsingOperations { if (migratedScrollback.changed) { this.runtime.loadNeedsSave = true } - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { @@ -293,7 +318,7 @@ export class LoadedStateParsingOperations { migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache } - logPersistenceStartupMilestone('persistence-load-done', () => ({ + logStartupMilestone('persistence-load-done', () => ({ repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) })) diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 2532ff3b2d3..9800b541373 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -27,7 +27,8 @@ import { getWorktreeMetaForHost as getWorktreeMetaForHostOperation, migrateWorktreeMetadataLocator, removeWorktreeMetadataForHost, - setWorktreeMetaForHost as setWorktreeMetaForHostOperation + setWorktreeMetaForHost as setWorktreeMetaForHostOperation, + WORKTREE_METADATA_DOMAINS } from './worktree-identity-metadata' import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' import { @@ -122,7 +123,7 @@ export class MetadataLineageOperations { } const updated = mergeWorktreeMetaForWrite(stored, meta) state.worktreeMeta[worktreeId] = updated - scheduleSave(this[metadataLineageOperationsContext].scheduling) + scheduleSave(this[metadataLineageOperationsContext].scheduling, ['worktreeMeta']) return updated } @@ -269,7 +270,11 @@ export class MetadataLineageOperations { mover ) if (legacyChanged || canonicalChanged) { - scheduleSave(this[metadataLineageOperationsContext].scheduling) + // Legacy identity moves also re-key sessions, lineage, mobile selections, and UI state. + scheduleSave( + this[metadataLineageOperationsContext].scheduling, + legacyChanged ? undefined : WORKTREE_METADATA_DOMAINS + ) } } diff --git a/src/main/persistence/loading-store/primary-state-write-context.ts b/src/main/persistence/loading-store/primary-state-write-context.ts new file mode 100644 index 00000000000..24dd5db6bea --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-context.ts @@ -0,0 +1,17 @@ +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import type { PrimaryStateWriteOperationsRuntime } from './primary-state-write-runtime' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' + +export type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + backups: BackupRecoveryRotationOperations + queuedSnapshot?: { + completion: Promise + capture: { + skipIfClean: boolean + fullCheckpoint: boolean + pendingSnapshotFileWork: Promise | null + } + } +} diff --git a/src/main/persistence/loading-store/primary-state-write-json.ts b/src/main/persistence/loading-store/primary-state-write-json.ts new file mode 100644 index 00000000000..78130afbb1f --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-json.ts @@ -0,0 +1,87 @@ +import { mkdir, open, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { durableWriteTempPath, renameDurable } from '../../durable-file-write' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { + canReuseDurableProfileState, + markPrimaryStateWriteDurable +} from './primary-state-write-runtime' + +export async function writeJsonProfileState( + { runtime, serialization, backups }: PrimaryStateWriteOperationsContext, + gen: number +): Promise { + const built = serialization.buildStateToSave() + const { stateHash, protectedSecretUpdates } = built + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (canReuseDurableProfileState(runtime, stateHash)) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, gen) + return true + } + const dataFile = runtime.dataFile + const payload = built.payload + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. + await handle.writeFile(payload) + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (runtime.writeGeneration !== gen) { + return false + } + runtime.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') || + runtime.writeGeneration === gen + ) { + throw error + } + } finally { + if (runtime.inFlightAsyncTmpFile === tmpFile) { + runtime.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && runtime.writeGeneration === gen) { + runtime.lastWrittenStateHash = stateHash + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + } else if (renamed) { + runtime.lastWrittenStateHash = null + } + if (renamed) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, gen) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return false + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (runtime.writeGeneration !== gen) { + return true + } + await backups.rotateBackupsAsync(dataFile) + return true +} diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts new file mode 100644 index 00000000000..2c79fa2b073 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -0,0 +1,69 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +export type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'dirtyProfileStateDomains' + | 'durableMutationPhase' + | 'fatalMutationError' + | 'flushOrThrow' + | 'runDurableMutation' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingAutomationRunsAfter' + | 'pendingWrite' + | 'profileMaintenancePending' + | 'profileStateAuthority' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'staleTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +export function markPrimaryStateWriteDurable( + runtime: Pick, + generation: number +): void { + runtime.lastDurableWriteGeneration = Math.max(runtime.lastDurableWriteGeneration, generation) +} + +export function canReuseDurableProfileState( + runtime: Pick, + stateHash: string +): boolean { + if (stateHash !== runtime.lastWrittenStateHash) { + return false + } + const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited revision check') + } + if (authority && !authority.assertCurrentRevision) { + return false + } + authority?.assertCurrentRevision?.() + return true +} + +export async function stopAfterFailedPrimaryStateMutation( + runtime: PrimaryStateWriteOperationsRuntime, + error: unknown +): Promise { + // A throwing callback never returns its rollback; do not persist a partial edit. + runtime.writesFrozen = true + runtime.quitFlushStarted = true + runtime.fatalMutationError = error instanceof Error ? error : new Error(String(error)) + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + await runtime.profileStateAuthority?.close?.() +} diff --git a/src/main/persistence/loading-store/primary-state-write-sync.ts b/src/main/persistence/loading-store/primary-state-write-sync.ts new file mode 100644 index 00000000000..fd598b06158 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-sync.ts @@ -0,0 +1,98 @@ +import { existsSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { writeSelectiveProfileState } from './profile-state-selective-write' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { + canReuseDurableProfileState, + markPrimaryStateWriteDurable +} from './primary-state-write-runtime' + +export function writeToDiskSync( + context: PrimaryStateWriteOperationsContext, + opts: { force?: boolean; skipBackupRotation?: boolean; expectedGeneration?: number } = {} +): boolean { + const { runtime, serialization, backups } = context + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError + } + if (runtime.writesFrozen) { + return false + } + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') + } + const isCurrent = + opts.expectedGeneration === undefined + ? undefined + : () => runtime.writeGeneration === opts.expectedGeneration + const selective = writeSelectiveProfileState( + runtime.profileStateAuthority, + serialization, + runtime.dirtyProfileStateDomains, + runtime.pendingAutomationRunsAfter, + isCurrent + ) + if (selective.handled) { + if (selective.aborted) { + return false + } + if (selective.consumedAutomationRuns) { + runtime.pendingAutomationRunsAfter = undefined + } + runtime.lastWrittenStateHash = null + runtime.protectedSecrets.commitRetentionUpdates(selective.protectedSecretUpdates) + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + runtime.profileStateAuthority?.scheduleBackup?.() + return true + } + const built = serialization.buildStateToSave( + runtime.profileStateAuthority?.writeCompleteSerializedDomains !== undefined + ) + const { stateHash, protectedSecretUpdates } = built + if (isCurrent && !isCurrent()) { + return false + } + // Why: matching hash means the file already holds this state; force overrides an async rename race. + if (!opts.force && canReuseDurableProfileState(runtime, stateHash)) { + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + return true + } + if (runtime.profileStateAuthority) { + if (built.domains && runtime.profileStateAuthority.writeCompleteSerializedDomains) { + runtime.profileStateAuthority.writeCompleteSerializedDomains(built.domains) + } else { + runtime.profileStateAuthority.writeSerializedState(built.payload) + } + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + if (!isCurrent || isCurrent()) { + runtime.lastWrittenStateHash = stateHash + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + } else { + runtime.lastWrittenStateHash = null + } + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + runtime.profileStateAuthority.scheduleBackup?.() + return true + } + const dataFile = runtime.dataFile + const payload = built.payload + const dir = dirname(dataFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileDurableSync(durableWriteTempPath(dataFile), dataFile, payload) + runtime.dirtyProfileStateDomains = new Set() + runtime.lastWrittenStateHash = stateHash + runtime.pendingAutomationRunsAfter = undefined + runtime.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) + markPrimaryStateWriteDurable(runtime, opts.expectedGeneration ?? runtime.writeGeneration) + const now = Date.now() + if (!opts.skipBackupRotation && backups.shouldRotateBackups(now, dataFile)) { + backups.rotateBackupsSync(dataFile) + } + return true +} diff --git a/src/main/persistence/loading-store/primary-state-write-worker.ts b/src/main/persistence/loading-store/primary-state-write-worker.ts new file mode 100644 index 00000000000..71862bc3b44 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-write-worker.ts @@ -0,0 +1,106 @@ +import type { AsyncProfileStateAuthority } from './profile-state-authority' +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { markPrimaryStateWriteDurable } from './primary-state-write-runtime' +import { prepareSelectiveProfileStateWrite } from './profile-state-selective-write' + +/** The queued operation owns its captured intent; later edits belong to the next write. */ +export async function writeProfileStateInWorker( + { runtime, serialization }: PrimaryStateWriteOperationsContext, + authority: AsyncProfileStateAuthority +): Promise { + authority.assertWritable() + const generation = runtime.writeGeneration + const dirtyDomains = runtime.dirtyProfileStateDomains + const automationRuns = runtime.pendingAutomationRunsAfter + runtime.dirtyProfileStateDomains = new Set() + runtime.pendingAutomationRunsAfter = undefined + + const restoreIntent = (): void => { + if (dirtyDomains === null) { + runtime.dirtyProfileStateDomains = null + } else if (runtime.dirtyProfileStateDomains !== null) { + for (const domain of dirtyDomains) { + runtime.dirtyProfileStateDomains.add(domain) + } + } + runtime.pendingAutomationRunsAfter ??= automationRuns + } + + try { + const prepared = beginWrite( + authority, + serialization, + dirtyDomains, + automationRuns, + runtime.lastWrittenStateHash, + () => runtime.writeGeneration === generation + ) + if (!prepared) { + restoreIntent() + return false + } + await prepared.completion + runtime.protectedSecrets.commitRetentionUpdates(prepared.protectedSecretUpdates) + runtime.lastWrittenStateHash = + runtime.writeGeneration === generation ? prepared.stateHash : null + markPrimaryStateWriteDurable(runtime, generation) + if (runtime.writeGeneration === generation) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + } + } catch (error) { + restoreIntent() + throw error + } + try { + authority.scheduleBackup?.() + } catch (error) { + console.error('[persistence] Failed to schedule profile state backup:', error) + } + return true +} + +/** Release copied payloads before the acknowledgement; retain only commit bookkeeping. */ +function beginWrite( + authority: AsyncProfileStateAuthority, + serialization: PrimaryStateWriteOperationsContext['serialization'], + dirtyDomains: ReadonlySet | null, + automationRuns: PrimaryStateWriteOperationsContext['runtime']['pendingAutomationRunsAfter'], + lastWrittenStateHash: string | null, + isCurrent: () => boolean +) { + const selective = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + automationRuns + ) + if (selective) { + if (!isCurrent()) { + return undefined + } + const completion = + selective.automationRuns !== undefined + ? authority.writeSerializedAutomationRuns(selective.replacements, selective.automationRuns) + : authority.writeSerializedDomains(selective.replacements) + return { completion, stateHash: null, protectedSecretUpdates: selective.protectedSecretUpdates } + } + const complete = serialization.buildStateToSave(true) + if (!isCurrent()) { + return undefined + } + const unchanged = complete.stateHash === lastWrittenStateHash + const completion = unchanged + ? authority.assertCurrentRevision() + : complete.domains + ? authority.writeCompleteSerializedDomains(complete.domains) + : authority.writeSerializedState(complete.payload) + return { + completion, + stateHash: complete.stateHash, + protectedSecretUpdates: unchanged ? [] : complete.protectedSecretUpdates + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 3820723fffb..db1eed63836 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -1,44 +1,23 @@ -import { mkdirSync, existsSync, unlinkSync } from 'node:fs' -import { mkdir, open, rm } from 'node:fs/promises' -import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' -import { dirname } from 'node:path' +import { unlinkSync } from 'node:fs' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { parseCodexResetCreditAttemptLedger, type CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' - -import type { StoreRuntimeState } from './store-runtime-state' +import { + stopAfterFailedPrimaryStateMutation, + type PrimaryStateWriteOperationsRuntime +} from './primary-state-write-runtime' import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' - -type PrimaryStateWriteOperationsRuntime = Pick< - StoreRuntimeState, - | 'activeViewPreference' - | 'backupRotationInFlight' - | 'dataFile' - | 'flushOrThrow' - | 'firstPendingSaveAt' - | 'inFlightAsyncTmpFile' - | 'lastDurableWriteGeneration' - | 'lastWrittenStateHash' - | 'pendingSnapshotFileWork' - | 'pendingWrite' - | 'protectedSecrets' - | 'quitFlushStarted' - | 'staleTempCleanup' - | 'state' - | 'writeGeneration' - | 'writeTimer' - | 'writesFrozen' -> +import type { PrimaryStateWriteOperationsContext } from './primary-state-write-context' +import { writeToDiskSync } from './primary-state-write-sync' +import { writeProfileStateInWorker } from './primary-state-write-worker' +import { writeJsonProfileState } from './primary-state-write-json' +import type { DurableProfileStateMutation } from './store-runtime-state' +import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') -type PrimaryStateWriteOperationsContext = { - runtime: PrimaryStateWriteOperationsRuntime - serialization: StateSerializationSecretHandlingOperations - backups: BackupRecoveryRotationOperations -} - export class PrimaryStateWriteOperations { readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext @@ -51,232 +30,242 @@ export class PrimaryStateWriteOperations { } flushOrThrow(): void { - if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + const context = this[primaryStateWriteOperationsContext] + const { runtime } = context + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { throw new Error('Cannot synchronously flush after final persistence has started') } - if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { - clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) - this[primaryStateWriteOperationsContext].runtime.writeTimer = null + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited flush') } - this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null - const asyncWriteWasInFlight = - this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const asyncWriteWasInFlight = runtime.pendingWrite !== null // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this[primaryStateWriteOperationsContext].runtime.writeGeneration++ - if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { + runtime.writeGeneration++ + if (runtime.inFlightAsyncTmpFile) { try { - unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) - this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + unlinkSync(runtime.inFlightAsyncTmpFile) + runtime.inFlightAsyncTmpFile = null } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { void enqueueWrite(this).catch(() => {}) throw error } } } // Why: later async flushes must remain serialized behind the invalidated writer. - writeToDiskSync(this, { + writeToDiskSync(context, { force: asyncWriteWasInFlight, - skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight + skipBackupRotation: runtime.backupRotationInFlight }) } flushActiveViewPreferenceOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.profileMaintenancePending) { + throw new Error('Cannot flush active-view persistence during profile maintenance') + } this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() } + /** Expected refusals return persist: false; thrown callbacks stop saving to protect partial state. */ + runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { + const { runtime } = this[primaryStateWriteOperationsContext] + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return Promise.reject(new Error('Cannot mutate finalized profile persistence')) + } + return enqueuePrimaryStateOperation(this, async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.profileStateAuthority.assertWritable() + } + let mutation: DurableProfileStateMutation + try { + mutation = this.runAdmittedMutationCallback('mutate', mutate) + } catch (error) { + await stopAfterFailedPrimaryStateMutation(runtime, error) + throw error + } + if ( + mutation.persist === false || + (mutation.persist === 'if-dirty' && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration) + ) { + return mutation.value + } + runtime.writeGeneration++ + const requiredGeneration = runtime.writeGeneration + try { + const captured = runtime.profileStateAuthority?.asynchronous + ? await writeToDiskAsync(this) + : writeToDiskSync(this[primaryStateWriteOperationsContext], { + expectedGeneration: requiredGeneration + }) + if (!captured || runtime.lastDurableWriteGeneration < requiredGeneration) { + throw new Error('Profile mutation changed while preparing its durable snapshot') + } + } catch (error) { + if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { + if (mutation.rollback) { + try { + this.runAdmittedMutationCallback('rollback', mutation.rollback) + } catch (rollbackError) { + await stopAfterFailedPrimaryStateMutation(runtime, rollbackError) + throw rollbackError + } + } + } + throw error + } + return mutation.value + }) + } + + private runAdmittedMutationCallback(phase: 'mutate' | 'rollback', callback: () => T): T { + const { runtime } = this[primaryStateWriteOperationsContext] + // Finalization drains admitted mutations; the disk wait must not admit new snapshots. + runtime.durableMutationPhase = phase + try { + return callback() + } finally { + runtime.durableMutationPhase = null + } + } + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { return parseCodexResetCreditAttemptLedger( this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger ) } - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } + replaceCodexResetCreditAttemptLedgerAndFlush( + ledger: CodexResetCreditAttemptLedger + ): Promise { + const { runtime } = this[primaryStateWriteOperationsContext] const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this[primaryStateWriteOperationsContext].runtime.state - .codexResetCreditAttemptLedger - ? structuredClone( - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger - ) - : undefined - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next - try { - this[primaryStateWriteOperationsContext].runtime.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = - previous - throw error - } + return this.runDurableMutation(() => { + const previous = runtime.state.codexResetCreditAttemptLedger + runtime.state.codexResetCreditAttemptLedger = next + runtime.dirtyProfileStateDomains?.add('codexResetCreditAttemptLedger') + return { + value: undefined, + rollback: () => { + if (runtime.state.codexResetCreditAttemptLedger === next) { + runtime.state.codexResetCreditAttemptLedger = previous + } + } + } + }) } } -export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { +export function enqueueWrite( + owner: PrimaryStateWriteOperations, + options: { fullCheckpoint?: boolean; skipIfClean?: boolean; signal?: AbortSignal } = {} +): Promise { + const context = owner[primaryStateWriteOperationsContext] + const { runtime } = context + const batchable = + runtime.profileStateAuthority?.asynchronous && !options.fullCheckpoint && !options.signal + const queued = context.queuedSnapshot + if (batchable && queued) { + queued.capture.skipIfClean &&= options.skipIfClean === true + // Merged explicit flushes must retain the full capture that covered untracked getter edits. + queued.capture.fullCheckpoint ||= !queued.capture.skipIfClean + queued.capture.pendingSnapshotFileWork = runtime.pendingSnapshotFileWork + return queued.completion + } + const capture = { + skipIfClean: options.skipIfClean === true, + fullCheckpoint: options.fullCheckpoint === true, + pendingSnapshotFileWork: runtime.pendingSnapshotFileWork + } + const completion = enqueuePrimaryStateOperation(owner, async () => { + // Later flushes must capture edits made after this batch starts, even without a new generation. + if (context.queuedSnapshot?.capture === capture) { + context.queuedSnapshot = undefined + } + if (batchable) { + await capture.pendingSnapshotFileWork + } + const { signal } = options + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if ( + capture.skipIfClean && + runtime.dirtyProfileStateDomains?.size === 0 && + runtime.pendingAutomationRunsAfter === undefined && + runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) { + return + } + // A queued predecessor can clear dirty domains before this checkpoint runs. + if (capture.fullCheckpoint) { + runtime.dirtyProfileStateDomains = null + } + await writeToDiskAsync(owner) + }) + if (batchable) { + context.queuedSnapshot = { completion, capture } + } + // A caller may stop waiting; the admitted write must retain its acknowledgement and ordering. + return waitForPromiseWithSignal(completion, options.signal) +} + +export function enqueuePrimaryStateOperation( + owner: PrimaryStateWriteOperations, + operation: () => Promise +): Promise { + const context = owner[primaryStateWriteOperationsContext] + const { runtime } = context + // A durable mutation, export, or independent checkpoint separates adjacent snapshot batches. + context.queuedSnapshot = undefined const previousWrite = Promise.all([ - owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? - owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, - owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + runtime.pendingWrite ?? runtime.staleTempCleanup, + runtime.pendingSnapshotFileWork ?? Promise.resolve() ]).then(() => {}) - const write = previousWrite.then(() => writeToDiskAsync(owner)) + const write = previousWrite.then(operation).finally(() => { + if (context.queuedSnapshot?.completion === write) { + context.queuedSnapshot = undefined + } + }) const trackedWrite = write + .then(() => {}) .catch((err) => { console.error('[persistence] Failed to write state:', err) }) .finally(() => { - if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { - owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + if (runtime.pendingWrite === trackedWrite) { + runtime.pendingWrite = null } }) - owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + runtime.pendingWrite = trackedWrite return write } -export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { - if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { - return +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + const { runtime } = owner[primaryStateWriteOperationsContext] + if (runtime.fatalMutationError) { + throw runtime.fatalMutationError } - const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen + if (runtime.writesFrozen) { + return false + } + const gen = runtime.writeGeneration + if (runtime.profileStateAuthority?.asynchronous) { + return writeProfileStateInWorker( + owner[primaryStateWriteOperationsContext], + runtime.profileStateAuthority ) - return } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. - await handle.writeFile(payload) - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { - return - } - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ( - (error as NodeJS.ErrnoException).code !== 'ENOENT' || - owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen - ) { - throw error - } - } finally { - if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { - owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash - owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( - protectedSecretUpdates - ) - } else if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null - } - if (renamed) { - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - gen - ) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { - return - } - await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) -} - -export function writeToDiskSync( - owner: PrimaryStateWriteOperations, - opts: { force?: boolean; skipBackupRotation?: boolean } = {} -): void { - if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = - owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if ( - !opts.force && - stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash - ) { - // Why: flushOrThrow already bumped writeGeneration; the file holds this state, so record it - // durable or persistPtyBinding's fast lane stays parked one generation behind forever. - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - owner[primaryStateWriteOperationsContext].runtime.writeGeneration - ) - return - } - const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash - owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( - protectedSecretUpdates - ) - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( - owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, - owner[primaryStateWriteOperationsContext].runtime.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if ( - !opts.skipBackupRotation && - owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) - ) { - owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) + if (runtime.profileStateAuthority) { + // SQL commits are synchronous so both entry points share the same generation fence. + return writeToDiskSync(owner[primaryStateWriteOperationsContext], { expectedGeneration: gen }) } + return writeJsonProfileState(owner[primaryStateWriteOperationsContext], gen) } export function installPrimaryStateWriteOperationsContext( diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 8e910ed235d..b52e748f190 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -159,7 +159,7 @@ export function getSettingsMutationOperations( bumpLocalWorktreeScanGeneration, removeRetainedBlob: (slot) => owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling, ['settings']), notifySettingsChanged: (updates, originWebContentsId) => notifySettingsChanged(owner, updates, originWebContentsId) } @@ -183,7 +183,7 @@ export function getFeatureInteractionOperations( ): FeatureInteractionOperations { return { state: owner[profilePreferencesContext].runtime.state, - scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + scheduleSave: (domains) => scheduleSave(owner[profilePreferencesContext].scheduling, domains), notifyUIChanged: () => notifyUIChanged(owner), getUI: () => owner.getUI() } diff --git a/src/main/persistence/loading-store/profile-state-authority-writes.ts b/src/main/persistence/loading-store/profile-state-authority-writes.ts new file mode 100644 index 00000000000..a7f3b69a8e3 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority-writes.ts @@ -0,0 +1,59 @@ +import { createHash } from 'node:crypto' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' +import type { ProfileStateDomainReplacement } from './profile-state-authority' + +export function buildProfileStateDomainReplacements( + payload: Buffer, + dirtyDomains: ReadonlySet +): ProfileStateDomainReplacement[] { + const parsed: unknown = JSON.parse(payload.toString('utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Profile state payload must be a JSON object') + } + const entries = new Map(Object.entries(parsed)) + return [...dirtyDomains].map((domain) => { + if (!entries.has(domain)) { + return { domain, payload: null } + } + const serialized = JSON.stringify(entries.get(domain)) + if (serialized === undefined) { + throw new Error(`Profile state domain payload is not serializable: ${domain}`) + } + return { domain, payload: serialized } + }) +} + +export function serializeCompleteProfileStateDomains( + state: Record, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string; domains: readonly ProfileStateDomainReplacement[] } { + const domains: ProfileStateDomainReplacement[] = [] + const hash = createHash('sha1').update(degradedPrefix) + for (const [domain, value] of Object.entries(state)) { + // The wrapper preserves the original property name passed to a value's toJSON. + const fragment = JSON.stringify({ [domain]: value }) + if (fragment === '{}') { + continue + } + const serialized = applySecretSentinelSubstitutions(fragment, substitutions, '', 'text') + hash.update(serialized.stateHash) + domains.push({ + domain, + payload: serialized.payload.slice(JSON.stringify(domain).length + 2, -1) + }) + } + return { + domains, + stateHash: hash.digest('hex'), + get payload() { + return Buffer.from( + `{${domains.map(({ domain, payload }) => `${JSON.stringify(domain)}:${payload}`).join(',')}}`, + 'utf8' + ) + } + } +} diff --git a/src/main/persistence/loading-store/profile-state-authority.ts b/src/main/persistence/loading-store/profile-state-authority.ts new file mode 100644 index 00000000000..0ced5ba3248 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-authority.ts @@ -0,0 +1,127 @@ +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { AutomationRun } from '../../../shared/automations-types' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' + +export type ProfileStateMaintenance = { + /** Re-admit the unchanged profile before permitting any new persistence work. */ + resume(): Promise +} + +/** Keep offline/compatibility persistence loadable without eagerly importing SQLite. */ +export type ProfileStateAuthority = { + readonly asynchronous?: false + /** Return storage-form JSON, or undefined when this authority has no state yet. */ + readSerializedState(): string | undefined + + /** Fence a hash-identical checkpoint without rereading or rewriting its payload. */ + assertCurrentRevision?: () => void + + /** + * Optionally commit only the explicitly dirty top-level domains. Authorities + * without this capability retain the complete-document fallback below. + */ + writeSerializedDomains?: (replacements: readonly ProfileStateDomainReplacement[]) => void + + /** + * Commit automation definition replacements and the changed run projection + * in one profile-revision transaction without serializing unrelated domains. + */ + writeSerializedAutomationRuns?: ( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ) => void + + /** + * Durably replace the complete storage-form document. Implementations may + * reject a stale read instead of allowing last-writer-wins replacement. + */ + writeSerializedState(payload: Buffer): void + + /** Replace the whole profile; omitted domains and null payloads are deleted. */ + writeCompleteSerializedDomains?: (replacements: readonly ProfileStateDomainReplacement[]) => void + + /** Schedule bounded recovery protection after a successful primary commit. */ + scheduleBackup?: () => void + + /** Drain owned backup handles before shutdown or profile file mutations. */ + drainBackups?: (cancel?: boolean) => Promise + + /** Optionally publish a durable JSON export for rollback or a compatibility runtime. */ + writeJsonExport?: (targetPath: string) => number + + /** Publish canonical JSON for an older build and advance its SQLite acceptance marker. */ + writeJsonCompatibilityExport?: (targetPath: string) => number | undefined + + /** Refresh compatibility JSON after the final flush with asynchronous JSON file writes. */ + writeJsonCompatibilityExportAsync?: (targetPath: string) => Promise + + /** Optionally preserve the database family before an explicit recovery decision. */ + quarantineDatabase?: (quarantineRoot?: string, reason?: string) => ProfileStateDatabaseQuarantine + + /** Release any process-local database handle before a profile is switched or removed. */ + close?: () => void + + /** Only a clean maintenance close may provide an explicit resume capability. */ + pauseForMaintenance?: () => Promise +} + +export type AsyncProfileStateAuthority = Omit< + ProfileStateAuthority, + | 'asynchronous' + | 'assertCurrentRevision' + | 'writeSerializedDomains' + | 'writeSerializedAutomationRuns' + | 'writeSerializedState' + | 'writeCompleteSerializedDomains' + | 'writeJsonExport' + | 'writeJsonCompatibilityExport' + | 'quarantineDatabase' + | 'close' +> & { + readonly asynchronous: true + assertWritable(): void + abort(): Promise + assertCurrentRevision(): Promise + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): Promise + writeSerializedState(payload: Buffer): Promise + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise + writeJsonExport(targetPath: string): Promise + writeLatestJsonExport(dataFile: string): Promise + writeJsonCompatibilityExport(targetPath: string): Promise + quarantineDatabase( + quarantineRoot?: string, + reason?: string + ): Promise + close(): Promise +} + +export type ProfileStatePersistenceAuthority = ProfileStateAuthority | AsyncProfileStateAuthority + +export type ProfileStateDomainReplacement = { + domain: string + /** Storage-form JSON for the domain, or null to remove its row. */ + payload: string | null +} + +export type ProfileStateStartupPaneAlias = Omit + +/** A startup read paired with the authority that observed its revision. */ +export type ProfileStateAuthorityInitialState< + Authority extends ProfileStatePersistenceAuthority = ProfileStateAuthority +> = { + readonly authority: Authority + readonly unboundPaneAliases?: readonly ProfileStateStartupPaneAlias[] +} & ( + | { readonly serializedState: string | undefined; readonly takeParsedState?: never } + | { + readonly serializedState?: never + /** Transfer this storage-form object once, before the loader can decrypt or mutate it. */ + readonly takeParsedState: () => Record | undefined + } +) diff --git a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts new file mode 100644 index 00000000000..728c63f6b8d --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile flush caller cancellation', () => { + it('releases a canceled waiter while its admitted write completes and later saving continues', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + await write(domains) + started.resolve() + await release.promise + }) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + let settled = false + void pending.catch(() => { + settled = true + }) + await started.promise + controller.abort() + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(abort).not.toHaveBeenCalled() + expect(settled).toBe(true) + expect(() => authority.assertWritable()).not.toThrow() + } finally { + release.resolve() + await rejected + } + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('keeps an abandoned write ordered before the final checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const abandoned = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(abandoned).rejects.toThrow('aborted') + await started.promise + controller.abort() + store.getWorkspaceSession().activeTabId = 'shutdown-edit' + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + const result = final.catch((error: unknown) => error) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + } finally { + release.resolve() + await rejected + } + await expect(result).resolves.toBeUndefined() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'shutdown-edit' } + }) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-checkpoints.test.ts b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts new file mode 100644 index 00000000000..1ef5c6ef556 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-checkpoints.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { profileStateJsonExportPath } from '../profile-state/profile-state-export-path' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { scheduleSave } from './write-scheduling' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'checkpoint-test' +const fixtures: { directory: string; store: Store }[] = [] + +afterEach(async () => { + for (const { directory, store } of fixtures.splice(0)) { + store.freezeWrites() + await store.flushAsync() + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-checkpoint-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const backup = vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + authority.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + ) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store }) + store.flushOrThrow() + return { + directory, + dataFile, + store, + authority, + backup, + readState: () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, PROFILE_ID) + try { + return parseProfileStateRoot(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } + } + } +} + +function mutateThroughGetters(store: Store): void { + store.getWorkspaceSession().activeTabId = 'direct-local-tab' + store.getWorkspaceSession('ssh:build-host').activeTabId = 'direct-remote-tab' +} + +const EXPECTED_CHECKPOINT = { + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'direct-local-tab' }, + workspaceSessionsByHostId: { 'ssh:build-host': { activeTabId: 'direct-remote-tab' } } +} + +describe('complete profile state checkpoints', () => { + it('does not retain a save timer after writes are frozen', () => { + const { store } = fixture() + store.freezeWrites() + const setTimer = vi.spyOn(globalThis, 'setTimeout') + scheduleSave(store) + expect(setTimer).not.toHaveBeenCalled() + }) + + it.each(['sync', 'async'] as const)( + 'rejects a stale %s checkpoint even when the local hash is unchanged', + async (mode) => { + const { store, directory, readState } = fixture() + const other = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), PROFILE_ID) + try { + other.readSerializedState() + other.writeSerializedDomains([{ domain: 'futureDomain', payload: '{"external":true}' }]) + scheduleSave(store) + if (mode === 'sync') { + expect(() => store.flushOrThrow()).toThrow(/Profile state revision changed/) + } else { + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow( + /Profile state revision changed/ + ) + } + expect(readState()).toMatchObject({ futureDomain: { external: true } }) + } finally { + other.close() + } + } + ) + + it('captures nested history mutations after an unchanged checkpoint', async () => { + const { store, directory, readState } = fixture() + store.writeProfileStateJsonExport(join(directory, 'before.json')) + const run = store.listAutomationRuns()[0] + if (!run?.outputSnapshot) { + throw new Error('Expected a fixture run with output') + } + run.outputSnapshot.content = 'changed through a nested getter' + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + + await store.flushAsync() + + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + automationRuns: expect.arrayContaining([ + expect.objectContaining({ + id: run.id, + outputSnapshot: expect.objectContaining({ content: 'changed through a nested getter' }) + }) + ]) + }) + }) + + it.each([false, true])( + 'captures getter mutations alongside pending settings on quit (compatibility export: %s)', + async (exportJsonCompatibility) => { + const { store, dataFile, readState } = fixture() + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + + await store.flushAsync({ exportJsonCompatibility }) + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + if (exportJsonCompatibility) { + expect(parseProfileStateRoot(readFileSync(dataFile, 'utf8'))).toMatchObject( + EXPECTED_CHECKPOINT + ) + } + } + ) + + it.each(['explicit', 'revisioned', 'compatibility'] as const)( + 'includes getter mutations in the %s JSON export', + (mode) => { + const { store, directory, dataFile, readState } = fixture() + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + let exportPath = join(directory, 'rollback.json') + + if (mode === 'explicit') { + store.writeProfileStateJsonExport(exportPath) + } else if (mode === 'revisioned') { + const revision = store.writeLatestProfileStateJsonExport() + if (revision === undefined) { + throw new Error('Expected a revisioned export') + } + exportPath = profileStateJsonExportPath(dataFile, revision) + } else { + store.writeLatestProfileStateJsonCompatibilityExport() + exportPath = dataFile + } + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + expect(parseProfileStateRoot(readFileSync(exportPath, 'utf8'))).toMatchObject( + EXPECTED_CHECKPOINT + ) + } + ) + + it('takes the final checkpoint after an earlier queued writer clears its dirty domains', async () => { + const { store, backup, readState } = fixture() + store.updateSettings({ theme: 'light' }) + backup.mockImplementationOnce(() => { + queueMicrotask(() => { + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + }) + }) + + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const final = store.flushAsync() + await Promise.all([previous, final]) + + expect(readState()).toMatchObject(EXPECTED_CHECKPOINT) + }) + + it('keeps normal pending and synchronous writes selective', async () => { + const { store, authority } = fixture() + const fullWrite = vi.spyOn(authority, 'writeSerializedState') + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + store.patchWorkspaceSession({ activeTabId: 'scheduled-tab' }) + store.flushOrThrow() + + expect(fullWrite).not.toHaveBeenCalled() + expect( + selectiveWrite.mock.calls.map(([domains]) => domains.map(({ domain }) => domain)) + ).toEqual([['settings'], ['workspaceSession']]) + }) + + it('clears full-checkpoint mode after a synchronous hash no-op', () => { + const { store, authority } = fixture() + store.writeLatestProfileStateJsonExport() + + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + + expect(completeWrite).not.toHaveBeenCalled() + expect( + selectiveWrite.mock.calls.map(([domains]) => domains.map(({ domain }) => domain)) + ).toEqual([['settings']]) + }) + + it('does not write a frozen profile when final persistence requests a checkpoint', async () => { + const { store, authority, readState } = fixture() + const before = readState() + const fullWrite = vi.spyOn(authority, 'writeSerializedState') + mutateThroughGetters(store) + store.updateSettings({ theme: 'dark' }) + store.freezeWrites() + + await store.flushAsync() + + expect(fullWrite).not.toHaveBeenCalled() + expect(readState()).toEqual(before) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts new file mode 100644 index 00000000000..57a4915ad3f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, vi } from 'vitest' +import type { AutomationRun } from '../../../shared/automations-types' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { writeVersionedProfileStateExport } from '../profile-state/profile-state-versioned-export' +import type { + AsyncProfileStateAuthority, + ProfileStateDomainReplacement +} from './profile-state-authority' +import { Store } from './store' + +export function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((accept, refuse) => { + resolve = accept + reject = refuse + }) + return { promise, resolve, reject } +} + +/** Delay the authority boundary while retaining real SQLite and Store serialization. */ +export class DelayedAuthority implements AsyncProfileStateAuthority { + readonly asynchronous = true + private next: + | { started: ReturnType>; finish: ReturnType> } + | undefined + readonly captures: ProfileStateDomainReplacement[][] = [] + readonly close = vi.fn(async () => { + this.inner.close() + }) + + constructor(readonly inner: ProfileStateSqliteAuthority) {} + + pause() { + const gate = { started: deferred(), finish: deferred() } + this.next = gate + return gate + } + + assertWritable() {} + async abort() {} + readSerializedState() { + return this.inner.readSerializedState() + } + async assertCurrentRevision() { + await this.dispatch(() => this.inner.assertCurrentRevision()) + } + async writeSerializedState(payload: Buffer) { + const captured = Buffer.from(payload) + await this.dispatch(() => this.inner.writeSerializedState(captured)) + } + async writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]) { + const captured = structuredClone(replacements) + this.captures.push([...captured]) + await this.dispatch(() => this.inner.writeSerializedDomains(captured)) + } + async writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]) { + const captured = structuredClone(replacements) + this.captures.push([...captured]) + await this.dispatch(() => this.inner.writeCompleteSerializedDomains(captured)) + } + async writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ) { + const captured = structuredClone(replacements) + const capturedRuns = structuredClone(runs) + await this.dispatch(() => this.inner.writeSerializedAutomationRuns(captured, capturedRuns)) + } + async writeJsonExport(path: string) { + return this.inner.writeJsonExport(path) + } + async writeLatestJsonExport(path: string) { + return writeVersionedProfileStateExport(path, this.inner.writeJsonExport.bind(this.inner)) + } + async writeJsonCompatibilityExport(path: string) { + return this.inner.writeJsonCompatibilityExportAsync(path) + } + async quarantineDatabase(root?: string, reason?: string) { + return this.inner.quarantineDatabase(root, reason) + } + private async dispatch(operation: () => void) { + const gate = this.next + this.next = undefined + gate?.started.resolve() + await gate?.finish.promise + operation() + } +} + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + vi.restoreAllMocks() +}) + +export async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-')) + const path = join(directory, 'profile-state.db') + const inner = new ProfileStateSqliteAuthority(path, 'coordination-test') + inner.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + ) + const authority = new DelayedAuthority(inner) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + cleanups.push(async () => { + await store.freezeWritesAsync() + rmSync(directory, { recursive: true, force: true }) + }) + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + const readState = () => { + const reader = new ProfileStateSqliteAuthority(path, 'coordination-test') + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + } + return { store, authority, readState } +} diff --git a/src/main/persistence/loading-store/profile-state-direct-flush.test.ts b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts new file mode 100644 index 00000000000..9cf3d1ec2d1 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-direct-flush.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditAttemptLedger } from '../../../shared/codex-reset-credit-attempt-ledger' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { readProfileStateDomain } from '../profile-state/profile-state-domain-reader' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'direct-flush-test' +const fixtures: { directory: string; store: Store }[] = [] + +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + fixture.store.freezeWrites() + await fixture.store.flushAsync() + rmSync(fixture.directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture(seedState?: unknown) { + const directory = mkdtempSync(join(tmpdir(), 'orca-direct-flush-')) + const databasePath = join(directory, 'profile-state.db') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + if (seedState !== undefined) { + authority.writeSerializedState(Buffer.from(JSON.stringify(seedState))) + } + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) + fixtures.push({ directory, store }) + store.flushOrThrow() + return { + store, + read: (domain: string) => readProfileStateDomain(databasePath, PROFILE_ID, domain), + pendSession: () => store.patchWorkspaceSession({ activeWorktreeId: 'pending-workspace' }) + } +} + +describe('SQLite durability barriers with another selective write pending', () => { + it('commits a reset-credit claim before returning to its provider caller', async () => { + const state = fixture() + const ledger: CodexResetCreditAttemptLedger = { + version: 1, + attempts: [ + { + idempotencyKey: '158a0d86-8d8e-4589-b9c5-f53a59bdcdd8', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account', + accountRevision: 1, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + } + state.pendSession() + await state.store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + expect(state.read('codexResetCreditAttemptLedger')).toMatchObject({ + kind: 'value', + value: ledger + }) + }) + + it('commits Claude live-PTY admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.addClaudeLivePtySessionId('claude-session') + expect(state.read('claudeLivePtySessionIds')).toMatchObject({ + kind: 'value', + value: ['claude-session'] + }) + }) + + it('commits SSH lease admission before returning', () => { + const state = fixture() + state.pendSession() + state.store.upsertSshRemotePtyLease({ targetId: 'ssh-test', ptyId: 'pty-1', state: 'attached' }) + expect(state.read('sshRemotePtyLeases')).toMatchObject({ + kind: 'value', + value: [{ targetId: 'ssh-test', ptyId: 'pty-1', state: 'attached' }] + }) + }) + + it.each(['async', 'shutdown'] as const)( + 'persists SSH detachment through the %s barrier', + async (barrier) => { + const state = fixture() + state.store.upsertSshRemotePtyLease({ + targetId: 'ssh-test', + ptyId: 'pty-1', + state: 'attached' + }) + state.pendSession() + if (barrier === 'async') { + await state.store.markSshRemotePtyLeasesAsync('ssh-test', 'detached') + } else { + state.store.markSshRemotePtyLeasesForShutdown('ssh-test', 'detached') + await state.store.flushAsync() + } + expect(state.read('sshRemotePtyLeases')).toMatchObject({ + kind: 'value', + value: [{ targetId: 'ssh-test', ptyId: 'pty-1', state: 'detached' }] + }) + } + ) + + it('persists sealed SSH consumer recovery before relay setup continues', async () => { + const state = fixture() + state.pendSession() + await state.store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-test', + clientInstanceId: 'client-test', + serverBuildId: 'build-test', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'secret-owner-lease' + }) + const stored = state.read('sshPtyConsumerRecoveries') + expect(stored).toMatchObject({ kind: 'value', value: [{ targetId: 'ssh-test' }] }) + expect(JSON.stringify(stored)).not.toContain('secret-owner-lease') + state.pendSession() + await state.store.removeSshPtyConsumerRecovery('ssh-test') + expect(state.read('sshPtyConsumerRecoveries')).toMatchObject({ kind: 'value', value: [] }) + }) + + it('deletes an automation and its retained runs in the same commit', () => { + const state = fixture(buildProfileStateCutoverFixture()) + const automation = state.store.listAutomations()[0] + if (!automation) { + throw new Error('Fixture automation is absent') + } + expect(state.store.listAutomationRuns(automation.id)).not.toHaveLength(0) + state.store.deleteAutomation(automation.id) + expect(state.read('automations')).toMatchObject({ kind: 'value', value: [] }) + expect(state.read('automationRuns')).toMatchObject({ kind: 'value', value: [] }) + }) + + it('persists the session and UI identities moved with worktree metadata', () => { + const seed = buildProfileStateCutoverFixture() + const oldId = 'repo-local::/fixture/local' + const newId = 'repo-local::/fixture/renamed' + seed.workspaceSession.activeWorktreeId = oldId + seed.ui.showDotfilesByWorktree = { [oldId]: true } + const state = fixture(seed) + state.store.migrateWorktreeIdentity(oldId, newId) + state.store.flushOrThrow() + expect(state.read('workspaceSession')).toMatchObject({ + kind: 'value', + value: { activeWorktreeId: newId } + }) + expect(state.read('ui')).toMatchObject({ + kind: 'value', + value: { showDotfilesByWorktree: { [newId]: true } } + }) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-flush-lifetime.ts b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts new file mode 100644 index 00000000000..215c1babe85 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-flush-lifetime.ts @@ -0,0 +1,34 @@ +import type { StoreRuntimeState } from './store-runtime-state' + +/** Lifecycle cleanup must join every accepted operation before closing its writer. */ +export async function drainProfileStateOperations( + operations: Iterable | null | undefined> +): Promise { + const settled = await Promise.allSettled( + Array.from(operations, (operation) => Promise.resolve(operation)) + ) + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason + } + } +} + +/** A flush may dispatch again after SQL completes while its sidecars are still pending. */ +export async function runProfileStateFlush( + runtime: Pick, + operation: () => Promise +): Promise { + let finish!: () => void + const pending = new Promise((resolve) => { + finish = resolve + }) + runtime.pendingProfileFlushes.add(pending) + try { + await operation() + } finally { + // Each caller owns its result; lifecycle barriers may retry a known failed capture. + runtime.pendingProfileFlushes.delete(pending) + finish() + } +} diff --git a/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts new file mode 100644 index 00000000000..45f4111551b --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-import-lifetime.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { + clearMigrationUnsupportedPty, + setMigrationUnsupportedPty, + setMigrationUnsupportedPtyPersistenceListener +} from '../../agent-hooks/migration-unsupported-pty-state' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import * as composition from './store-domain-composition' +import { scheduleSave } from './write-scheduling' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] +const livePaneKey = 'live-tab:11111111-1111-4111-8111-111111111111' + +afterEach(async () => { + agentHookServer.setPaneKeyAliasPersistenceListener(null) + setMigrationUnsupportedPtyPersistenceListener(null) + agentHookServer.clearPaneKeyAliasesForPty('later-live-pty') + clearMigrationUnsupportedPty('later-live-pty') + for (const store of stores.splice(0)) { + await store.freezeWritesAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it.each([false, true])('isolates imported aliases and live listeners (load failure=%s)', (fail) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const directory = mkdtempSync(join(tmpdir(), 'orca-import-lifetime-')) + directories.push(directory) + const live = new Store({ dataFile: join(directory, 'live', 'orca-data.json') }) + stores.push(live) + const source = buildProfileStateCutoverFixture(directory) + for (const session of [ + source.workspaceSession, + ...Object.values(source.workspaceSessionsByHostId ?? {}) + ]) { + if (!session) { + continue + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { + layout.root = { type: 'leaf', leafId: 'pane:1' } + layout.activeLeafId = 'pane:1' + layout.ptyIdsByLeafId = { 'pane:1': `imported-${tabId}` } + } + } + const registerAlias = vi.spyOn(agentHookServer, 'registerPaneKeyAlias') + const replaceListener = vi.spyOn(agentHookServer, 'setPaneKeyAliasPersistenceListener') + if (fail) { + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + const domains = createDomains(runtime) + vi.spyOn(domains.adaptation, 'hydrateFolderWorkspaceDiffComments').mockImplementationOnce( + () => { + scheduleSave(domains.scheduling) + throw new Error('normalization refused') + } + ) + return domains + }) + } + const pendingTimers = vi.getTimerCount() + const createImport = () => + new Store({ + dataFile: join(directory, 'imported', 'orca-data.json'), + serializedState: JSON.stringify(source) + }) + if (fail) { + expect(createImport).toThrow('normalization refused') + } else { + const imported = createImport() + stores.push(imported) + expect(JSON.parse(imported.prepareProfileStateExport().json).legacyPaneKeyAliasEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ legacyPaneKey: 'tab-local:1', ptyId: 'imported-tab-local' }), + expect.objectContaining({ legacyPaneKey: 'tab-remote:1', ptyId: 'imported-tab-remote' }) + ]) + ) + } + expect(registerAlias).not.toHaveBeenCalled() + expect(replaceListener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(pendingTimers) + agentHookServer.registerPaneKeyAlias('live-tab:1', livePaneKey, 'later-live-pty') + setMigrationUnsupportedPty({ + ptyId: 'later-live-pty', + paneKey: livePaneKey, + tabId: 'live-tab', + worktreeId: 'live-worktree', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + }) + const persisted = JSON.parse(live.prepareProfileStateExport().json) + expect(persisted.legacyPaneKeyAliasEntries).toEqual([ + expect.objectContaining({ legacyPaneKey: 'live-tab:1', ptyId: 'later-live-pty' }) + ]) + expect(persisted.migrationUnsupportedPtyEntries).toEqual([ + expect.objectContaining({ ptyId: 'later-live-pty', paneKey: livePaneKey }) + ]) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts new file mode 100644 index 00000000000..b1497f049a4 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-compatibility.test.ts @@ -0,0 +1,125 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { createWorkerMaintenanceFixture } from './profile-state-maintenance-fixture' +import { profileStateJsonExportPaths } from '../profile-state/profile-state-export-path' +import { openProfileStateDatabase } from '../profile-state/profile-state-database' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('maintenance compatibility checkpoint', () => { + it('exports the current worker state before a clean profile switch releases its writer', async () => { + const { store, dataFile, readState } = await createWorkerMaintenanceFixture() + expect(existsSync(dataFile)).toBe(false) + store.updateSettings({ theme: 'dark' }) + store.getWorkspaceSession().activeTabId = 'latest-tab' + const maintenance = await store.beginProfileMaintenance() + const snapshot = JSON.parse(readFileSync(dataFile, 'utf8')) + expect(snapshot).toEqual(readState()) + expect(snapshot.settings.theme).toBe('dark') + expect(snapshot.workspaceSession.activeTabId).toBe('latest-tab') + const [retained] = profileStateJsonExportPaths(dataFile) + expect(JSON.parse(readFileSync(retained, 'utf8'))).toEqual(snapshot) + await maintenance.resume() + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('does not publish compatibility files for a recovery pause', async () => { + const { store, dataFile } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + await store.beginProfileMaintenance({ flush: false }) + expect(existsSync(dataFile)).toBe(false) + expect(profileStateJsonExportPaths(dataFile)).toEqual([]) + }) + + it('resumes admission after a known export failure while preserving the committed state', async () => { + const { store, authority, dataFile, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(authority, 'writeJsonCompatibilityExportAsync').mockRejectedValueOnce( + new Error('export disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('export disk refused') + expect(existsSync(dataFile)).toBe(false) + expect(readState().settings.theme).toBe('dark') + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + }) + + it('resumes after the worker cannot read JSON before staging compatibility acceptance', async () => { + const { store, dataFile, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + mkdirSync(dataFile) + store.updateSettings({ theme: 'dark' }) + + await expect(store.beginProfileMaintenance()).rejects.toMatchObject({ + outcome: 'known-failure' + }) + expect(readState().settings.theme).toBe('dark') + rmSync(dataFile, { recursive: true }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + }) + + it.each(['maintenance', 'update-preflight'] as const)( + 'resumes saving after a rolled-back %s export leaves both JSON versions accepted', + async (phase) => { + const { store, authority, dataFile, databaseFile, profileId, readState } = + await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.writeJsonCompatibilityExportAsync(dataFile) + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + opened.db.exec(` + CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + AND json_type(NEW.value, '$.pending') IS NULL + BEGIN SELECT RAISE(ABORT, 'injected promotion failure'); END + `) + } finally { + opened.db.close() + } + store.updateSettings({ theme: 'dark' }) + + await expect( + phase === 'maintenance' + ? store.beginProfileMaintenance() + : store.writeLatestProfileStateJsonCompatibilityExportAsync() + ).rejects.toMatchObject({ outcome: 'known-failure' }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + expect(readState().settings.theme).toBe('dark') + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + const repaired = openProfileStateDatabase(databaseFile, profileId) + try { + repaired.db.exec('DROP TRIGGER reject_acceptance') + } finally { + repaired.db.close() + } + await (await store.beginProfileMaintenance()).resume() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts new file mode 100644 index 00000000000..7b9a5e16fa9 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('quit during failed profile maintenance', () => { + it('retries a known failed checkpoint and persists shutdown edits before closing', async () => { + const { store, authority, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ targetId: 'remote', ptyId: 'pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('SQLITE_BUSY') + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toThrow('SQLITE_BUSY') + await started.promise + store.markSshRemotePtyLeasesForShutdown('remote', 'detached') + const final = store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + const result = final.catch((error: unknown) => error) + release.resolve() + await failed + await expect(result).resolves.toBeUndefined() + expect(checkpoint).toHaveBeenCalledTimes(2) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + sshRemotePtyLeases: [expect.objectContaining({ state: 'detached' })] + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8'))).toEqual(readState()) + }) + + it.each(['indeterminate', 'changed-source'] as const)( + 'keeps %s maintenance fenced during quit', + async (kind) => { + const { store, authority, readState, peer } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const durable = readState() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = + kind === 'indeterminate' + ? new ProfileStateWriterError('test-unknown-commit', 'commit outcome unknown', kind) + : new Error('SQLITE_BUSY') + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + if (kind === 'changed-source') { + const other = peer() + try { + other.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + other.close() + } + } + throw failure + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toBe(failure) + await started.promise + const final = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([failed, final]) + expect(checkpoint).toHaveBeenCalledOnce() + expect(readState()).toEqual( + kind === 'changed-source' ? { ...durable, peer: { preserved: true } } : durable + ) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts new file mode 100644 index 00000000000..31e4d173b6f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-fixture.ts @@ -0,0 +1,109 @@ +import { build } from 'esbuild' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, vi } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { ProfileStateWorkerAuthority } from '../profile-state/profile-state-worker-authority' +import { Store } from './store' + +let bundleRoot: string +let workerOptions: { workerPath: string; backupWorkerPath: string } +const stores: Store[] = [] +const roots: string[] = [] +const releases: (() => void)[] = [] +type ProfileFixtureLocation = { directory: string; profileId: string; cleanupRoot?: string } + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-maintenance-worker-')) + workerOptions = { + workerPath: join(bundleRoot, 'profile-state-writer-worker-entry.js'), + backupWorkerPath: join(bundleRoot, 'profile-state-backup-worker-entry.js') + } + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundleRoot, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync().catch(() => {}))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.restoreAllMocks() + vi.useRealTimers() +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +export function maintenanceBarrier() { + const gate = Promise.withResolvers() + releases.push(gate.resolve) + return gate +} + +function paths(profile?: ProfileFixtureLocation) { + const directory = profile?.directory ?? mkdtempSync(join(tmpdir(), 'orca-maintenance-profile-')) + roots.push(profile?.cleanupRoot ?? directory) + return { + directory, + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: profile?.profileId ?? 'maintenance-test' + } +} + +export async function createWorkerMaintenanceFixture( + profile?: ProfileFixtureLocation, + onFailure?: (error: Error) => void +) { + const input = paths(profile) + const bootstrap = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + bootstrap.writeSerializedState( + Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(input.directory))) + ) + const state = bootstrap.readInitialState().takeParsedState?.() + const authority = new ProfileStateWorkerAuthority(bootstrap.retireForWorker(), { + ...workerOptions, + onFailure + }) + await authority.ready + const store = new Store({ + dataFile: input.dataFile, + profileStateAuthority: authority, + initialAuthorityState: { authority, takeParsedState: () => state } + }) + stores.push(store) + const backup = vi.spyOn(authority, 'scheduleBackup').mockImplementation(() => {}) + await store.flushPendingOrThrowAsync() + backup.mockRestore() + const peer = () => new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + const readState = () => { + const reader = peer() + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } + } + return { ...input, store, authority, peer, readState } +} + +export function createJsonMaintenanceFixture() { + const input = paths() + writeFileSync(input.dataFile, JSON.stringify(buildProfileStateCutoverFixture(input.directory))) + const store = new Store({ dataFile: input.dataFile }) + stores.push(store) + return { ...input, store } +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts new file mode 100644 index 00000000000..84a849b3b26 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('failed maintenance recovery', () => { + it.each(['maintenance', 'final', 'freeze'] as const)( + '%s cancels an active backup after a routine flush completes', + async (kind) => { + const { store } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + await store.flushPendingOrThrowAsync() + const stop = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : store.freezeWritesAsync() + await canceled.promise + await stop + } + ) + + it('cancels between checkpoints without aborting an acknowledged write', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (domains) => { + started.resolve() + await release.promise + await write(domains) + } + ) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.beginProfileMaintenance({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + await started.promise + controller.abort() + release.resolve() + await rejected + expect(abort).not.toHaveBeenCalled() + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-cancellation' }) + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'after-cancellation' } + }) + }) + + it('restores the writer and snapshot admission after a known failed checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-failure' }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'after-failure' } + }) + await (await store.beginProfileMaintenance()).resume() + }) + + it('keeps changed storage fenced when recovering a known failure', async () => { + const { store, authority, peer, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ theme: 'dark' }) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(async () => { + const writer = peer() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + writer.close() + throw new Error('disk refused') + }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(readState().peer).toEqual({ preserved: true }) + }) + + it('does not extend the maintenance checkpoint for unadmitted saves', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const pending = store.beginProfileMaintenance() + await started.promise + for (let terminalFontSize = 12; terminalFontSize < 32; terminalFontSize++) { + store.updateSettings({ terminalFontSize }) + } + release.resolve() + const maintenance = await pending + expect(checkpoint).toHaveBeenCalledOnce() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.terminalFontSize).toBe(31) + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.test.ts b/src/main/persistence/loading-store/profile-state-maintenance.test.ts new file mode 100644 index 00000000000..8dcb39eef54 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.test.ts @@ -0,0 +1,297 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ActiveViewPreference, getActiveViewPreferenceFile } from '../../active-view-preference' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import { + createJsonMaintenanceFixture, + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile maintenance admission', () => { + it('drains accepted writes and captures newer edits after blocking new durable operations', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = maintenanceBarrier() + const started = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await gate.promise + await write(domains) + }) + const accepted = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: 'accepted' } + }) + await started.promise + const stopped = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + const refused = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(refused)).rejects.toThrow('finalized') + expect(refused).not.toHaveBeenCalled() + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow('finalized') + expect(() => store.stageWorkspaceSessionBeforeUnload(store.getWorkspaceSession())).toThrow( + 'maintenance' + ) + store.updateSettings({ theme: 'light' }) + store.getWorkspaceSession().activeTabId = 'during-maintenance' + expect(stopped).not.toHaveBeenCalled() + gate.resolve() + await expect(accepted).resolves.toBe('accepted') + const maintenance = await paused + expect(stopped).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'during-maintenance' } + }) + store.updateSettings({ theme: 'dark' }) + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('refuses re-admission after a competing write without adopting its revision', async () => { + const { store, peer, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const maintenance = await store.beginProfileMaintenance() + const writer = peer() + try { + writer.readSerializedState() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + writer.close() + } + await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') + const mutate = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(mutate)).rejects.toThrow('finalized') + expect(mutate).not.toHaveBeenCalled() + expect(readState().peer).toEqual({ preserved: true }) + }) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for an admitted flush between SQL passes', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + release.resolve() + await older + await paused + expect(close).toHaveBeenCalled() + expect(readState().settings.theme).toBe('light') + } + ) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for accepted retries after another flush reports a known failure', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const failureStarted = maintenanceBarrier() + const fail = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + failureStarted.resolve() + await fail.promise + throw new Error('disk refused') + }) + const failed = expect( + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ).rejects.toThrow('disk refused') + await failureStarted.promise + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + fail.resolve() + await failed + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await paused + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + } + ) + + it('cancels a backup and waits for its worker to exit before releasing maintenance', async () => { + const { store, authority, databaseFile } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + await release.promise + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + const close = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + let done = false + void paused.then(() => { + done = true + }) + await canceled.promise + expect(done).toBe(false) + expect(close).not.toHaveBeenCalled() + release.resolve() + await paused + expect(close).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(0) + }) + + it('drains an accepted flush before rejecting canceled maintenance', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const close = vi.spyOn(authority, 'close') + const controller = new AbortController() + controller.abort() + const rejected = expect( + store.beginProfileMaintenance({ signal: controller.signal }) + ).rejects.toThrow('aborted') + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await rejected + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + }) + + it('joins an ongoing maintenance close at final shutdown without reopening or rewriting', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const close = authority.close.bind(authority) + vi.spyOn(authority, 'close').mockImplementationOnce(async () => { + started.resolve() + await release.promise + await close() + }) + const paused = store.beginProfileMaintenance() + await started.promise + const write = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + release.resolve() + const maintenance = await paused + await final + expect(write).not.toHaveBeenCalled() + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('quarantines a faulted worker only after closing it, without writing current memory', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + const durable = readState() + store.updateSettings({ theme: durable.settings.theme === 'dark' ? 'light' : 'dark' }) + await authority.abort() + const result = await store.quarantineProfileStateDatabaseAsync(directory, 'worker-failure') + expect(result.copiedFiles.some((path) => path.endsWith('profile-state.db'))).toBe(true) + expect(readState()).toEqual(durable) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + }) + + it('never provides a resume token after a faulted normal-maintenance attempt', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.abort() + await expect(store.beginProfileMaintenance()).rejects.toThrow('aborted') + await expect(store.beginProfileMaintenance()).rejects.toThrow('already stopped') + }) + + it('pauses JSON and preference timers, then persists edits after unchanged-source resume', async () => { + const { store, dataFile } = createJsonMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + const before = readFileSync(dataFile) + const preference = getActiveViewPreferenceFile(dataFile) + const preferenceBefore = readFileSync(preference) + vi.useFakeTimers() + store.updateSettings({ theme: 'dark' }) + store.updateUI({ activeView: 'settings' }) + await vi.advanceTimersByTimeAsync(6_000) + expect(readFileSync(dataFile)).toEqual(before) + expect(readFileSync(preference)).toEqual(preferenceBefore) + vi.useRealTimers() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + expect(JSON.parse(readFileSync(preference, 'utf8')).activeView).toBe('settings') + }) + + it('refuses legacy JSON resume if the source changed during maintenance', async () => { + const { store, dataFile } = createJsonMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + writeFileSync(dataFile, '{"peer":true}') + await expect(maintenance.resume()).rejects.toThrow('Profile storage changed') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(readFileSync(dataFile, 'utf8')).toBe('{"peer":true}') + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.ts b/src/main/persistence/loading-store/profile-state-maintenance.ts new file mode 100644 index 00000000000..c4632f26fb1 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.ts @@ -0,0 +1,198 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { profileStateDatabaseFile } from '../../../shared/profile-state-storage-paths' +import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' +import { hasProfileStateDatabaseFiles } from '../profile-state/profile-state-storage-classification' +import type { ProfileStateMaintenance } from './profile-state-authority' +import type { StoreDomains } from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { drainProfileStateOperations } from './profile-state-flush-lifetime' +import { flushCurrentStateAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +export type ProfileStateMaintenanceOptions = { + signal?: AbortSignal + /** Recovery must preserve the existing database even when its state cannot be flushed. */ + flush?: boolean +} + +export function freezeProfileStateWrites(runtime: StoreRuntimeState): void { + if (runtime.profileStateAuthority?.asynchronous) { + throw new Error('Live profile persistence requires an awaited close') + } + runtime.writesFrozen = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.profileStateAuthority?.close?.() +} + +export async function freezeProfileStateWritesAsync(runtime: StoreRuntimeState): Promise { + runtime.quitFlushStarted = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + try { + await drainProfileStateOperations([ + runtime.pendingProfileMaintenance, + runtime.activeViewPreference.flushAsync(), + drainProfileFileWork(runtime) + ]) + } finally { + runtime.writesFrozen = true + await runtime.profileStateAuthority?.close?.() + } +} + +/** Stop admission before the first await; only unchanged-source maintenance may resume. */ +export function beginProfileStateMaintenance( + runtime: StoreRuntimeState, + domains: StoreDomains, + options: ProfileStateMaintenanceOptions = {} +): Promise { + if (runtime.profileMaintenancePending || runtime.quitFlushStarted || runtime.writesFrozen) { + return Promise.reject(new Error('Profile persistence is already stopped for maintenance')) + } + runtime.profileMaintenancePending = true + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + const resumePreference = runtime.activeViewPreference.pauseForMaintenance() + const resumeScheduling = () => { + runtime.writesFrozen = false + runtime.profileMaintenancePending = false + runtime.pendingProfileMaintenance = null + resumePreference() + scheduleSave(domains.scheduling) + } + const paused = pauseProfileState(runtime, domains, options).then((authority) => { + let consumed = false + return { + resume: async () => { + if (consumed || runtime.quitFlushStarted || !runtime.profileMaintenancePending) { + throw new Error('Profile persistence cannot resume after finalization') + } + consumed = true + await authority.resume() + if (runtime.quitFlushStarted) { + await runtime.profileStateAuthority?.close?.() + throw new Error('Profile persistence finalized during maintenance admission') + } + resumeScheduling() + } + } + }) + const recoverable = paused.catch(async (error: unknown) => { + if (await canResumeFailedMaintenance(runtime, options, error)) { + resumeScheduling() + } else { + runtime.writesFrozen = true + await runtime.profileStateAuthority?.close?.() + } + throw error + }) + runtime.pendingProfileMaintenance = recoverable.then(() => {}) + void runtime.pendingProfileMaintenance.catch(() => {}) + return recoverable +} + +async function canResumeFailedMaintenance( + runtime: StoreRuntimeState, + options: ProfileStateMaintenanceOptions, + error: unknown +): Promise { + try { + await drainProfileFileWork(runtime) + if ( + options.flush === false || + runtime.writesFrozen || + profileStateWriterFailureOutcome(error) === 'indeterminate' + ) { + return false + } + const authority = runtime.profileStateAuthority + if (authority?.asynchronous) { + authority.assertWritable() + } + await (authority?.pauseForMaintenance + ? (await authority.pauseForMaintenance()).resume() + : authority?.assertCurrentRevision?.()) + return true + } catch { + return false + } +} + +async function pauseProfileState( + runtime: StoreRuntimeState, + domains: StoreDomains, + { signal, flush = true }: ProfileStateMaintenanceOptions +): Promise { + const authority = runtime.profileStateAuthority + signal?.throwIfAborted() + await drainProfileFileWork(runtime) + signal?.throwIfAborted() + if (flush) { + // Cancel between commands so a dispatched commit retains a known outcome. + await flushCurrentStateAsync(domains.flushBarriers, { + requireInitialGenerationDurable: true, + fullCheckpoint: true + }) + signal?.throwIfAborted() + await authority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + signal?.throwIfAborted() + runtime.writesFrozen = true + if (!flush) { + await authority?.close?.() + return { + resume: async () => { + throw new Error('Recovery maintenance requires reloading the profile') + } + } + } + if (authority?.pauseForMaintenance) { + return authority.pauseForMaintenance() + } + await authority?.close?.() + if (authority) { + throw new Error('Profile authority cannot safely resume from maintenance') + } + return pauseJsonProfile(runtime.dataFile) +} + +async function drainProfileFileWork(runtime: StoreRuntimeState): Promise { + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.pendingWrite, + runtime.pendingSnapshotFileWork, + runtime.pendingGithubCacheWrite, + runtime.activeViewPreference.waitForPendingWrite(), + runtime.profileStateAuthority?.drainBackups?.( + runtime.profileMaintenancePending || runtime.quitFlushStarted + ) + ]) +} + +async function pauseJsonProfile(dataFile: string): Promise { + const before = createHash('sha256') + .update(await readFile(dataFile)) + .digest('hex') + return { + resume: async () => { + const current = createHash('sha256') + .update(await readFile(dataFile)) + .digest('hex') + if ( + hasProfileStateDatabaseFiles(profileStateDatabaseFile(dirname(dataFile))) || + current !== before + ) { + throw new Error('Profile storage changed during maintenance; reload is required') + } + } + } +} diff --git a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts new file mode 100644 index 00000000000..3fe64562997 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' +import type { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'finalizing-retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'finalizing-retirement-pty', + incarnationId: 'finalizing-retirement-incarnation' +} + +function retire(store: Store, connectionId?: string) { + return retirePersistedStablePaneOwner( + store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ) +} + +function assertNewSnapshotsRefused(store: Store) { + const session = store.getWorkspaceSession() + expect(() => store.setWorkspaceSession(session)).toThrow('blocking new terminal snapshot work') + expect(() => store.stageWorkspaceSessionBeforeUnload(session)).toThrow( + 'blocking new terminal snapshot work' + ) +} + +describe.each([ + ['running', undefined], + ['maintenance', undefined], + ['freeze', undefined], + ['final', undefined], + ['running', 'retirement-ssh'], + ['maintenance', 'retirement-ssh'], + ['freeze', 'retirement-ssh'], + ['final', 'retirement-ssh'] +] as const)('admitted terminal retirement during %s on host %s', (kind, connectionId) => { + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const persistedSession = ( + readState: Awaited>['readState'] + ) => { + const state = readState() + return hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + } + const stop = (store: Store) => + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : Promise.resolve() + const assertSnapshotAdmission = (store: Store) => { + if (kind !== 'running') { + assertNewSnapshotsRefused(store) + } + } + + it('persists an accepted queued retirement while refusing new snapshots', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const accepted = retire(store, connectionId).then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await previous + await stopping + expect(await accepted).toEqual({ value: true }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + assertSnapshotAdmission(store) + }) + + it('finishes a failed retirement rollback before closing its writer', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const original = structuredClone(store.getWorkspaceSession(hostId)) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw failure + }) + const rejected = retire(store, connectionId).catch((error: unknown) => error) + await started.promise + expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + store.getWorkspaceSession(hostId).activeTabId = 'newer-active-tab' + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await stopping + expect(await rejected).toBe(failure) + expect(store.getWorkspaceSession(hostId)).toEqual({ + ...original, + activeTabId: 'newer-active-tab' + }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toEqual( + original.terminalLayoutsByTabId[binding.tabId] + ) + assertSnapshotAdmission(store) + }) +}) + +describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { + it('preserves durable state and refuses later saves after a callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const original = readState() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('callback failed') + if (phase === 'rollback') { + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) + } + await expect( + store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + if (phase === 'mutate') { + throw failure + } + return { + value: undefined, + rollback: () => { + throw failure + } + } + }) + ).rejects.toBe(failure) + assertNewSnapshotsRefused(store) + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + await expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + expect(readState()).toEqual(original) + }) +}) + +it('rejects an already admitted final flush after a queued callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const failure = new Error('partial edit failed') + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + throw failure + }) + const rejectedMutation = expect(mutation).rejects.toBe(failure) + const rejectedFinal = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([previous, rejectedMutation, rejectedFinal]) + expect(readState().settings.theme).toBe('dark') +}) diff --git a/src/main/persistence/loading-store/profile-state-selective-write.ts b/src/main/persistence/loading-store/profile-state-selective-write.ts new file mode 100644 index 00000000000..060078ea436 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-selective-write.ts @@ -0,0 +1,99 @@ +import type { ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' +import type { + ProfileStateAuthority, + ProfileStateDomainReplacement, + ProfileStatePersistenceAuthority +} from './profile-state-authority' +import { buildProfileStateDomainReplacements } from './profile-state-authority-writes' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { AutomationRun } from '../../../shared/automations-types' + +export type SelectiveProfileStateWriteResult = { + handled: boolean + aborted: boolean + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export type PreparedSelectiveProfileStateWrite = { + replacements: ProfileStateDomainReplacement[] + automationRuns: readonly AutomationRun[] | undefined + consumedAutomationRuns: boolean + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] +} + +export function writeSelectiveProfileState( + authority: ProfileStateAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: Set | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined, + isCurrent?: () => boolean +): SelectiveProfileStateWriteResult { + const prepared = prepareSelectiveProfileStateWrite( + authority, + serialization, + dirtyDomains, + pendingAutomationRunsAfter + ) + if (!prepared) { + return { + handled: false, + aborted: false, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (isCurrent && !isCurrent()) { + return { + handled: true, + aborted: true, + consumedAutomationRuns: false, + protectedSecretUpdates: [] + } + } + if (prepared.automationRuns !== undefined) { + authority?.writeSerializedAutomationRuns?.(prepared.replacements, prepared.automationRuns) + } else { + authority?.writeSerializedDomains?.(prepared.replacements) + } + dirtyDomains?.clear() + return { + handled: true, + aborted: false, + consumedAutomationRuns: prepared.consumedAutomationRuns, + protectedSecretUpdates: prepared.protectedSecretUpdates + } +} + +export function prepareSelectiveProfileStateWrite( + authority: ProfileStatePersistenceAuthority | undefined, + serialization: StateSerializationSecretHandlingOperations, + dirtyDomains: ReadonlySet | null, + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined +): PreparedSelectiveProfileStateWrite | undefined { + if ( + !authority || + dirtyDomains === null || + dirtyDomains.size === 0 || + !authority.writeSerializedDomains + ) { + return undefined + } + const useAutomationDelta = + pendingAutomationRunsAfter !== undefined && + authority.writeSerializedAutomationRuns !== undefined + const serializableDomains = useAutomationDelta + ? new Set([...dirtyDomains].filter((domain) => domain !== 'automationRuns')) + : dirtyDomains + const built = serialization.buildStateDomainsToSave(serializableDomains) + if (built === undefined) { + return undefined + } + const { payload, protectedSecretUpdates } = built + return { + replacements: buildProfileStateDomainReplacements(payload, serializableDomains), + automationRuns: useAutomationDelta ? pendingAutomationRunsAfter : undefined, + consumedAutomationRuns: pendingAutomationRunsAfter !== undefined, + protectedSecretUpdates + } +} diff --git a/src/main/persistence/loading-store/profile-state-settings-writes.test.ts b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts new file mode 100644 index 00000000000..e7f64b6278a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-settings-writes.test.ts @@ -0,0 +1,320 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { setSecretStore } from '../../../shared/secret-store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const ORIGINAL = { + opencodeSessionCookie: 'original-cookie', + httpProxyUrl: 'http://original:password@proxy.test:8080' +} +type Failure = 'none' | 'unavailable' | 'availability' | 'encryption' | 'decryption' +let failure: Failure = 'none' +let nonce = 0 +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + failure = 'none' + nonce = 0 + setSecretStore({ + isEncryptionAvailable: () => { + if (failure === 'availability') { + throw new Error('keychain unavailable') + } + return failure !== 'unavailable' + }, + encryptString: (plaintext) => { + if (failure === 'encryption') { + throw new Error('encryption failed') + } + return Buffer.from(`cipher:${++nonce}:${plaintext}`) + }, + decryptString: (ciphertext) => { + if (failure === 'decryption') { + throw new Error('decryption failed') + } + const value = ciphertext.toString() + if (!value.startsWith('cipher:')) { + throw new Error('invalid ciphertext') + } + return value.slice(value.indexOf(':', 'cipher:'.length) + 1) + }, + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-settings-domain-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const profileId = 'settings-domain' + const authority = new ProfileStateSqliteAuthority(databasePath, profileId) + const fixtureState = buildProfileStateCutoverFixture(directory) + authority.writeSerializedState( + Buffer.from( + JSON.stringify({ + ...getDefaultPersistedState(directory), + automationRuns: fixtureState.automationRuns, + futureTopLevelExtension: fixtureState.futureTopLevelExtension + }) + ) + ) + const createStore = (storage = new ProfileStateSqliteAuthority(databasePath, profileId)) => { + const store = new Store({ dataFile, profileStateAuthority: storage }) + stores.push(store) + return store + } + const store = createStore(authority) + store.updateSettings(ORIGINAL) + store.flushOrThrow() + const read = () => { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return { + state: parseProfileStateRoot(readProfileStateSnapshot(opened.db).json), + otherDocuments: opened.db + .prepare( + "SELECT * FROM profile_state_documents WHERE domain <> 'settings' ORDER BY rowid" + ) + .all(), + runs: opened.db.prepare('SELECT * FROM profile_state_automation_runs ORDER BY run_id').all() + } + } finally { + opened.db.close() + } + } + return { store, authority, read, reopen: () => createStore() } +} + +describe('selective SQLite settings persistence', () => { + it.each(['sync', 'async'] as const)( + 'saves settings through the %s barrier without rewriting history or unknown domains', + async (mode) => { + const state = fixture() + const before = state.read() + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + state.store.updateSettings({ terminalFontSize: 19, httpProxyBypassRules: '*.internal' }) + if (mode === 'sync') { + state.store.flushOrThrow() + } else { + await state.store.flushPendingOrThrowAsync() + } + expect(wholeWrite).not.toHaveBeenCalled() + const after = state.read() + expect(after.otherDocuments).toEqual(before.otherDocuments) + expect(after.runs).toEqual(before.runs) + expect(after.state).toMatchObject({ + settings: { terminalFontSize: 19, httpProxyBypassRules: '*.internal' }, + futureTopLevelExtension: before.state.futureTopLevelExtension + }) + expect(JSON.stringify(after.state)).not.toContain(ORIGINAL.opencodeSessionCookie) + expect(JSON.stringify(after.state)).not.toContain(ORIGINAL.httpProxyUrl) + expect(state.reopen().getSettings()).toMatchObject({ ...ORIGINAL, terminalFontSize: 19 }) + } + ) + + it.each(['unavailable', 'availability', 'encryption'] as const)( + 'retains committed secrets while %s and retries them after recovery', + async (mode) => { + const state = fixture() + const before = state.read() + failure = mode + state.store.updateSettings({ opencodeSessionCookie: 'pending-cookie', terminalFontSize: 18 }) + await state.store.flushPendingOrThrowAsync() + expect(state.read().state.settings).toEqual({ + ...getSettingsRecord(before.state), + terminalFontSize: 18 + }) + failure = 'none' + state.store.updateSettings({ terminalFontSize: 20 }) + await state.store.flushPendingOrThrowAsync() + expect(state.reopen().getSettings()).toMatchObject({ + ...ORIGINAL, + opencodeSessionCookie: 'pending-cookie', + terminalFontSize: 20 + }) + } + ) + + it('preserves sealed settings on unrelated saves and permits an explicit clear', () => { + const state = fixture() + const before = state.read() + state.store.freezeWrites() + failure = 'decryption' + const sealed = state.reopen() + sealed.flushOrThrow() + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + sealed.updateSettings({ terminalFontSize: 21 }) + sealed.flushOrThrow() + expect(state.read().state.settings).toEqual({ + ...getSettingsRecord(before.state), + terminalFontSize: 21 + }) + sealed.updateSettings({ opencodeSessionCookie: '', httpProxyUrl: '' }) + sealed.flushOrThrow() + failure = 'none' + expect(state.reopen().getSettings()).toMatchObject({ + opencodeSessionCookie: '', + httpProxyUrl: '', + terminalFontSize: 21 + }) + }) + + it('does not retain ciphertext from a failed selective commit', () => { + const state = fixture() + const before = state.read() + vi.spyOn(state.authority, 'writeSerializedDomains').mockImplementationOnce(() => { + throw new Error('commit failed') + }) + state.store.updateSettings({ opencodeSessionCookie: 'uncommitted-cookie' }) + expect(() => state.store.flushOrThrow()).toThrow('commit failed') + expect(state.read()).toEqual(before) + failure = 'unavailable' + state.store.updateSettings({ terminalFontSize: 22 }) + state.store.flushOrThrow() + failure = 'none' + expect(state.reopen().getSettings()).toMatchObject({ ...ORIGINAL, terminalFontSize: 22 }) + }) + + it('commits pending session/settings domains together and falls back for unclassified updates', () => { + const state = fixture() + state.store.patchWorkspaceSession({ activeTabId: 'pending-tab' }) + state.store.updateSettings({ terminalFontSize: 23 }) + state.store.flushOrThrow() + expect(state.read().state).toMatchObject({ + settings: { terminalFontSize: 23 }, + workspaceSession: { activeTabId: 'pending-tab' } + }) + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + state.store.updateSettings({ terminalFontSize: 24 }) + state.store.updateOnboarding({ outcome: 'completed' }) + state.store.flushOrThrow() + expect(wholeWrite).toHaveBeenCalledOnce() + expect(state.read().state).toMatchObject({ + settings: { terminalFontSize: 24 }, + onboarding: { outcome: 'completed' } + }) + }) + + it.each(['unavailable', 'encryption'] as const)( + 'retries deferred UI and SSH secrets on a settings save after %s recovers', + async (mode) => { + const state = fixture() + const recovery = { + targetId: 'ssh-test', + clientInstanceId: 'client-test', + serverBuildId: 'build-test', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'original-lease' + } + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.upsertSshPtyConsumerRecovery(recovery) + const before = state.read().state + + failure = mode + state.store.updateUI({ browserKagiSessionLink: 'pending-link' }) + await state.store.flushPendingOrThrowAsync() + await state.store.upsertSshPtyConsumerRecovery({ ...recovery, ownerLease: 'pending-lease' }) + expect(state.read().state).toMatchObject({ + ui: before.ui, + sshPtyConsumerRecoveries: before.sshPtyConsumerRecoveries + }) + + failure = 'none' + const wholeWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + wholeWrite.mockImplementationOnce(() => { + throw new Error('recovery commit failed') + }) + state.store.updateSettings({ terminalFontSize: 25 }) + await expect(state.store.flushPendingOrThrowAsync()).rejects.toThrow('recovery commit failed') + expect(state.read().state).toMatchObject({ + ui: before.ui, + sshPtyConsumerRecoveries: before.sshPtyConsumerRecoveries + }) + state.store.updateSettings({ terminalFontSize: 26 }) + await state.store.flushPendingOrThrowAsync() + expect(wholeWrite).toHaveBeenCalledTimes(2) + const reopened = state.reopen() + expect(reopened.getUI().browserKagiSessionLink).toBe('pending-link') + expect(reopened.getSshPtyConsumerRecovery('ssh-test')?.ownerLease).toBe('pending-lease') + expect(JSON.stringify(state.read().state)).not.toMatch(/pending-link|pending-lease/) + + state.store.updateSettings({ terminalFontSize: 27 }) + await state.store.flushPendingOrThrowAsync() + expect(wholeWrite).toHaveBeenCalledTimes(2) + } + ) + + it('keeps an explicit UI secret clear after an unavailable write and later settings save', async () => { + const state = fixture() + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.flushPendingOrThrowAsync() + failure = 'unavailable' + state.store.updateUI({ browserKagiSessionLink: 'pending-link' }) + await state.store.flushPendingOrThrowAsync() + state.store.updateUI({ browserKagiSessionLink: null }) + await state.store.flushPendingOrThrowAsync() + failure = 'none' + state.store.updateSettings({ terminalFontSize: 28 }) + await state.store.flushPendingOrThrowAsync() + expect(state.reopen().getUI().browserKagiSessionLink).toBeNull() + }) + + it.each(['unavailable', 'decryption'] as const)( + 'persists an explicit empty UI secret clear after reopening with %s secrets', + async (mode) => { + const state = fixture() + state.store.updateUI({ browserKagiSessionLink: 'original-link' }) + await state.store.flushPendingOrThrowAsync() + state.store.freezeWrites() + failure = mode + const sealed = state.reopen() + await sealed.flushPendingOrThrowAsync() + expect(sealed.getUI().browserKagiSessionLink).toBe('') + sealed.updateUI({ browserKagiSessionLink: '' }) + sealed.updateSettings({ terminalFontSize: 29 }) + await sealed.flushPendingOrThrowAsync() + failure = 'none' + expect(state.reopen().getUI().browserKagiSessionLink).toBeNull() + } + ) +}) + +function getSettingsRecord(state: Record): Record { + const settings = state.settings + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + throw new Error('Expected persisted settings') + } + return Object.fromEntries(Object.entries(settings)) +} diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts new file mode 100644 index 00000000000..596b75cf219 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -0,0 +1,858 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_AUTOMATION_RUNS_PER_AUTOMATION } from '../../../shared/automation-run-retention' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { + profileStateJsonMatchesAcceptance, + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateSnapshot +} from '../profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../profile-state/profile-state-document-validation' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../profile-state/profile-state-database' +import { profileStateJsonExportPath } from '../profile-state/profile-state-export-path' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('./store') +const { createProfileStateStore } = await import('../profile-state/profile-state-store-factory') + +const temporaryDirectories: string[] = [] +const backupAuthorities = new Set() +const authorities = new Set() +const scheduleBackup = ProfileStateSqliteAuthority.prototype.scheduleBackup + +function createAuthority(databasePath: string, profileId: string): ProfileStateSqliteAuthority { + const authority = new ProfileStateSqliteAuthority(databasePath, profileId) + authorities.add(authority) + return authority +} + +beforeEach(() => { + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation( + function (this: ProfileStateSqliteAuthority) { + backupAuthorities.add(this) + scheduleBackup.call(this) + } + ) +}) + +afterEach(async () => { + for (const authority of authorities) { + authority.close() + await authority.drainBackups() + } + authorities.clear() + backupAuthorities.clear() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('Store with an injected SQLite profile-state authority', () => { + it('rejects profile-state buffers that are not valid UTF-8', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-invalid-utf8-')) + temporaryDirectories.push(directory) + const authority = createAuthority(join(directory, 'profile-state.db'), 'profile-authority-test') + + authority.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + const before = authority.readSerializedState() + const malformed = Buffer.concat([ + Buffer.from('{"settings":{"theme":"'), + Buffer.from([0xff]), + Buffer.from('"}}') + ]) + expect(() => authority.writeSerializedState(malformed)).toThrow( + 'Profile state payload is not valid UTF-8' + ) + expect(authority.readSerializedState()).toBe(before) + authority.close() + }) + + it('mutates, flushes, and reloads without writing the legacy JSON file', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + writeFileSync(dataFile, '{"settings":{"theme":"light"}}', 'utf8') + const legacyBytes = readFileSync(dataFile) + const authority = createAuthority(databaseFile, 'profile-authority-test') + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark', opencodeSessionCookie: 'authority-secret' }) + store.flushOrThrow() + + store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) + await store.flushPendingOrThrowAsync() + + expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(existsSync(databaseFile)).toBe(true) + + const reloaded = new Store({ dataFile, profileStateAuthority: authority }) + expect(reloaded.getSettings().theme).toBe('dark') + expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) + expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') + expect(readFileSync(dataFile)).toEqual(legacyBytes) + reloaded.freezeWrites() + }) + + it('rejects a corrupt ordering placeholder when normalized rows exist', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-normalized-read-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const fixture = buildProfileStateCutoverFixture() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify(fixture))) + authority.close() + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'automationRuns') + opened.db.close() + + const runtime = createAuthority(databasePath, 'profile-authority-test') + expect(() => runtime.readSerializedState()).toThrow(/hash mismatch: automationRuns/) + runtime.close() + + const strict = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(() => readProfileStateSnapshot(strict.db)).toThrow(/hash mismatch: automationRuns/) + } finally { + strict.db.close() + } + }) + + it('rejects invalid domain JSON before handing it to the Store', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-runtime-parse-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + authority.close() + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db + .prepare('UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?') + .run('{invalid', hashProfileStateJson('{invalid'), 'settings') + opened.db.close() + + expect( + () => + new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + ).toThrow('Profile state document payload is invalid JSON: settings') + }) + + it('rejects documents whose revision metadata was removed or reset', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db.prepare("DELETE FROM profile_state_meta WHERE key = 'revision'").run() + opened.db.close() + + const authority = createAuthority(databasePath, 'profile-authority-test') + expect(() => authority.readSerializedState()).toThrow() + }) + + it('fences a complete-document writer that read before another authority committed', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-cas-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const first = createAuthority(databasePath, 'profile-authority-test') + const second = createAuthority(databasePath, 'profile-authority-test') + + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + expect(second.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'light' } })) + + first.readSerializedState() + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + expect(() => + second.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { theme: 'stale-writer' } })) + ) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(verifier.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'dark' } })) + }) + + it('fences a first commit after another authority creates the database', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-create-cas-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const first = createAuthority(databasePath, 'profile-authority-test') + const second = createAuthority(databasePath, 'profile-authority-test') + + expect(first.readSerializedState()).toBeUndefined() + second.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'other' } }))) + + expect(() => + first.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'stale' } }))) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 0, + actualRevision: 1 + }) + ) + }) + + it('keeps normalized rows stable during a complete document replacement', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-complete-write-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const fixture = buildProfileStateCutoverFixture(directory) + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify(fixture))) + + const before = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + const beforeAutomationMeta = before.db + .prepare( + 'SELECT revision, content_hash FROM profile_state_automation_runs_meta WHERE domain = ?' + ) + .get('automationRuns') + const beforeAutomationRows = before.db + .prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs') + .get() + before.db.close() + + const replacement = parseProfileStateRoot(authority.readSerializedState() ?? '{}') + replacement.settings = { theme: 'complete-replacement' } + replacement.unknownDomain = { preserved: true } + delete replacement.ui + authority.writeSerializedState(Buffer.from(JSON.stringify(replacement))) + + expect(JSON.parse(authority.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'complete-replacement' }, + unknownDomain: { preserved: true } + }) + const after = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect( + after.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('revision') + ).toEqual({ value: '2' }) + expect( + after.db + .prepare( + 'SELECT revision, content_hash FROM profile_state_automation_runs_meta WHERE domain = ?' + ) + .get('automationRuns') + ).toEqual(beforeAutomationMeta) + expect( + after.db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual(beforeAutomationRows) + expect( + after.db.prepare('SELECT 1 FROM profile_state_documents WHERE domain = ?').get('ui') + ).toBe(undefined) + } finally { + after.db.close() + } + }) + + it('reopens its writer after an explicit close without losing the revision fence', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-authority-close-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + authority.close() + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(verifier.readSerializedState()).toBe(JSON.stringify({ settings: { theme: 'dark' } })) + verifier.close() + }) + + it('keeps a newer Store commit when a stale Store flushes afterward', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-store-cas-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + + const first = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const stale = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const initialTerminalFontSize = first.getSettings().terminalFontSize + first.updateSettings({ theme: 'dark' }) + first.flushOrThrow() + + stale.updateSettings({ terminalFontSize: stale.getSettings().terminalFontSize + 1 }) + expect(() => stale.flushOrThrow()).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + + const verifier = createAuthority(databasePath, 'profile-authority-test') + expect(JSON.parse(verifier.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'dark', terminalFontSize: initialTerminalFontSize } + }) + }) + + it('writes a local session mutation as dirty domains and preserves unrelated rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-domain-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seedDataFile = join(directory, 'seed-orca-data.json') + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.updateSettings({ theme: 'light' }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + activeTabId: 'after' + }) + store.flushOrThrow() + + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSession' + ]) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.getWorkspaceSession().activeTabId).toBe('after') + expect(reloaded.getSettings().theme).toBe('light') + reloaded.freezeWrites() + }) + + it('writes a PTY rebind through the workspace-session domain', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-pty-domain-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const fixtureFile = join(directory, 'fixture-orca-data.json') + writeFileSync(fixtureFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: fixtureFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(fixtureFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const session = store.getWorkspaceSession() + const worktreeId = session.activeWorktreeId + const tabId = session.activeTabId + const layout = tabId ? session.terminalLayoutsByTabId[tabId] : undefined + const leafId = layout ? Object.keys(layout.ptyIdsByLeafId ?? {})[0] : undefined + const previousPtyId = leafId ? layout?.ptyIdsByLeafId?.[leafId] : undefined + if (!worktreeId || !tabId || !layout || !leafId || !previousPtyId) { + throw new Error('fixture did not produce a normalized PTY binding') + } + + expect( + await store.persistPtyBinding({ + worktreeId, + tabId, + leafId, + ptyId: 'pty-rebound', + expectedBinding: { ptyId: previousPtyId } + }) + ).toBe(true) + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSession' + ]) + + const remoteSession = store.getWorkspaceSession('ssh:build-host') + const remoteWorktreeId = remoteSession.activeWorktreeId + const remoteTabId = remoteSession.activeTabId + const remoteLayout = remoteTabId ? remoteSession.terminalLayoutsByTabId[remoteTabId] : undefined + const remoteLeafId = remoteLayout + ? Object.keys(remoteLayout.ptyIdsByLeafId ?? {})[0] + : undefined + const remotePtyId = remoteLeafId ? remoteLayout?.ptyIdsByLeafId?.[remoteLeafId] : undefined + if (!remoteWorktreeId || !remoteTabId || !remoteLayout || !remoteLeafId || !remotePtyId) { + throw new Error('fixture did not produce a normalized remote PTY binding') + } + expect( + await store.persistPtyBinding( + { + worktreeId: remoteWorktreeId, + tabId: remoteTabId, + leafId: remoteLeafId, + ptyId: 'pty-remote-rebound', + expectedBinding: { ptyId: remotePtyId } + }, + 'ssh:build-host' + ) + ).toBe(true) + expect(writeDomains).toHaveBeenCalledTimes(2) + expect(writeDomains.mock.calls[1]?.[0].map(({ domain }) => domain)).toEqual([ + 'workspaceSessionsByHostId' + ]) + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect( + reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId + ).toMatchObject({ + [leafId]: 'pty-rebound' + }) + reloaded.freezeWrites() + }) + + it('writes scheduled automation changes as automations and automationRuns domains', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeAutomationRuns = vi.spyOn(authority, 'writeSerializedAutomationRuns') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + store.createAutomationRun(automation, Date.now(), 'scheduled') + + expect(writeAutomationRuns).toHaveBeenCalledTimes(1) + expect(writeAutomationRuns.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual([ + 'automations' + ]) + expect(writeAutomationRuns.mock.calls[0]?.[1]).toHaveLength(2) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.listAutomationRuns()).toHaveLength(2) + reloaded.freezeWrites() + }) + + it('persists an automation run lifecycle through normalized rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-lifecycle-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeAutomationRuns = vi.spyOn(authority, 'writeSerializedAutomationRuns') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + + store.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=authority' }) + store.flushOrThrow() + const pending = store.createAutomationRun(automation, 30, 'manual') + store.flushOrThrow() + expect(pending.status).toBe('pending') + const completed = store.updateAutomationRun({ + runId: pending.id, + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'completed through sqlite', + capturedAt: 31, + truncated: false + } + }) + store.flushOrThrow() + expect(completed.status).toBe('completed') + expect(writeAutomationRuns).toHaveBeenCalledTimes(2) + expect(writeAutomationRuns.mock.calls[1]?.[0].map(({ domain }) => domain)).toEqual([ + 'automations' + ]) + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect(reloaded.listAutomationRuns('automation-fixture')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: pending.id, + status: 'completed', + outputSnapshot: expect.objectContaining({ content: 'completed through sqlite' }) + }) + ]) + ) + expect(reloaded.getUI().featureInteractions?.['automation-run']?.interactionCount).toBe(1) + expect(reloaded.getUI().browserKagiSessionLink).toBe('https://kagi.com/session?t=authority') + const stored = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + const uiRow = stored.db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('ui') + expect(uiRow).toEqual( + expect.objectContaining({ payload: expect.not.stringContaining('authority') }) + ) + } finally { + stored.db.close() + } + reloaded.freezeWrites() + }) + + it('prunes normalized automation rows and reloads the retained window', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-automation-retention-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const store = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + store.flushOrThrow() + const automation = store.listAutomations()[0] + if (!automation) { + throw new Error('fixture automation missing') + } + for (let index = 0; index < MAX_AUTOMATION_RUNS_PER_AUTOMATION + 2; index += 1) { + const run = store.createAutomationRun(automation, 100 + index, 'scheduled') + store.updateAutomationRun({ runId: run.id, status: 'completed' }) + } + + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + const retained = reloaded.listAutomationRuns('automation-fixture') + expect(retained).toHaveLength(MAX_AUTOMATION_RUNS_PER_AUTOMATION) + expect(retained.some((run) => run.id === 'automation-run-fixture')).toBe(false) + reloaded.flushOrThrow() + const stored = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect( + stored.db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual({ count: MAX_AUTOMATION_RUNS_PER_AUTOMATION }) + } finally { + stored.db.close() + } + reloaded.freezeWrites() + }) + + it('writes host-qualified worktree metadata as projected domain rows', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-worktree-write-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const seedDataFile = join(directory, 'seed-orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(seedDataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + const seedStore = new Store({ dataFile: seedDataFile }) + seedStore.flushOrThrow() + const seed = createAuthority(databasePath, 'profile-authority-test') + seed.writeSerializedState(readFileSync(seedDataFile)) + seedStore.freezeWrites() + + const authority = createAuthority(databasePath, 'profile-authority-test') + const writeDomains = vi.spyOn(authority, 'writeSerializedDomains') + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.flushOrThrow() + store.setWorktreeMetaForHost('repo-local::/fixture/local', 'local', { + displayName: 'Updated fixture local' + }) + store.flushOrThrow() + + expect(writeDomains).toHaveBeenCalledTimes(1) + expect(writeDomains.mock.calls[0]?.[0].map(({ domain }) => domain)).toEqual( + expect.arrayContaining(['worktreeMeta', 'worktreeMetaByIdentity', 'worktreeIdentityAliases']) + ) + const reloaded = new Store({ + dataFile, + profileStateAuthority: createAuthority(databasePath, 'profile-authority-test') + }) + expect( + reloaded.getWorktreeMetaForHost('repo-local::/fixture/local', 'local')?.displayName + ).toBe('Updated fixture local') + reloaded.freezeWrites() + }) + + it('publishes a durable JSON rollback export without changing the SQLite authority', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-json-export-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState( + Buffer.from(JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } })) + ) + + const exportPath = join(directory, 'rollback', 'orca-data.json.r3') + const revision = authority.writeJsonExport(exportPath) + + expect(revision).toBe(1) + expect(JSON.parse(readFileSync(exportPath, 'utf8'))).toEqual({ + settings: { theme: 'dark' }, + unknownDomain: { keep: true } + }) + const reopened = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(exportProfileStateJson(reopened.db)).toBe(readFileSync(exportPath, 'utf8')) + } finally { + reopened.db.close() + } + }) + + it('publishes the Store export after flushing pending SQLite state', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-store-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + const exportPath = join(directory, 'rollback', 'orca-data.json.current') + const revision = store.writeProfileStateJsonExport(exportPath) + + expect(revision).toBe(2) + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('dark') + store.freezeWrites() + }) + + it('publishes the latest SQLite revision as an idempotent versioned export', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-latest-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile }) + seed.updateSettings({ theme: 'light' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + + const firstRevision = store.writeLatestProfileStateJsonExport() + expect(firstRevision).toBe(2) + if (firstRevision === undefined) { + throw new Error('Expected the SQLite authority to publish a revisioned export') + } + const firstPath = profileStateJsonExportPath(dataFile, firstRevision) + expect(JSON.parse(readFileSync(firstPath, 'utf8')).settings.theme).toBe('dark') + + expect(store.writeLatestProfileStateJsonExport()).toBe(firstRevision) + expect(readFileSync(profileStateJsonExportPath(dataFile, 2), 'utf8')).toBe( + readFileSync(firstPath, 'utf8') + ) + expect( + readdirSync(directory).some((name) => + name.startsWith('orca-data.json.sqlite-export.pending.') + ) + ).toBe(false) + store.freezeWrites() + }) + + it('publishes canonical JSON for an older build and advances its acceptance marker', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-compat-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const seed = new Store({ dataFile }) + seed.updateSettings({ theme: 'light' }) + seed.flushOrThrow() + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(seed.prepareProfileStateExport().json, 'utf8')) + seed.freezeWrites() + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + const revision = store.writeLatestProfileStateJsonCompatibilityExport() + + expect(revision).toBe(2) + const canonical = readFileSync(dataFile, 'utf8') + expect(JSON.parse(canonical).settings.theme).toBe('dark') + const opened = openProfileStateDatabaseReadOnly(databasePath, 'profile-authority-test') + try { + expect(readProfileStateJsonAcceptance(opened.db)).toEqual({ + jsonHash: hashProfileStateJson(canonical), + acceptedRevision: revision + }) + expect(profileStateJsonMatchesAcceptance(opened.db, canonical)).toBe(true) + } finally { + opened.db.close() + } + authority.close() + const reopened = createProfileStateStore({ + dataFile, + databaseFile: databasePath, + profileId: 'profile-authority-test', + authorityMode: 'sqlite-established' + }) + expect(reopened.backend).toBe('sqlite') + expect(reopened.store.getSettings().theme).toBe('dark') + reopened.store.freezeWrites() + store.freezeWrites() + }) + + it('refuses to overwrite a conflicting export for the same SQLite revision', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-export-conflict-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'dark' } }))) + const store = new Store({ dataFile, profileStateAuthority: authority }) + const revision = store.writeLatestProfileStateJsonExport() + if (revision === undefined) { + throw new Error('Expected the SQLite authority to publish a revisioned export') + } + const exportPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(exportPath), { recursive: true }) + writeFileSync(exportPath, '{"settings":{"theme":"tampered"}}', 'utf8') + + expect(() => store.writeLatestProfileStateJsonExport()).toThrow( + 'already exists with different content' + ) + expect(readFileSync(exportPath, 'utf8')).toContain('tampered') + expect( + readdirSync(directory).some((name) => + name.startsWith('orca-data.json.sqlite-export.pending.') + ) + ).toBe(false) + store.freezeWrites() + }) + + it('freezes Store writes before quarantining the SQLite database family', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-quarantine-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const authority = createAuthority(databasePath, 'profile-authority-test') + authority.writeSerializedState(Buffer.from(JSON.stringify({ settings: { theme: 'light' } }))) + + const store = new Store({ dataFile, profileStateAuthority: authority }) + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + await authority.drainBackups() + const sourceBytes = readFileSync(databasePath) + writeFileSync(`${databasePath}-wal`, 'wal-preservation-sentinel') + + const result = store.quarantineProfileStateDatabase( + join(directory, 'quarantine'), + 'store-recovery-test' + ) + + expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( + 'wal-preservation-sentinel' + ) + expect(JSON.parse(readFileSync(result.manifestPath, 'utf8'))).toMatchObject({ + profileId: 'profile-authority-test', + reason: 'store-recovery-test' + }) + expect(readFileSync(databasePath)).toEqual(sourceBytes) + }) + + it('keeps the Store export path JSON-compatible without creating SQLite', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-legacy-export-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const store = new Store({ dataFile }) + store.updateSettings({ theme: 'dark' }) + + const exportPath = join(directory, 'rollback', 'orca-data.json.legacy.json') + expect(store.writeProfileStateJsonExport(exportPath)).toBeUndefined() + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('dark') + expect(existsSync(join(directory, 'profile-state.db'))).toBe(false) + store.freezeWrites() + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts new file mode 100644 index 00000000000..f8214c93711 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -0,0 +1,249 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushActiveProfileBeforeFileMutation } from '../../orca-profiles/profile-persistence-deadline' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import * as snapshots from '../profile-state/profile-state-database-snapshot' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { Store } from './store' +import { scheduleSave } from './write-scheduling' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'store-backup-test' +const HOUR = 60 * 60 * 1000 +const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] +const releases: (() => void)[] = [] + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + for (const fixture of fixtures.splice(0)) { + await fixture.authority.drainBackups() + fixture.store.freezeWrites() + await fixture.store.flushAsync() + rmSync(fixture.directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-backup-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const legacyBytes = '{"settings":{"theme":"system"},"legacy":"retained"}' + writeFileSync(dataFile, legacyBytes) + const beginning = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(beginning) + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store, authority }) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + await authority.drainBackups() + const retained = profileStateDatabaseBackups(databasePath) + expect(retained).toHaveLength(1) + const retainedBytes = readFileSync(retained[0].path) + clock.mockReturnValue(beginning + HOUR + 1) + return { + directory, + databasePath, + dataFile, + legacyBytes, + store, + authority, + retained, + retainedBytes + } +} + +function readSnapshot(path: string) { + const opened = openProfileStateDatabaseReadOnly(path, PROFILE_ID) + try { + const snapshot = readProfileStateSnapshot(opened.db) + return { revision: snapshot.revision, state: JSON.parse(snapshot.json) } + } finally { + opened.db.close() + } +} + +describe('Store automatic SQLite recovery snapshots', () => { + it.each([ + ['selective', 'sync'], + ['selective', 'async'], + ['complete', 'sync'], + ['complete', 'async'] + ] as const)('backs up a %s %s commit without rewriting retained JSON', async (scope, flush) => { + const state = await fixture() + const fullWrite = vi.spyOn(state.authority, 'writeCompleteSerializedDomains') + const selectiveWrite = vi.spyOn(state.authority, 'writeSerializedDomains') + state.store.patchWorkspaceSession({ activeWorktreeId: 'backup-worktree' }) + if (scope === 'complete') { + scheduleSave(state.store) + } + if (flush === 'sync') { + state.store.flushOrThrow() + await state.authority.drainBackups() + } else { + await state.store.flushPendingOrThrowAsync() + } + + await state.authority.drainBackups() + expect(scope === 'complete' ? fullWrite : selectiveWrite).toHaveBeenCalledOnce() + expect(scope === 'complete' ? selectiveWrite : fullWrite).not.toHaveBeenCalled() + const backups = profileStateDatabaseBackups(state.databasePath) + expect(backups).toHaveLength(2) + expect(readSnapshot(backups[0].path)).toEqual(readSnapshot(state.databasePath)) + expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( + 'backup-worktree' + ) + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect( + readdirSync(state.directory) + .filter((name) => name.includes('.backup.')) + .sort() + ).toEqual(backups.map((backup) => basename(backup.path)).sort()) + }) + + it('acknowledges a routine flush while the previous recovery backup is still running', async () => { + const state = await fixture() + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + const started = Promise.withResolvers() + const gate = Promise.withResolvers() + releases.push(gate.resolve) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( + async (db, target) => { + started.resolve() + await gate.promise + await realSnapshot(db, target) + } + ) + state.store.updateSettings({ theme: 'dark' }) + state.store.flushOrThrow() + await started.promise + state.store.patchWorkspaceSession({ activeWorktreeId: 'newer-than-backup' }) + let settled = false + const flush = state.store.flushPendingOrThrowAsync().then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(readSnapshot(state.databasePath).state.workspaceSession.activeWorktreeId).toBe( + 'newer-than-backup' + ) + expect(profileStateDatabaseBackups(state.databasePath)).toHaveLength(1) + gate.resolve() + await Promise.all([flush, state.authority.drainBackups()]) + }) + + it.each(['quit', 'profile mutation'] as const)( + '%s waits for its owned backup across Store close', + async (kind) => { + const state = await fixture() + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + releases.push(release) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( + async (db, target) => { + begin() + await gate + await realSnapshot(db, target) + } + ) + state.store.updateSettings({ theme: 'dark' }) + state.store.flushOrThrow() + await started + const drain = vi.spyOn(state.authority, 'drainBackups') + let settled = false + const barrier = ( + kind === 'quit' + ? state.store.flushAsync({ exportJsonCompatibility: true }) + : flushActiveProfileBeforeFileMutation(state.store) + ).then(() => { + settled = true + }) + await vi.waitFor(() => expect(drain).toHaveBeenCalled()) + expect(settled).toBe(false) + state.store.freezeWrites() + expect(profileStateDatabaseBackups(state.databasePath)).toHaveLength(1) + release() + await barrier + + expect(settled).toBe(true) + const backups = profileStateDatabaseBackups(state.databasePath) + expect(backups).toHaveLength(2) + expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( + readSnapshot(state.databasePath).state + ) + } + ) + + it.each(['sync', 'async'] as const)( + 'does not reject a committed %s flush when its backup fails', + async (flush) => { + const state = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('injected backup disk failure') + const snapshot = vi + .spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync') + .mockRejectedValueOnce(failure) + state.store.updateSettings({ theme: 'dark' }) + if (flush === 'sync') { + expect(() => state.store.flushOrThrow()).not.toThrow() + await expect(state.authority.drainBackups()).resolves.toBeUndefined() + } else { + await expect(state.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() + } + + await state.authority.drainBackups() + expect(snapshot).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith( + '[persistence] Failed to back up profile state database:', + failure + ) + expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') + expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) + expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') + expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) + expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-update-quit.test.ts b/src/main/persistence/loading-store/profile-state-update-quit.test.ts new file mode 100644 index 00000000000..ec2506c32b9 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-update-quit.test.ts @@ -0,0 +1,242 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as durableFiles from '../../durable-file-write' +import { settleTeardownWithinDeadline } from '../../quit-teardown-deadline' +import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' +import { + hashProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateSnapshot +} from '../profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { createProfileStateStore } from '../profile-state/profile-state-store-factory' +import { Store } from './store' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const PROFILE_ID = 'update-quit-test' +const TARGET_ID = 'remote-host' +const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] +const releases: (() => void)[] = [] + +afterEach(async () => { + for (const release of releases.splice(0)) { + release() + } + for (const { store, authority, directory } of fixtures.splice(0)) { + await store.flushAsync() + await authority.drainBackups() + store.freezeWrites() + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +async function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-update-quit-')) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const authority = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + const store = new Store({ dataFile, profileStateAuthority: authority }) + fixtures.push({ directory, store, authority }) + store.upsertSshRemotePtyLease({ targetId: TARGET_ID, ptyId: 'remote-pty', state: 'attached' }) + await store.flushPendingOrThrowAsync() + store.writeLatestProfileStateJsonExport() + store.writeLatestProfileStateJsonCompatibilityExport() + return { store, authority, databasePath, dataFile } +} + +function persistedState(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, PROFILE_ID) + try { + return { + ...readProfileStateSnapshot(opened.db), + acceptance: readProfileStateJsonAcceptance(opened.db) + } + } finally { + opened.db.close() + } +} + +function gate() { + let release: () => void = () => {} + const promise = new Promise((resolve) => { + release = resolve + }) + releases.push(release) + return { promise, release } +} + +describe('SQLite profile state during an update quit', () => { + it('exports final SSH shutdown writes and reloads them in a JSON-only Store', async () => { + const { store, dataFile, databasePath } = await fixture() + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + const json = readFileSync(dataFile, 'utf8') + const snapshot = persistedState(databasePath) + expect(json).toBe(snapshot.json) + expect(snapshot.acceptance).toEqual({ + jsonHash: hashProfileStateJson(json), + acceptedRevision: snapshot.revision + }) + const legacy = new Store({ dataFile }) + try { + expect(legacy.getSshRemotePtyLeases(TARGET_ID)).toEqual([ + expect.objectContaining({ state: 'detached', lastDetachedAt: expect.any(Number) }) + ]) + } finally { + legacy.freezeWrites() + } + }) + + it('exports a normal quit for an older build after sessions and settings changed', async () => { + const { store, dataFile, databasePath } = await fixture() + store.updateSettings({ theme: 'dark' }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + + const json = readFileSync(dataFile, 'utf8') + expect(json).toBe(persistedState(databasePath).json) + const legacy = new Store({ dataFile }) + try { + expect(legacy.getSettings().theme).toBe('dark') + expect(legacy.getSshRemotePtyLeases(TARGET_ID)[0]?.state).toBe('detached') + } finally { + legacy.freezeWrites() + } + }) + + it('does not publish or accept a snapshot when final persistence fails', async () => { + const { store, authority, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const before = persistedState(databasePath) + const failure = new Error('injected final commit failure') + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementation(() => { + throw failure + }) + const exportJson = vi.spyOn(authority, 'writeJsonCompatibilityExportAsync') + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + expect(exportJson).not.toHaveBeenCalled() + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath)).toEqual(before) + }) + + it('retains the preflight export and acceptance if the final file write fails', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const before = persistedState(databasePath) + const failure = new Error('injected final export failure') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce(failure) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + expect(persistedState(databasePath).acceptance).toMatchObject(before.acceptance ?? {}) + expect(persistedState(databasePath).revision).toBeGreaterThan(before.revision) + expect(log).toHaveBeenCalledWith('[persistence] Failed to flush final state:', failure) + }) + + it('joins the final barrier and permits its deadline while the JSON file write stalls', async () => { + const { store, dataFile, databasePath } = await fixture() + const retainedJson = readFileSync(dataFile, 'utf8') + const writing = gate() + const held = gate() + const writeFileDurable = durableFiles.writeFileDurable + const exportWrite = vi + .spyOn(durableFiles, 'writeFileDurable') + .mockImplementationOnce(async (...args) => { + writing.release() + await held.promise + await writeFileDurable(...args) + }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + const pending = store.flushAsync({ exportJsonCompatibility: true }) + expect(store.flushAsync()).toBe(pending) + await writing.promise + + await expect( + settleTeardownWithinDeadline([{ name: 'state', promise: pending }], 25) + ).resolves.toEqual(['state']) + expect(readFileSync(dataFile, 'utf8')).toBe(retainedJson) + held.release() + await pending + + expect(exportWrite).toHaveBeenCalledOnce() + expect(readFileSync(dataFile, 'utf8')).toBe(persistedState(databasePath).json) + }) + + it('reopens the latest SQLite state when a concurrent commit prevents export promotion', async () => { + const { store, dataFile, databasePath } = await fixture() + const before = persistedState(databasePath) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const writeFileDurable = durableFiles.writeFileDurable + vi.spyOn(durableFiles, 'writeFileDurable').mockImplementationOnce(async (...args) => { + await writeFileDurable(...args) + const competitor = new ProfileStateSqliteAuthority(databasePath, PROFILE_ID) + try { + competitor.readSerializedState() + competitor.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + } finally { + competitor.close() + } + }) + store.markSshRemotePtyLeasesForShutdown(TARGET_ID, 'detached') + + await store.flushAsync({ exportJsonCompatibility: true }) + + const snapshot = persistedState(databasePath) + expect(snapshot.acceptance).toMatchObject(before.acceptance ?? {}) + expect(snapshot.acceptance?.pending?.jsonHash).toBe( + hashProfileStateJson(readFileSync(dataFile, 'utf8')) + ) + expect(log).toHaveBeenCalledWith( + '[persistence] Failed to flush final state:', + expect.objectContaining({ code: 'profile-state-revision-conflict' }) + ) + store.freezeWrites() + const reopened = createProfileStateStore({ + dataFile, + databaseFile: databasePath, + profileId: PROFILE_ID, + authorityMode: 'sqlite-established' + }) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.store.getSettings().theme).toBe('dark') + expect(reopened.store.getSshRemotePtyLeases(TARGET_ID)[0]?.state).toBe('detached') + } finally { + reopened.store.freezeWrites() + } + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts new file mode 100644 index 00000000000..0f1bdd6ff9d --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('worker-owned Store writes', () => { + it('consumes the debounce timer and avoids full checkpoints after a selective durable write', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selective = vi.spyOn(authority, 'writeSerializedDomains') + vi.useFakeTimers() + try { + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await vi.advanceTimersByTimeAsync(6_000) + await store.waitForPendingWrite() + expect(selective).toHaveBeenCalledOnce() + expect(full).not.toHaveBeenCalled() + expect(readState().settings.theme).toBe('dark') + } finally { + vi.useRealTimers() + } + }) + + it('still captures direct durable mutations that do not identify dirty domains', async () => { + const { store, readState } = await fixture() + await store.runDurableMutation(() => { + store.getWorkspaceSession().activeTabId = 'direct-mutation' + return { value: undefined } + }) + expect(readState().workspaceSession.activeTabId).toBe('direct-mutation') + }) + + it('skips a debounce callback already queued behind the write that consumed its changes', async () => { + const { store, authority } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const durable = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await durable + await store.waitForPendingWrite() + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('handles a rejected debounced save and retains it for an explicit retry', async () => { + const { store, authority, readState } = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + vi.useFakeTimers() + try { + store.updateSettings({ theme: 'dark' }) + await vi.advanceTimersByTimeAsync(1000) + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await store.waitForPendingWrite() + expect(log).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + }) + + it('refuses an export when serialization invalidates its full checkpoint', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const publish = vi.spyOn(authority, 'writeLatestJsonExport') + const session = store.getWorkspaceSession() + Object.defineProperty(session, 'toJSON', { + configurable: true, + value: () => { + store.updateSettings({ theme: 'light' }) + return { ...session } + } + }) + try { + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow( + 'changed while preparing its export' + ) + expect(publish).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(session, 'toJSON') + } + await store.writeLatestProfileStateJsonExportAsync() + expect(readState().settings.theme).toBe('light') + expect(publish).toHaveBeenCalledOnce() + }) + + it('retains a newer edit after an older write is acknowledged', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + gate.finish.resolve() + await first + expect(readState().settings.theme).toBe('dark') + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it('merges a failed older write with dirty state added while it was in flight', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const rejected = expect(first).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'newer-tab' + store.setWorkspaceSession(store.getWorkspaceSession()) + gate.finish.reject(new Error('disk refused')) + await rejected + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'newer-tab' } + }) + }) + + it('captures a full checkpoint after an older save even without a new generation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-tab' + const final = store.flushAsync() + gate.finish.resolve() + await first + await final + expect(readState().workspaceSession.activeTabId).toBe('getter-only-tab') + expect(authority.captures.at(-1)?.some(({ domain }) => domain === 'workspaceSession')).toBe( + true + ) + }) + + it('reserves ordering before an exact mutation changes live state', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const mutate = vi.fn(() => { + store.updateSettings({ theme: 'light' }) + return { value: 'durable' } + }) + const exact = store.runDurableMutation(mutate) + await Promise.resolve() + expect(mutate).not.toHaveBeenCalled() + expect(store.getSettings().theme).toBe('dark') + gate.finish.resolve() + await first + await expect(exact).resolves.toBe('durable') + expect(readState().settings.theme).toBe('light') + }) + + it('retains dirty intent while many durability waiters share an active snapshot', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const gate = authority.pause() + store.updateSettings({ terminalFontSize: 12 }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const waiters: Promise[] = [first] + for (let size = 13; size <= 32; size++) { + store.updateSettings({ terminalFontSize: size }) + waiters.push(store.flushPendingOrThrowAsync({ drainToStableGeneration: false })) + } + await Promise.resolve() + expect(authority.captures).toHaveLength(1) + gate.finish.resolve() + await Promise.all(waiters) + expect(readState().settings.terminalFontSize).toBe(32) + expect(fullCapture).toHaveBeenCalledOnce() + expect(authority.captures).toHaveLength(2) + }) + + it('cancels a queued checkpoint without aborting the preceding writer or losing edits', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const abortWriter = vi.spyOn(authority, 'abort') + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const controller = new AbortController() + store.updateSettings({ theme: 'light' }) + const canceled = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const refused = expect(canceled).rejects.toThrow('aborted') + controller.abort() + gate.finish.resolve() + await first + await refused + expect(abortWriter).not.toHaveBeenCalled() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it.each(['known-failure', 'indeterminate'] as const)( + 'only rolls back a mutation with a known failure (%s)', + async (outcome) => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rollback = vi.fn() + const exact = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined, rollback } + }) + const rejected = expect(exact).rejects.toThrow('write failed') + await gate.started.promise + gate.finish.reject(new ProfileStateWriterError('test', 'write failed', outcome)) + await rejected + expect(rollback).toHaveBeenCalledTimes(outcome === 'known-failure' ? 1 : 0) + } + ) + + it('awaits accepted operations before closing and refuses new exact mutations', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const closing = store.freezeWritesAsync() + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(authority.close).not.toHaveBeenCalled() + gate.finish.resolve() + await first + await closing + expect(authority.close).toHaveBeenCalledTimes(1) + expect(readState().settings.theme).toBe('dark') + }) +}) diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts new file mode 100644 index 00000000000..4d72ce144c6 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSecretStore, setSecretStore } from '../../../shared/secret-store' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let encryptionAvailable = true +let previousSecretStore: ReturnType +const ciphertext = (plaintext: string) => Buffer.from(`sealed:${plaintext}`).toString('base64') + +beforeEach(() => { + encryptionAvailable = true + previousSecretStore = getSecretStore() + setSecretStore({ + isEncryptionAvailable: () => encryptionAvailable, + encryptString: (value) => Buffer.from(`sealed:${value}`), + decryptString: (value) => value.toString().slice('sealed:'.length), + describeProtectionGap: () => null + }) +}) +afterEach(() => setSecretStore(previousSecretStore)) + +describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( + 'Store %s retention across worker acknowledgements', + (setting) => { + it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: '' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe('') + expect(store.getSettings()[setting]).toBe('') + }) + + it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('in-flight')) + expect(store.getSettings()[setting]).toBe('newer') + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + + it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'failed' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const failure = expect(write).rejects.toThrow('disk refused') + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.reject(new Error('disk refused')) + await failure + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('durable')) + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + } +) + +describe('worker protected settings serialization', () => { + it.each(['selective', 'complete'] as const)( + 'encrypts both protected credentials in a %s write to SQLite', + async (mode) => { + const { store, authority, readState } = await fixture() + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const secrets = { + opencodeSessionCookie: 'cookie-only-plaintext', + opencodeGoApiKey: 'api-key-only-plaintext' + } + store.updateSettings(secrets) + if (mode === 'complete') { + store.updateOnboarding({ outcome: 'completed' }) + } + await store.flushPendingOrThrowAsync() + expect(selectiveWrite).toHaveBeenCalledTimes(mode === 'selective' ? 1 : 0) + expect(completeWrite).toHaveBeenCalledTimes(mode === 'complete' ? 1 : 0) + const persisted = readState() + expect(persisted.settings).toMatchObject({ + opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), + opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey) + }) + for (const plaintext of Object.values(secrets)) { + expect(JSON.stringify(persisted)).not.toContain(plaintext) + } + expect(store.getSettings()).toMatchObject(secrets) + } + ) +}) diff --git a/src/main/persistence/loading-store/profile-state-write-batching.test.ts b/src/main/persistence/loading-store/profile-state-write-batching.test.ts new file mode 100644 index 00000000000..fb6f44a5d8a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-write-batching.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from 'vitest' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import * as composition from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { ProfileStateRevisionConflictError } from '../profile-state/profile-state-document-validation' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +async function snapshotFixture() { + let captured: StoreRuntimeState | undefined + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + captured = runtime + return createDomains(runtime) + }) + const state = await fixture() + if (!captured) { + throw new Error('Store runtime was not initialized') + } + return { ...state, runtime: captured } +} + +describe('queued worker snapshot batching', () => { + it.each(['explicit', 'debounce'] as const)( + 'preserves getter-only edits when an explicit flush joins a dirty %s batch', + async (firstFlush) => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const first = + firstFlush === 'explicit' + ? store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + : await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + store.updateSettings({ theme: 'light' }) + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([mutation, first, second]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + } + ) + + it('preserves a queued explicit capture when a later debounce joins it', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await Promise.all([mutation, explicit]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps batches of only debounced saves selective', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await mutation + await store.waitForPendingWrite() + expect(readState().settings.theme).toBe('light') + expect(authority.captures).toHaveLength(2) + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('captures getter-only edits in a flush requested after the preceding capture started', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const firstGate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await firstGate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + firstGate.finish.resolve() + await Promise.all([first, second]) + expect(readState().workspaceSession.activeTabId).toBe('getter-only-edit') + expect(fullCapture).toHaveBeenCalledOnce() + }) + + it('upgrades a queued debounce to capture an explicit getter-only flush', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'explicit-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([first, explicit]) + expect(readState().workspaceSession.activeTabId).toBe('explicit-edit') + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps a later flush ordered after an intervening durable mutation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const before = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + const after = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const verifyAfter = after.then(() => expect(readState().settings.theme).toBe('light')) + gate.finish.resolve() + await Promise.all([first, before, mutation, verifyAfter]) + }) + + it('shares a queued write failure with its waiters and allows a fresh retry', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + const failed = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ].map((waiter) => expect(waiter).rejects.toThrow('disk refused')) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + gate.finish.resolve() + await Promise.all([first, ...failed]) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) + + it('checks the disk revision for a clean batch and rejects every waiter on conflict', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const conflict = new ProfileStateRevisionConflictError(1, 2) + const revisionCheck = vi + .spyOn(authority, 'assertCurrentRevision') + .mockRejectedValueOnce(conflict) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all(waiters.map((waiter) => expect(waiter).rejects.toBe(conflict))) + expect(revisionCheck).toHaveBeenCalledOnce() + }) + + it('waits for snapshot files admitted by a later member of the queued batch', async () => { + const { store, authority, readState, runtime } = await snapshotFixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const snapshot = deferred() + runtime.pendingSnapshotFileWork = snapshot.promise + store.updateSettings({ theme: 'light' }) + const third = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + try { + gate.finish.resolve() + await first + await new Promise((resolve) => setImmediate(resolve)) + expect(authority.captures).toHaveLength(1) + } finally { + snapshot.resolve() + runtime.pendingSnapshotFileWork = null + await Promise.all([second, third]) + } + expect(readState().settings.theme).toBe('light') + }) + + it('discards a queued batch when its predecessor dependency rejects', async () => { + const { store, readState, runtime } = await snapshotFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + runtime.pendingSnapshotFileWork = Promise.reject(new Error('snapshot preparation failed')) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all( + waiters.map((waiter) => expect(waiter).rejects.toThrow('snapshot preparation failed')) + ) + runtime.pendingSnapshotFileWork = null + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts new file mode 100644 index 00000000000..066cea3cce5 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'async-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'async-binding-pty', + incarnationId: 'async-binding-incarnation' +} + +describe('durable asynchronous PTY binding', () => { + it('acknowledges only after the binding reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + let acknowledged = false + const pending = store.persistPtyBinding(binding).then((result) => { + acknowledged = true + return result + }) + await gate.started.promise + expect(acknowledged).toBe(false) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([]) + gate.finish.resolve() + expect(await pending).toBe(true) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + }) + + it('repairs activity on an otherwise matching durable reattach', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + await store.persistPtyBinding(binding) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + expect(authority.captures).toHaveLength(1) + await store.persistPtyBinding(binding) + expect(authority.captures).toHaveLength(1) + }) + + it('evaluates membership refusal after an older write finishes', async () => { + const { store, authority } = await fixture() + await store.persistPtyBinding(binding) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const resolveBinding = vi.fn(() => ({ ...binding, mayCreate: false })) + const queued = store.persistPtyBinding(resolveBinding) + expect(resolveBinding).not.toHaveBeenCalled() + const session = store.getWorkspaceSession() + session.tabsByWorktree[binding.worktreeId] = [] + delete session.terminalLayoutsByTabId[binding.tabId] + store.setWorkspaceSession(session) + gate.finish.resolve() + await older + expect(await queued).toBe(false) + expect(resolveBinding).toHaveBeenCalledTimes(1) + }) + + it('restores the binding after a known write failure', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + const before = structuredClone(store.getWorkspaceSession()) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession()).toEqual(before) + }) + + it('preserves newer getter edits when an older binding write fails', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId = { + [binding.leafId]: 'newer-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: 'newer-pty' + }) + }) + + it.each(['tab title', 'pane title'] as const)( + 'rolls back a failed replacement while preserving a newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + ptyId: 'failed-replacement', + incarnationId: 'failed-incarnation', + expectedBinding: binding + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else { + session.terminalLayoutsByTabId[binding.tabId].titlesByLeafId = { + [binding.leafId]: 'new title' + } + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect(persisted.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( + binding.incarnationId + ) + if (edit === 'tab title') { + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, customTitle: 'new title' }) + ) + } else { + expect(persisted.terminalLayoutsByTabId[binding.tabId].titlesByLeafId).toEqual({ + [binding.leafId]: 'new title' + }) + } + } + ) + + it('rolls back a failed binding while retaining an unrelated newer navigation edit', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeRepoId = 'newer-repo' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession().activeRepoId).toBe('newer-repo') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a newer root sibling when rolling back a failed leaf replacement', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ ...binding, ptyId: 'failed-replacement' }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: TEST_LEAF_2 }, + second: { type: 'leaf', leafId: binding.leafId } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'new-root-pty' } + tab.ptyId = 'new-root-pty' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].root).toEqual(layout.root) + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId, + [TEST_LEAF_2]: 'new-root-pty' + }) + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, ptyId: 'new-root-pty' }) + ) + }) + + it('removes a failed new binding while retaining a newer sibling tab', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const tabs = store.getWorkspaceSession().tabsByWorktree[binding.worktreeId] + const boundTab = tabs.find((tab) => tab.id === binding.tabId) + if (!boundTab) { + throw new Error('binding did not create its terminal row') + } + tabs.push({ ...boundTab, id: 'newer-sibling', ptyId: 'newer-sibling-pty' }) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).toContain('newer-sibling') + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).not.toContain(binding.tabId) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a binding whose commit outcome is indeterminate', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('worker exited') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-worker-exit', 'worker exited', 'indeterminate') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts new file mode 100644 index 00000000000..0feed681bf0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts @@ -0,0 +1,135 @@ +import { expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it.each(['tab title', 'pane title', 'new sibling'] as const)( + 'retains a valid unbound new surface after a failed binding and newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'new-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + } + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else if (edit === 'pane title') { + layout.titlesByLeafId = { [binding.leafId]: 'new title' } + } else { + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: binding.leafId }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'sibling-pty' } + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + } + tab.ptyId = 'sibling-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(JSON.stringify(persisted)).not.toContain('failed-pty') + expect(JSON.stringify(persisted)).not.toContain('failed-incarnation') + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ + id: binding.tabId, + worktreeId: binding.worktreeId, + createdAt: expect.any(Number), + ptyId: edit === 'new sibling' ? 'sibling-pty' : null, + ...(edit === 'tab title' ? { customTitle: 'new title' } : {}) + }) + ) + expect(persisted.terminalLayoutsByTabId[binding.tabId]).toMatchObject({ + root: layout.root, + ...(edit === 'pane title' ? { titlesByLeafId: { [binding.leafId]: 'new title' } } : {}) + }) + if (edit === 'new sibling') { + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [TEST_LEAF_2]: 'sibling-pty' + }) + expect(persisted.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + }) + } + } +) + +it('preserves the valid newer tree after a failed split insertion', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'split-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'original-pty', + incarnationId: 'original-incarnation' + } + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + leafId: TEST_LEAF_2, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const layout = store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId] + if (!layout.root) { + throw new Error('binding did not create its layout') + } + const laterLeaf = '33333333-3333-4333-8333-333333333333' + layout.root = { + type: 'split', + direction: 'horizontal', + first: layout.root, + second: { type: 'leaf', leafId: laterLeaf } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [laterLeaf]: 'later-sibling-pty' } + const expectedRoot = structuredClone(layout.root) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession.terminalLayoutsByTabId[binding.tabId] + expect(persisted.root).toEqual(expectedRoot) + expect(collectLayoutLeafIdsInOrder(persisted.root)).toContain(laterLeaf) + expect(persisted.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'original-pty', + [laterLeaf]: 'later-sibling-pty' + }) +}) diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts index 8ac6de70a0d..3f8cc1c7e7d 100644 --- a/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.test.ts @@ -71,6 +71,7 @@ describe('evaluatePtyBindingFastLane', () => { ) ).toEqual(['layout_missing']) expect(miss({ incarnationId: 'a' })).toEqual(['incarnation']) + expect(miss({}, session({ activeWorktreeIdsOnShutdown: [] }))).toEqual(['inactive_worktree']) expect(miss({}, session({ terminalPtyIncarnationsByPaneKey: { [paneKey]: 'a' } }))).toEqual([ 'incarnation' ]) diff --git a/src/main/persistence/loading-store/pty-binding-fast-lane.ts b/src/main/persistence/loading-store/pty-binding-fast-lane.ts index 9ae6304ed0f..3f0763c883f 100644 --- a/src/main/persistence/loading-store/pty-binding-fast-lane.ts +++ b/src/main/persistence/loading-store/pty-binding-fast-lane.ts @@ -18,6 +18,7 @@ export type PtyBindingFastLaneMiss = | 'leaf_pty' | 'incarnation' | 'tombstone' + | 'inactive_worktree' | 'not_durable' export type PtyBindingFastLaneRequest = { @@ -80,6 +81,12 @@ export function evaluatePtyBindingFastLane( if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { misses.push('tombstone') } + if ( + session.activeWorktreeIdsOnShutdown && + !session.activeWorktreeIdsOnShutdown.includes(bindingWorktreeId) + ) { + misses.push('inactive_worktree') + } if (!durable) { misses.push('not_durable') } diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c3cbff0796a..f902ff7d252 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -1,15 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { - cloneLayoutNode, - layoutContainsLeafId -} from '../restoring-sessions/terminal-layout-normalization' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' +import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' +import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' import type { PtyBindingSourceExpectation } from './store' @@ -18,21 +12,22 @@ import type { SessionHostPartitionOperations } from './session-host-partitions' import { resolveHostId } from './session-host-partitions' import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' -import { startPtyBindingSpan, type PtyBindingOrigin } from './pty-binding-span' -import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' +import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' +import { applyPtyBinding } from './pty-binding-session-update' type PtyBindingPersistenceOperationsRuntime = Pick< StoreRuntimeState, - | 'flushOrThrow' + | 'runDurableMutation' | 'lastDurableWriteGeneration' | 'pendingWrite' | 'quitFlushStarted' + | 'dirtyProfileStateDomains' | 'state' | 'writeGeneration' | 'writeTimer' > -type PersistPtyBindingArgs = { +export type PersistPtyBindingArgs = { worktreeId: string tabId: string leafId: string @@ -72,43 +67,57 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - persistPtyBinding(args: PersistPtyBindingArgs, hostId?: string | null): boolean { - const runtime = this[ptyBindingPersistenceOperationsContext].runtime + async persistPtyBinding( + input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null), + hostId?: string | null + ): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolvedHostId = resolveHostId(hostId) - const session = - this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - const span = startPtyBindingSpan({ - hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', - origin: args.origin ?? 'unknown', - savePending: runtime.writeTimer !== null || runtime.pendingWrite !== null, - generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration - }) - if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { - span.finish('refused') - return false - } - // A durable reattach needs neither a session clone nor whole-state serialization. - const verdict = evaluatePtyBindingFastLane( - args, - session, - bindingWorktreeId, - !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration - ) - span.setEligibility(verdict) - if (verdict.eligible) { - span.finish('fast_lane') - return true - } + const savePending = runtime.writeTimer !== null || runtime.pendingWrite !== null + let span: PtyBindingSpan | undefined + let outcome: 'refused' | 'fast_lane' | 'flushed' = 'flushed' try { - writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) - } catch (err) { - span.finish('threw', err) - throw err + const persisted = await runtime.runDurableMutation(() => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return { value: false, persist: false } + } + // Measure the admitted binding operation; queue time precedes its current-state checks. + span = startPtyBindingSpan({ + hostKind: parseExecutionHostId(resolvedHostId)?.kind ?? 'local', + origin: args.origin ?? 'unknown', + savePending, + generationGap: runtime.writeGeneration - runtime.lastDurableWriteGeneration + }) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + const session = sessions.getWorkspaceSession(resolvedHostId) + if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { + outcome = 'refused' + return { value: false, persist: false } + } + const verdict = evaluatePtyBindingFastLane( + args, + session, + bindingWorktreeId, + !runtime.quitFlushStarted && runtime.lastDurableWriteGeneration >= runtime.writeGeneration + ) + span.setEligibility(verdict) + if (verdict.eligible) { + outcome = 'fast_lane' + return { value: true, persist: false } + } + return { + value: true, + rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) + } + }) + span?.finish(outcome) + return persisted + } catch (error) { + span?.finish('threw', error) + throw error } - span.finish('flushed') - return true } } @@ -119,9 +128,19 @@ function writePtyBinding( resolvedHostId: ReturnType, bindingWorktreeId: string, paneKey: string -): void { - const runtime = owner[ptyBindingPersistenceOperationsContext].runtime +): () => void { + const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext] const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const restore = (restoredSession = sessionBeforeBinding): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = restoredSession + } else { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: restoredSession + } + } + } try { if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { runtime.state.workspaceSessionsByHostId = { @@ -130,144 +149,44 @@ function writePtyBinding( } } applyPtyBinding(args, session, bindingWorktreeId, paneKey) - runtime.flushOrThrow() - } catch (err) { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - runtime.state.workspaceSession = sessionBeforeBinding - } else { - runtime.state.workspaceSessionsByHostId = { - ...runtime.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - throw err - } -} - -function applyPtyBinding( - args: PersistPtyBindingArgs, - session: WorkspaceSessionState, - bindingWorktreeId: string, - paneKey: string -): void { - const reconciledIncarnation = - args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = tabRowPtyIdAfterLeafBinding( - tab, - session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, - args.leafId, - args.ptyId + runtime.dirtyProfileStateDomains?.add( + resolvedHostId === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' ) - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. - session.defaultTerminalTabsAppliedByWorktreeId = { - ...session.defaultTerminalTabsAppliedByWorktreeId, - [bindingWorktreeId]: true - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - return - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } + const boundSession = cloneWorkspaceSessionState(session) + return () => { + const current = sessions.getWorkspaceSession(resolvedHostId) + const ownerState = (value: WorkspaceSessionState) => { + const tab = value.tabsByWorktree[bindingWorktreeId]?.find((tab) => tab.id === args.tabId) + return { + createdAt: tab?.createdAt, + generation: tab?.generation, + worktreeId: tab?.worktreeId, + ptyId: isTerminalLeafId(args.leafId) + ? value.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + : tab?.ptyId, + incarnation: value.terminalPtyIncarnationsByPaneKey?.[paneKey] + } } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null + // Presentation edits do not replace the binding that must be rolled back. + if (!isDeepStrictEqual(ownerState(current), ownerState(boundSession))) { + return } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } + const rolledBack = rollbackFailedPtyBinding( + sessionBeforeBinding, + boundSession, + current, + bindingWorktreeId, + args.tabId, + args.leafId + ) + if (rolledBack !== current) { + restore(rolledBack) } } + } catch (error) { + restore() + throw error } - advanceTopologyFence() } export function installPtyBindingPersistenceOperationsContext( diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts new file mode 100644 index 00000000000..3024543505a --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -0,0 +1,146 @@ +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' +import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership' +import type { PersistPtyBindingArgs } from './pty-binding-persistence' + +export function applyPtyBinding( + args: PersistPtyBindingArgs, + session: WorkspaceSessionState, + bindingWorktreeId: string, + paneKey: string +): void { + const reconciledIncarnation = + args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = tabRowPtyIdAfterLeafBinding( + tab, + session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId, + args.leafId, + args.ptyId + ) + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + // Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied. + session.defaultTerminalTabsAppliedByWorktreeId = { + ...session.defaultTerminalTabsAppliedByWorktreeId, + [bindingWorktreeId]: true + } + // Acknowledged spawns must survive a crash before the renderer records their activity. + if ( + session.activeWorktreeIdsOnShutdown && + !session.activeWorktreeIdsOnShutdown.includes(bindingWorktreeId) + ) { + session.activeWorktreeIdsOnShutdown = [ + ...session.activeWorktreeIdsOnShutdown, + bindingWorktreeId + ] + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + return + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() +} diff --git a/src/main/persistence/loading-store/pty-binding-write-rollback.ts b/src/main/persistence/loading-store/pty-binding-write-rollback.ts new file mode 100644 index 00000000000..af7cb63d7b0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-write-rollback.ts @@ -0,0 +1,94 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' + +function restoreBindingSlot( + original: Record | undefined, + staged: Record | undefined, + current: Record | undefined, + key: string +): Record | undefined { + if (current?.[key] !== staged?.[key] || original?.[key] === staged?.[key]) { + return current + } + const restored = { ...current } + const previous = original?.[key] + if (previous === undefined) { + delete restored[key] + } else { + restored[key] = previous + } + return original === undefined && Object.keys(restored).length === 0 ? undefined : restored +} + +export function rollbackFailedPtyBinding( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState, + worktreeId: string, + tabId: string, + leafId: string +): WorkspaceSessionState { + const tab = (session: WorkspaceSessionState) => + session.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + const stagedTab = tab(staged) + const originalLayout = original.terminalLayoutsByTabId[tabId] + const stagedLayout = staged.terminalLayoutsByTabId[tabId] + let baseline = original + if ( + stagedTab && + (!isDeepStrictEqual(tab(current), stagedTab) || + !isDeepStrictEqual(current.terminalLayoutsByTabId[tabId], stagedLayout)) + ) { + // Keep edited new surfaces structurally valid, without the failed process binding. + baseline = { + ...original, + tabsByWorktree: tab(original) + ? original.tabsByWorktree + : { + ...original.tabsByWorktree, + [worktreeId]: [ + ...(original.tabsByWorktree[worktreeId] ?? []), + { ...stagedTab, ptyId: null } + ] + }, + terminalLayoutsByTabId: + originalLayout || !stagedLayout + ? original.terminalLayoutsByTabId + : { + ...original.terminalLayoutsByTabId, + [tabId]: { ...stagedLayout, ptyIdsByLeafId: {} } + } + } + } + const restored = rollbackWorkspaceSessionAfterFailedAsyncWrite(baseline, staged, current) + const layout = restored.terminalLayoutsByTabId[tabId] + const currentRoot = current.terminalLayoutsByTabId[tabId]?.root + return { + ...restored, + ...(layout + ? { + terminalLayoutsByTabId: { + ...restored.terminalLayoutsByTabId, + [tabId]: { + ...layout, + // A tree is one value; fieldwise rollback can mix leaf and split node shapes. + root: isDeepStrictEqual(currentRoot, stagedLayout?.root) ? layout.root : currentRoot, + ptyIdsByLeafId: restoreBindingSlot( + originalLayout?.ptyIdsByLeafId, + stagedLayout?.ptyIdsByLeafId, + layout.ptyIdsByLeafId, + leafId + ) + } + } + } + : {}), + terminalPtyIncarnationsByPaneKey: restoreBindingSlot( + original.terminalPtyIncarnationsByPaneKey, + staged.terminalPtyIncarnationsByPaneKey, + restored.terminalPtyIncarnationsByPaneKey, + `${tabId}:${leafId}` + ) + } +} diff --git a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts new file mode 100644 index 00000000000..16f2978e04b --- /dev/null +++ b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { ptyIncarnationById, ptyOwnership } from '../../ipc/pty/provider/ownership-state' +import { toSshExecutionHostId } from '../../../shared/execution-host' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const connectionId = 'reattach-host' +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'ssh:reattach-host@@surviving-pty' +} +const incarnation = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const successorIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +afterEach(() => { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) +}) + +describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) => { + it.each( + [undefined, incarnation].flatMap((incarnationId) => + ['live', 'exited', 'replaced'].map((outcome) => ({ incarnationId, outcome })) + ) + )( + 'preserves host evidence after a failed save: $outcome, $incarnationId', + async ({ incarnationId, outcome }) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + const publish = vi.spyOn(deps, 'sendPtySpawnedToRenderer') + const result = { id: binding.ptyId, incarnationId, isReattach: true } + let commit: () => Promise + let shutdown: ReturnType + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { + ...binding, + connectionId, + cols: 80, + rows: 24 + }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + shutdown = vi.spyOn(ctx.provider, 'shutdown') + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { + ...binding, + connectionId, + cols: 80, + rows: 24 + }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + shutdown = vi.spyOn(ctx.provider, 'shutdown') + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + await gate.started.promise + const ownerWhileSaving = ptyOwnership.get(binding.ptyId) + if (outcome !== 'live') { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) + await runtime.onPtyExit(binding.ptyId, 0, incarnationId, { providerExitObserved: true }) + if (outcome === 'replaced') { + runtime.onPtySpawned(binding.ptyId, successorIncarnation) + ptyOwnership.set(binding.ptyId, 'successor-host') + ptyIncarnationById.set(binding.ptyId, successorIncarnation) + } + } + gate.finish.reject(new Error('disk full')) + await pending + expect(ownerWhileSaving).toBe(connectionId) + expect(ptyOwnership.get(binding.ptyId)).toBe( + outcome === 'live' ? connectionId : outcome === 'replaced' ? 'successor-host' : undefined + ) + expect(ptyIncarnationById.get(binding.ptyId)).toBe( + outcome === 'live' + ? incarnationId + : outcome === 'replaced' + ? successorIncarnation + : undefined + ) + expect(shutdown).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(store.getSshRemotePtyLeases(connectionId)).toEqual([]) + const session = readState().workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] + expect( + session?.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + ).toBeUndefined() + if (outcome !== 'exited') { + await runtime.onPtyExit( + binding.ptyId, + 0, + outcome === 'replaced' ? successorIncarnation : incarnationId, + { providerExitObserved: true } + ) + } + } + ) +}) diff --git a/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts new file mode 100644 index 00000000000..6751cc3a010 --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { retireTerminalSurfaceFromPersistence } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'retirement-pty', + incarnationId: 'retirement-incarnation' +} + +async function retirementFixture(connectionId: string | undefined) { + const result = await fixture() + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + await result.store.persistPtyBinding(binding, hostId) + return { + ...result, + retire: () => + retirePersistedStablePaneOwner( + result.store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ), + removeInMemory: () => { + result.store.setWorkspaceSession( + retireTerminalSurfaceFromPersistence(result.store.getWorkspaceSession(hostId), { + ...binding, + parentTabId: binding.tabId + }), + hostId + ) + }, + persistedLayout: () => { + const state = result.readState() + const session = hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + return session.terminalLayoutsByTabId[binding.tabId] + } + } +} + +describe.each([undefined, 'retirement-ssh'])( + 'durable PTY retirement on host %s', + (connectionId) => { + it('waits for an already removed in-memory pane to reach SQLite', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + removeInMemory() + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + const gate = authority.pause() + let acknowledged = false + const pending = retire().then((accepted) => { + acknowledged = true + return accepted + }) + try { + await Promise.race([gate.started.promise, pending]) + expect(acknowledged).toBe(false) + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + } finally { + gate.finish.resolve() + } + await expect(pending).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('rejects when a pending removal cannot reach SQLite and retains it for retry', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + vi.spyOn(console, 'error').mockImplementation(() => {}) + removeInMemory() + const gate = authority.pause() + const rejected = expect(retire()).rejects.toThrow('retirement disk refused') + try { + await Promise.race([gate.started.promise, rejected]) + gate.finish.reject( + new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + ) + await rejected + } finally { + gate.finish.resolve() + } + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('skips disk work when the pane removal is already durable', async () => { + const { authority, retire, persistedLayout } = await retirementFixture(connectionId) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + authority.captures.length = 0 + const revisionCheck = vi.spyOn(authority, 'assertCurrentRevision') + const fullStateWrite = vi.spyOn(authority, 'writeSerializedState') + await expect(retire()).resolves.toBe(true) + expect(authority.captures).toEqual([]) + expect(revisionCheck).not.toHaveBeenCalled() + expect(fullStateWrite).not.toHaveBeenCalled() + }) + } +) diff --git a/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts b/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts new file mode 100644 index 00000000000..dd6754c1d73 --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-publication-during-read.test.ts @@ -0,0 +1,146 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'retiring-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +class RetirementRuntime extends OrcaRuntimeService { + readVisibleState() { + return this.readVisibleTerminalState(binding.ptyId) + } + snapshot(): RuntimeMobileSessionTabsSnapshot | undefined { + return this.mobileSessionTabsByWorktree.get(binding.worktreeId) + } + generations(): number { + return this.ptyLifecycleGenerationById.size + } + retirements(): number { + return this.pendingPtySurfaceRetirementsByPtyId.size + } + async closeTab(): Promise { + const snapshot = this.snapshot() + const tab = snapshot?.tabs[0] + if (!snapshot || tab?.type !== 'terminal') { + throw new Error('missing test tab') + } + await this.closeHeadlessMobileTerminalTab(binding.worktreeId, snapshot, tab) + } + publish(layoutOnly = false): void { + this.storeMobileSessionSnapshot(binding.worktreeId, { + worktree: binding.worktreeId, + publicationEpoch: 'retirement-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `${binding.tabId}::${binding.leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${binding.tabId}::${binding.leafId}`, + parentTabId: binding.tabId, + leafId: binding.leafId, + ptyId: layoutOnly ? null : binding.ptyId, + ...(layoutOnly + ? { + parentLayout: { + root: { type: 'leaf' as const, leafId: binding.leafId }, + activeLeafId: binding.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [binding.leafId]: binding.ptyId } + } + } + : {}), + title: 'Terminal', + isActive: true + } + ] + }) + } +} + +it.each(['read', 'replacement', 'legacy-replacement'] as const)( + 'publishes an exited terminal retirement with concurrent %s', + async (action) => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const gate = authority.pause() + const exiting = runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + await gate.started.promise + if (action === 'read') { + await runtime.readVisibleState() + } else if (action === 'legacy-replacement') { + runtime.onPtySpawned(binding.ptyId) + } else { + runtime.onPtySpawned(binding.ptyId, 'dddddddd-dddd-4ddd-8ddd-dddddddddddd') + } + gate.finish.resolve() + await exiting + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(runtime.snapshot()?.tabs).toHaveLength(action === 'read' ? 0 : 1) + if (action === 'read') { + expect(runtime.retirements()).toBe(0) + } + } +) + +it('publishes an exit whose only live ownership is the mobile parent layout', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + runtime.publish(true) + const gate = authority.pause() + const exiting = runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + await gate.started.promise + gate.finish.resolve() + await exiting + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(runtime.snapshot()?.tabs).toEqual([]) + expect(runtime.generations()).toBe(0) +}) + +it('completes a durable close and refuses a split queued behind it', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + const runtime = new RetirementRuntime(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) + runtime.registerPty(binding.ptyId, binding.worktreeId, null, binding) + runtime.publish() + const gate = authority.pause() + const close = runtime.closeTab() + await gate.started.promise + const split = store.persistPtyBinding({ + ...binding, + leafId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + ptyId: 'concurrent-split', + expectedSourceBinding: binding + }) + gate.finish.resolve() + await close + await expect(split).resolves.toBe(false) + expect(kill).toHaveBeenCalledExactlyOnceWith(binding.ptyId) + expect(runtime.snapshot()?.tabs).toEqual([]) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() +}) diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts new file mode 100644 index 00000000000..b1d185c38b1 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -0,0 +1,45 @@ +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from './store' + +function unexpectedPreflight(): never { + throw new Error('Spawn commit must not rerun preflight') +} + +export function createPtySpawnCommitDependencies( + runtime: OrcaRuntimeService, + store: Store +): PtySpawnIpcDeps & PtyRuntimeControllerDeps { + return { + runtime, + store, + sendPtySpawnedToRenderer: () => {}, + getLocalPtyStartupPromise: unexpectedPreflight, + getLocalPtyProviderStartupPromise: unexpectedPreflight, + adoptStablePane: unexpectedPreflight, + assertFolderWorkspacePtyPathUsable: unexpectedPreflight, + resolvePtySpawnStartupCwd: unexpectedPreflight, + localStartupCwdDirectoryExists: unexpectedPreflight, + prepareCodexResumeHome: unexpectedPreflight, + noCodexResumeLaunch: unexpectedPreflight, + resolveCodexResumeLaunch: unexpectedPreflight, + reconcileSharedRuntimeResumeHome: unexpectedPreflight, + stripSequencedStartupResumeArgv: unexpectedPreflight, + transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight, + trustedTerminalHandleEnv: new Set(), + syncPtyBackgroundedDelivery: unexpectedPreflight, + stopReplacedPty: unexpectedPreflight, + requestSerializedBuffer: unexpectedPreflight, + shutdownProviderAndDetectExit: unexpectedPreflight, + rememberSyntheticKillExit: unexpectedPreflight, + rememberRetiredRejectedPty: unexpectedPreflight, + sendPtyExitToRenderer: unexpectedPreflight, + finishPtyShutdown: unexpectedPreflight, + retiredRejectedPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + get mainWindow() { + return unexpectedPreflight() + } + } +} diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts new file mode 100644 index 00000000000..ef146779b4b --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -0,0 +1,238 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { registerPersistedPtySpawn } from '../../ipc/pty/pane/spawn-registration' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { ptyOwnership, ptyIncarnationById } from '../../ipc/pty/provider/ownership-state' +import { ptySizes } from '../../ipc/pty/delivery/visibility-state' +import { + paneSpawnReservationsByOwnerKey, + reservePaneSpawn, + reserveIdlePaneSpawn, + resolvePaneSpawnReservation, + type PaneSpawnReservation +} from '../../ipc/pty/pane/spawn-reservation' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it.each( + [ + { controller: 'runtime', connectionId: null, exitCode: 0 }, + { controller: 'runtime', connectionId: 'test-host', exitCode: 0 }, + { controller: 'ipc', connectionId: null, exitCode: -1 }, + { controller: 'ipc', connectionId: 'test-host', exitCode: 0 } + ].flatMap((test) => [ + { ...test, stableOwner: false }, + { ...test, stableOwner: true } + ]) +)( + 'retires an exited $controller binding on $connectionId after disk finishes (stable: $stableOwner)', + async ({ controller, connectionId, exitCode, stableOwner }) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: connectionId + ? `ssh:${connectionId}@@pty-exited-during-durable-bind` + : 'pty-exited-during-durable-bind', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + } + const owner = stableOwner + ? { + ...binding, + hasPersistedBinding: true as const, + persistedIncarnationId: 'previous-incarnation' + } + : null + if (owner) { + await store.persistPtyBinding( + { ...binding, incarnationId: owner.persistedIncarnationId }, + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + } + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + runtime.assertPtyRegistrationAllowed(binding.ptyId, binding.incarnationId) + let commit: () => Promise + const reservationKey = JSON.stringify([connectionId, binding.worktreeId, binding.leafId]) + let reservation: PaneSpawnReservation | undefined + if (controller === 'runtime') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store, options: {} } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.stablePaneOwner = owner + ctx.hostSessionBinding = { store, ...binding } + ctx.metadataLeafId = binding.leafId + commit = () => commitRuntimePtySpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; its runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.stablePaneOwner = owner + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + reservation = reservePaneSpawn(reservationKey) + ctx.paneSpawnReservationKey = reservationKey + ctx.paneSpawnReservation = reservation + commit = () => commitPtyIpcSpawn(ctx) + } + const gate = authority.pause() + const pending = + controller === 'ipc' + ? expect(commit()).resolves.toMatchObject({ + id: binding.ptyId, + incarnationId: binding.incarnationId + }) + : expect(commit()).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + const nextReservation = reservation ? reserveIdlePaneSpawn(reservationKey) : undefined + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) + await runtime.onPtyExit(binding.ptyId, exitCode, binding.incarnationId, { + providerExitObserved: true + }) + gate.finish.resolve() + await pending + expect(ptyOwnership.has(binding.ptyId)).toBe(false) + expect(ptyIncarnationById.has(binding.ptyId)).toBe(false) + expect(ptySizes.has(binding.ptyId)).toBe(false) + expect( + store + .getSshRemotePtyLeases(connectionId ?? undefined) + .some((lease) => lease.ptyId.includes('pty-exited-during-durable-bind')) + ).toBe(false) + if (reservation) { + await expect(reservation.promise).resolves.toMatchObject({ id: binding.ptyId }) + const next = await nextReservation + expect(next).not.toBe(reservation) + resolvePaneSpawnReservation(reservationKey, next, { id: 'next-spawn' }) + expect(paneSpawnReservationsByOwnerKey.has(reservationKey)).toBe(false) + } + const state = readState() + const session = connectionId + ? state.workspaceSessionsByHostId[toSshExecutionHostId(connectionId)] + : state.workspaceSession + expect( + session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + ).toBeUndefined() + } +) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'exited-pty', + incarnationId: 'old-incarnation' +} + +it.each(['replacement-pty', binding.ptyId])( + 'preserves a replacement binding to %s while retirement waits', + async (ptyId) => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + const gate = authority.pause() + const replacement = store.persistPtyBinding({ + ...binding, + ptyId, + incarnationId: 'new-incarnation' + }) + await gate.started.promise + const registration = expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).rejects.toThrow('agent_session_exited_during_start') + gate.finish.resolve() + await Promise.all([replacement, registration]) + const session = readState().workspaceSession + expect(session.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[binding.leafId]).toBe(ptyId) + expect(session.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( + 'new-incarnation' + ) + } +) + +it('retains the binding when loss of contact supplies no process-exit proof', async () => { + const { store, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, -1, binding.incarnationId) + expect(() => + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).toThrow('agent_session_exited_during_start') + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ + binding.leafId + ] + ).toBe(binding.ptyId) +}) + +it('does not settle rejected registration until its exit cleanup reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.beginPtyRegistration(binding.ptyId, binding.incarnationId) + await store.persistPtyBinding(binding) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + const gate = authority.pause() + let rejected = false + const cleanup = registerPersistedPtySpawn( + runtime, + store, + binding.ptyId, + binding.worktreeId, + null, + binding + ) + if (!cleanup) { + throw new Error('exited registration did not start durable cleanup') + } + const pending = cleanup.catch((error: unknown) => { + rejected = true + throw error + }) + const failure = expect(pending).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + expect(rejected).toBe(false) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId[ + binding.leafId + ] + ).toBe(binding.ptyId) + gate.finish.resolve() + await failure + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBeUndefined() +}) + +it('keeps successful registration synchronous through the remaining spawn publication', async () => { + const { store } = await fixture() + const runtime = new OrcaRuntimeService(store) + await store.persistPtyBinding(binding) + expect( + registerPersistedPtySpawn(runtime, store, binding.ptyId, binding.worktreeId, null, binding) + ).toBeUndefined() +}) diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts new file mode 100644 index 00000000000..9fb0d65a2cb --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -0,0 +1,77 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'mobile-pending-spawn', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +class MobileCreateRuntime extends OrcaRuntimeService { + waitForCreatedSurface() { + this.pendingMobileTerminalCreatesByKey.set(`${binding.worktreeId}::${binding.tabId}`, { + activate: true, + paired: true, + selectIfNoActiveTab: true + }) + return this.waitForMobileTerminalSurface(binding.worktreeId, binding.tabId, { + requireReady: true + }) + } +} + +it.each(['ipc', 'runtime'])( + 'publishes the preallocated handle to a pending mobile %s create', + async (controller) => { + const { store, authority } = await fixture() + const runtime = new MobileCreateRuntime(store) + const preAllocatedHandle = runtime.createPreAllocatedTerminalHandle() + const surface = runtime.waitForCreatedSurface() + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + let commit: () => Promise + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + ctx.preAllocatedHandle = preAllocatedHandle + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { + ...binding, + cols: 80, + rows: 24, + preAllocatedHandle + }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit() + await gate.started.promise + gate.finish.resolve() + await pending + const result = await surface + expect(result.tab.terminal).toBe(preAllocatedHandle) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + } +) diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts new file mode 100644 index 00000000000..719a3bbe924 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnIpcDeps } from '../../ipc/pty/ipc/spawn-types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../../ipc/pty/runtime/controller-deps' +import { ptyIncarnationById, ptyOwnership } from '../../ipc/pty/provider/ownership-state' +import { clearProviderPtyState } from '../../ipc/pty/provider/state-cleanup' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'replaced-during-save', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const replacementIncarnation = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +afterEach(() => { + clearProviderPtyState(binding.ptyId) + ptyOwnership.delete(binding.ptyId) +}) + +it.each( + ['ipc', 'runtime'].flatMap((controller) => + ['save', 'shutdown'].map((replacementDuring) => ({ controller, replacementDuring })) + ) +)( + 'preserves a successor during $replacementDuring when the predecessor $controller save fails', + async ({ controller, replacementDuring }) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + ptyIncarnationById.set(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + const shutdownStarted = deferred() + const shutdownFinished = deferred() + const holdShutdown = async () => { + shutdownStarted.resolve() + await shutdownFinished.promise + } + let commit: () => Promise + let shutdown: ReturnType + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown) + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + await gate.started.promise + if (replacementDuring === 'shutdown') { + gate.finish.reject(new Error('disk full')) + await shutdownStarted.promise + } + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + runtime.onPtySpawned(binding.ptyId, replacementIncarnation) + ptyIncarnationById.set(binding.ptyId, replacementIncarnation) + ptyOwnership.set(binding.ptyId, 'successor-host') + if (replacementDuring === 'save') { + gate.finish.reject(new Error('disk full')) + } + shutdownFinished.resolve() + await pending + if (replacementDuring === 'save') { + expect(shutdown).not.toHaveBeenCalled() + } else { + expect(shutdown).toHaveBeenCalledExactlyOnceWith(binding.ptyId, { + immediate: true, + expectedIncarnationId: binding.incarnationId + }) + } + expect(ptyIncarnationById.get(binding.ptyId)).toBe(replacementIncarnation) + expect(ptyOwnership.get(binding.ptyId)).toBe('successor-host') + await runtime.onPtyExit(binding.ptyId, 0, replacementIncarnation, { + providerExitObserved: true + }) + } +) + +it.each(['ipc', 'runtime'])( + 'keeps replacement provider identity when an exited %s spawn finishes saving', + async (controller) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + let commit: () => Promise + if (controller === 'ipc') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; the runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only commit runs; the runtime/store are real and preflight-only dependencies are unreachable. + const deps = { runtime, store } as PtyRuntimeControllerDeps + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = expect(commit()).rejects.toThrow('agent_session_exited_during_start') + await gate.started.promise + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) + runtime.onPtySpawned(binding.ptyId, replacementIncarnation) + runtime.seedHeadlessTerminal(binding.ptyId, 'replacement history', { cols: 112, rows: 37 }) + ptyIncarnationById.set(binding.ptyId, replacementIncarnation) + gate.finish.resolve() + await pending + expect(ptyIncarnationById.get(binding.ptyId)).toBe(replacementIncarnation) + expect(await runtime.serializeMainTerminalBuffer(binding.ptyId)).toMatchObject({ + cols: 112, + rows: 37 + }) + await runtime.onPtyExit(binding.ptyId, 0, replacementIncarnation, { + providerExitObserved: true + }) + } +) diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts new file mode 100644 index 00000000000..26737df9dd0 --- /dev/null +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit' +import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state' +import type { PtySpawnResult } from '../../providers/types' +import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit' +import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import { createPtySpawnCommitDependencies } from './pty-spawn-commit-dependencies-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'restoring-pty', + incarnationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +} +const restores: Pick[] = [ + { snapshot: 'restored history\r\n' }, + { + coldRestore: { + scrollback: 'restored history\r\n', + lastTitle: 'Restored', + cwd: '/fixture/local' + } + }, + { replay: 'restored history\r\n' } +] + +describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { + it.each(restores)( + 'keeps restored history before output during the binding save: %j', + async (restore) => { + const { store, authority } = await fixture() + const runtime = new OrcaRuntimeService(store) + runtime.onPtySpawned(binding.ptyId, binding.incarnationId) + const deps = createPtySpawnCommitDependencies(runtime, store) + let commit: () => Promise + const result = { id: binding.ptyId, incarnationId: binding.incarnationId, ...restore } + if (controller === 'ipc') { + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.validatedLeafId = binding.leafId + commit = () => commitPtyIpcSpawn(ctx) + } else { + const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = result + ctx.metadataLeafId = binding.leafId + ctx.hostSessionBinding = { store, ...binding } + commit = () => commitRuntimePtySpawn(ctx) + } + const gate = authority.pause() + const pending = commit() + await gate.started.promise + runtime.onPtyData(binding.ptyId, 'live output\r\n', Date.now()) + gate.finish.resolve() + await pending + const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('live output') + expect(snapshot?.data.indexOf('restored history')).toBeLessThan( + snapshot?.data.indexOf('live output') ?? -1 + ) + await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { + providerExitObserved: true + }) + } + ) +}) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts index e58d0280bae..eaae559e77f 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -1,11 +1,7 @@ -/** - * The bar for this change is "the bytes on disk did not move". Every case below runs the exact - * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and - * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write - * guard and a drifting payload is corrupted persisted state. - */ +/** Full serialization retains its bytes/hash; domain serialization preserves bytes and equality. */ import { createHash, randomUUID } from 'node:crypto' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' import { applySecretSentinelSubstitutions, type SecretSentinelSubstitution @@ -36,14 +32,101 @@ function expectIdenticalToPrevious( ): void { const before = previousImplementation(serialized, subs, degradedPrefix) const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix) + const text = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix, 'text') expect(after.payload.equals(before.payload)).toBe(true) expect(after.stateHash).toBe(before.stateHash) + expect(text.payload).toBe(before.payload.toString('utf8')) + expect(text.stateHash).toBe(before.stateHash) } function sentinel(): string { return `orca-secret-slot-${randomUUID()}` } +describe('complete profile domain serialization', () => { + it('preserves escaped domain names, omission and the keys passed to toJSON', () => { + const domain = '雪"\\\ud800' + const state = { + [domain]: { + toJSON(key: string) { + return { key, nested: { toJSON: (nestedKey: string) => nestedKey } } + } + }, + omitted: undefined, + nullable: null, + history: [{ id: 'z' }, { id: 'a' }] + } + + const serialized = serializeCompleteProfileStateDomains(state, [], '') + + expect(serialized.payload.toString('utf8')).toBe(JSON.stringify(state)) + expect(serialized.domains.map(({ domain }) => domain)).toEqual([domain, 'nullable', 'history']) + expect(JSON.parse(serialized.payload.toString('utf8'))).toMatchObject({ + [domain]: { key: domain, nested: 'nested' }, + nullable: null + }) + }) + + it('keeps the complete hash stable across ciphertext changes and sensitive to plaintext and absence', () => { + const slot = sentinel() + const state = { + settings: { cookie: slot }, + future: { shadow: 'ciphertext-one' }, + nullable: null + } + const firstSub = [{ sentinel: slot, blob: 'ciphertext-one', hashValue: 'secret' }] + const first = serializeCompleteProfileStateDomains(state, firstSub, '') + const second = serializeCompleteProfileStateDomains( + state, + [{ sentinel: slot, blob: 'ciphertext-two', hashValue: 'secret' }], + '' + ) + + expect(first.payload).toEqual( + applySecretSentinelSubstitutions(JSON.stringify(state), firstSub, '').payload + ) + expect(second.payload).not.toEqual(first.payload) + expect(second.stateHash).toBe(first.stateHash) + expect(JSON.parse(second.payload.toString('utf8')).future).toEqual({ shadow: 'ciphertext-one' }) + expect( + serializeCompleteProfileStateDomains( + state, + [{ sentinel: slot, blob: 'ciphertext-one', hashValue: 'changed-secret' }], + '' + ).stateHash + ).not.toBe(first.stateHash) + expect( + serializeCompleteProfileStateDomains({ ...state, nullable: undefined }, firstSub, '') + .stateHash + ).not.toBe(first.stateHash) + }) + + it('retains unknown own keys and encodes bytes only when a complete payload is requested', () => { + const state = Object.fromEntries([ + ['9', 9], + ['3', 3], + ['z', null], + ['__proto__', { own: true }], + ['constructor', false], + ['future', { text: '雪😀\ud800', absent: undefined }] + ]) + const expected = JSON.stringify(state) + const encode = vi.spyOn(Buffer, 'from') + try { + const serialized = serializeCompleteProfileStateDomains(state, [], 'safeStorage-degraded\0') + expect(serialized.domains.map(({ domain }) => domain)).toEqual(Object.keys(state)) + expect(serialized.domains.find(({ domain }) => domain === '__proto__')?.payload).toBe( + '{"own":true}' + ) + expect(encode).not.toHaveBeenCalled() + expect(serialized.payload.toString('utf8')).toBe(expected) + expect(encode).toHaveBeenCalledExactlyOnceWith(expected, 'utf8') + } finally { + encode.mockRestore() + } + }) +}) + describe('applySecretSentinelSubstitutions', () => { it('produces bytes and a hash identical to the previous implementation', () => { const subs: SecretSentinelSubstitution[] = [ @@ -117,6 +200,39 @@ describe('applySecretSentinelSubstitutions', () => { expect(payload.toString('utf8')).not.toContain(subs[0].sentinel) }) + it.each(['', 'safeStorage-degraded\0'])( + 'keeps the first duplicate and handles adjacent escaped sentinels with prefix %j', + (prefix) => { + const slot = 'orca-$a/.*+?^${}()|[]\\"雪' + const subs = [ + { sentinel: slot, blob: 'cipher-other-token-"\\\n雪\ud800', hashValue: 'plain-😀' }, + { sentinel: slot, blob: 'duplicate-must-not-win', hashValue: 'wrong-plain' }, + { sentinel: 'other-token', blob: 'last', hashValue: 'last-plain' } + ] + const serialized = JSON.stringify({ nested: { [slot]: `${slot}${slot}other-token` } }) + const expectedPayload = JSON.stringify({ + nested: { [subs[0].blob]: subs[0].blob + subs[0].blob + subs[2].blob } + }) + const expectedHashInput = JSON.stringify({ + nested: { [subs[0].hashValue]: subs[0].hashValue + subs[0].hashValue + subs[2].hashValue } + }) + const expectedHash = createHash('sha1').update(prefix).update(expectedHashInput).digest('hex') + for (const actual of [ + applySecretSentinelSubstitutions(serialized, subs, prefix), + applySecretSentinelSubstitutions(serialized, subs, prefix, 'text') + ]) { + expect(actual.payload.toString()).toBe(expectedPayload) + expect(actual.stateHash).toBe(expectedHash) + } + } + ) + + it('leaves domains without matching sentinels byte-identical', () => { + const serialized = JSON.stringify({ future: { output: '雪😀\ud800', present: null } }) + const subs = [{ sentinel: 'missing-slot', blob: 'cipher', hashValue: 'plain' }] + expectIdenticalToPrevious(serialized, subs, 'safeStorage-degraded\0') + }) + it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => { const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({ sentinel: sentinel(), @@ -172,13 +288,17 @@ describe('applySecretSentinelSubstitutions', () => { const before = counted(() => previousImplementation(serialized, subs, '')) const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, '')) + const text = counted(() => applySecretSentinelSubstitutions(serialized, subs, '', 'text')) // Two `String.replace` calls over the whole state per sentinel — payload and hash input. expect(before.fullStateReplaces).toBe(subs.length * 2) expect(after.fullStateReplaces).toBe(0) + expect(text.fullStateReplaces).toBe(0) // The old path encoded the state twice: once for sha1, once for the file write. expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9) expect(after.encodedChars).toBeLessThan(serialized.length * 1.1) expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9) + expect(text.encodedChars).toBeLessThan(serialized.length * 1.1) + expect(text.encodedChars).toBeGreaterThan(serialized.length * 0.9) }) }) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts index afcdddafa77..30ec43eaa1d 100644 --- a/src/main/persistence/loading-store/secret-sentinel-substitution.ts +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts @@ -10,69 +10,92 @@ export type SecretSentinelSubstitution = { hashValue: string } -/** - * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the - * guard hash from the same encoded segments. - * - * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each - * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so - * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when - * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three - * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space. - * - * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same - * buffers feed both the payload and the hash — 1 full-state string, 1 encode. - * - * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped - * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte - * sequence it saw when it was handed one concatenated string. - */ +type SecretSubstitutionOutput = { + encode: (value: string) => T + concat: (chunks: T[]) => T +} + +const bufferOutput: SecretSubstitutionOutput = { + encode: (value) => Buffer.from(value, 'utf8'), + concat: (chunks) => Buffer.concat(chunks) +} + +const textOutput: SecretSubstitutionOutput = { + encode: (value) => value, + concat: (chunks) => chunks.join('') +} + +/** One traversal keeps ciphertext and guard hashes aligned without copying once per secret. */ export function applySecretSentinelSubstitutions( serialized: string, substitutions: readonly SecretSentinelSubstitution[], - degradedPrefix: string -): { payload: Buffer; stateHash: string } { + degradedPrefix: string, + output?: 'buffer' +): { payload: Buffer; stateHash: string } +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: 'text' +): { payload: string; stateHash: string } +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: 'buffer' | 'text' = 'buffer' +): { payload: Buffer | string; stateHash: string } { + return output === 'text' + ? substituteSentinels(serialized, substitutions, degradedPrefix, textOutput) + : substituteSentinels(serialized, substitutions, degradedPrefix, bufferOutput) +} + +function substituteSentinels( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string, + output: SecretSubstitutionOutput +): { payload: T; stateHash: string } { const hash = createHash('sha1').update(degradedPrefix) if (substitutions.length === 0) { - const payload = Buffer.from(serialized, 'utf8') + const payload = output.encode(serialized) return { payload, stateHash: hash.update(payload).digest('hex') } } - const replacementBySentinel = new Map() + const replacementBySentinel = new Map() const alternatives: string[] = [] for (const { sentinel, blob, hashValue } of substitutions) { - // Preserved from the loop this replaces: both the search key and the replacements are the - // JSON-escaped forms, because that is what `serialized` actually contains. + // Match escaped JSON contents, including quotes and backslashes inside a secret. const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) if (replacementBySentinel.has(escapedSentinel)) { continue } alternatives.push(escapeRegex(escapedSentinel)) replacementBySentinel.set(escapedSentinel, { - blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'), - hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8') + blob: output.encode(JSON.stringify(blob).slice(1, -1)), + hashValue: output.encode(JSON.stringify(hashValue).slice(1, -1)) }) } - // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly - // once: a single pass that substitutes every occurrence cannot leave one behind on disk. + // Substitute every occurrence so a repeated sentinel cannot survive on disk. const pattern = new RegExp(alternatives.join('|'), 'g') - const chunks: Buffer[] = [] + const chunks: T[] = [] let cursor = 0 let match: RegExpExecArray | null while ((match = pattern.exec(serialized)) !== null) { - // Non-null: the alternation is built from exactly the map's keys. - const replacement = replacementBySentinel.get(match[0])! - // A sliced substring, so this does not copy the state; the encode below is its only pass. - const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8') + const replacement = replacementBySentinel.get(match[0]) + if (replacement === undefined) { + throw new Error('Secret substitution matched an unregistered sentinel') + } + // The Buffer output reuses each literal's UTF-8 bytes for both the payload and hash. + const literal = output.encode(serialized.slice(cursor, match.index)) chunks.push(literal, replacement.blob) hash.update(literal) hash.update(replacement.hashValue) cursor = match.index + match[0].length } - const tail = Buffer.from(serialized.slice(cursor), 'utf8') + const tail = output.encode(serialized.slice(cursor)) chunks.push(tail) hash.update(tail) - return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') } + return { payload: output.concat(chunks), stateHash: hash.digest('hex') } } diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index d358f4c8f62..12efd45a19e 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -146,7 +146,10 @@ export function removeWorkspaceSessionOwnerInPartition( [resolved]: session } } - scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) + scheduleSave( + owner[sessionHostPartitionOperationsContext].scheduling, + resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) } export function partitionOwnsWorktreeTabs( @@ -206,7 +209,9 @@ export function setHostWorkspaceSession( ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, [hostId]: pruned } - scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling, [ + 'workspaceSessionsByHostId' + ]) } export function installSessionHostPartitionOperationsContext( diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 37ffd9d366b..9c3cf23be7b 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -3,7 +3,7 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' @@ -18,7 +18,13 @@ import { scheduleSave } from './write-scheduling' type SessionSnapshotOperationsRuntime = Pick< StoreRuntimeState, - 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' + | 'durableMutationPhase' + | 'pendingSnapshotFileWork' + | 'profileMaintenancePending' + | 'quitFlushStarted' + | 'state' + | 'terminalScrollbackSnapshotStorage' + | 'writesFrozen' > const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') @@ -43,7 +49,15 @@ export class SessionSnapshotOperations { setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { const resolved = resolveHostId(hostId) + const { runtime } = this[sessionSnapshotOperationsContext] + if (runtime.durableMutationPhase === 'rollback') { + this.assertSnapshotAdmission(true) + // The fieldwise rollback already preserves newer edits; renderer rebasing would undo it. + this.publishSession(session, resolved) + return + } if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission(true) setLocalWorkspaceSession(this, session) return } @@ -56,6 +70,7 @@ export class SessionSnapshotOperations { ): void { const resolved = resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.assertSnapshotAdmission() setLocalWorkspaceSession(this, session, true) return } @@ -76,15 +91,34 @@ export class SessionSnapshotOperations { if (Object.hasOwn(patch, 'browserUrlHistory')) { next = pruneWorkspaceSessionBrowserHistory(next) } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + this.publishSession(next, resolved) + } + + private publishSession(session: WorkspaceSessionState, hostId: ExecutionHostId): void { + const { runtime, scheduling } = this[sessionSnapshotOperationsContext] + if (hostId === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = session } else { - this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { - ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, - [resolved]: next + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [hostId]: session } } - scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + scheduleSave( + scheduling, + hostId === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'] + ) + } + + private assertSnapshotAdmission(allowAdmittedMutation = false): void { + const { runtime } = this[sessionSnapshotOperationsContext] + if ( + runtime.writesFrozen || + ((runtime.profileMaintenancePending || runtime.quitFlushStarted) && + !(allowAdmittedMutation && runtime.durableMutationPhase !== null)) + ) { + throw new Error('Profile maintenance or finalization is blocking new terminal snapshot work') + } } } diff --git a/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts new file mode 100644 index 00000000000..6c646285d4b --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-async-durability.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { removeSshPtyConsumerOwnerRecovery } from '../../ssh/ssh-pty-consumer-recovery' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const recovery = { + targetId: 'async-target', + clientInstanceId: 'first-owner', + serverBuildId: 'build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-lease' +} + +describe('reserved SSH persistence', () => { + it('reserves consumer replacement before mutation and durably writes both exact owners', async () => { + const { store, authority } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const first = store.upsertSshPtyConsumerRecovery(recovery) + const second = store.upsertSshPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'next-owner' + }) + expect(store.getSshPtyConsumerRecovery(recovery.targetId)).toBeNull() + gate.finish.resolve() + await Promise.all([older, first, second]) + const ownerWrites = authority.captures + .flat() + .filter(({ domain }) => domain === 'sshPtyConsumerRecoveries') + expect(ownerWrites).toHaveLength(2) + expect(ownerWrites[0]?.payload).toContain('first-owner') + expect(ownerWrites[1]?.payload).toContain('next-owner') + }) + + it('retries a failed consumer removal through durability even after memory is already empty', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.upsertSshPtyConsumerRecovery(recovery) + const gate = authority.pause() + const removal = expect( + removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) + ).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await removal + expect(readState().sshPtyConsumerRecoveries).toHaveLength(1) + await removeSshPtyConsumerOwnerRecovery(recovery.targetId, recovery.clientInstanceId, store) + expect(readState().sshPtyConsumerRecoveries).toEqual([]) + }) + + it('does not let an old consumer remove the newer owner ahead of it in the write queue', async () => { + const { store, authority, readState } = await fixture() + await store.upsertSshPtyConsumerRecovery(recovery) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const replacement = store.upsertSshPtyConsumerRecovery({ + ...recovery, + clientInstanceId: 'next-owner' + }) + const removal = removeSshPtyConsumerOwnerRecovery( + recovery.targetId, + recovery.clientInstanceId, + store + ) + gate.finish.resolve() + await Promise.all([older, replacement, removal]) + expect(readState().sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('next-owner') + }) + + it('does not detach a newer replacement lease while waiting for the writer', async () => { + const { store, authority, readState } = await fixture() + const lease = { targetId: recovery.targetId, ptyId: 'relay-pty', state: 'attached' as const } + store.upsertSshRemotePtyLease(lease) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await gate.started.promise + const detach = store.markSshRemotePtyLeasesAsync(recovery.targetId, 'detached') + expect(store.getSshRemotePtyLeases(recovery.targetId)[0]?.state).toBe('attached') + store.upsertSshRemotePtyLease({ ...lease, worktreeId: 'new-worktree' }) + gate.finish.resolve() + await Promise.all([older, detach]) + expect(readState().sshRemotePtyLeases).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'relay-pty', + worktreeId: 'new-worktree', + state: 'attached' + }) + ]) + ) + }) + + it('does not acknowledge a failed attachment retry before its lease reaches disk', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.upsertSshRemotePtyLease({ + targetId: recovery.targetId, + ptyId: 'relay-pty', + state: 'expired' + }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + const attachment = expect( + store.markSshRemotePtyLeasesAttachedAsync(recovery.targetId, ['relay-pty']) + ).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await attachment + expect(readState().sshRemotePtyLeases[0].state).toBe('expired') + await store.markSshRemotePtyLeasesAttachedAsync(recovery.targetId, ['relay-pty']) + expect(readState().sshRemotePtyLeases[0].state).toBe('attached') + }) +}) diff --git a/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts b/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts new file mode 100644 index 00000000000..28f361a12cd --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-durable-mutation.ts @@ -0,0 +1,42 @@ +import type { DurableProfileStateMutation, StoreRuntimeState } from './store-runtime-state' +import { + flushDurableStateOrThrowAsync, + type WriteFlushBarrierOperations +} from './write-flush-barriers' + +export type SshLeaseDurableMutationRuntime = Pick< + StoreRuntimeState, + | 'dirtyProfileStateDomains' + | 'profileMaintenancePending' + | 'profileStateAuthority' + | 'quitFlushStarted' + | 'runDurableMutation' + | 'writesFrozen' +> + +export async function runSshLeaseDurableMutation( + runtime: SshLeaseDurableMutationRuntime, + barriers: WriteFlushBarrierOperations, + domain: 'sshPtyConsumerRecoveries' | 'sshRemotePtyLeases', + mutate: () => DurableProfileStateMutation +): Promise { + const writeMutation = (): DurableProfileStateMutation => { + const mutation = mutate() + if (mutation.persist !== false) { + runtime.dirtyProfileStateDomains?.add(domain) + } + return mutation + } + if (runtime.profileStateAuthority?.asynchronous) { + return runtime.runDurableMutation(writeMutation) + } + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + throw new Error('Cannot mutate finalized profile persistence') + } + // Legacy SSH recovery keeps its existing asynchronous disk barrier on older runtimes. + const mutation = writeMutation() + if (mutation.persist !== false) { + await flushDurableStateOrThrowAsync(barriers) + } + return mutation.value +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 7da5144898e..7188b4efb66 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -43,10 +43,14 @@ import type { StoreRuntimeState } from './store-runtime-state' import type { WriteFlushBarrierOperations } from './write-flush-barriers' import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' import type { WriteSchedulingOperations } from './write-scheduling' -import { flushDurableStateOrThrowAsync } from './write-flush-barriers' import { scheduleSave } from './write-scheduling' +import { + runSshLeaseDurableMutation, + type SshLeaseDurableMutationRuntime +} from './ssh-lease-durable-mutation' -type SshLeaseRecoveryOperationsRuntime = Pick +type SshLeaseRecoveryOperationsRuntime = SshLeaseDurableMutationRuntime & + Pick const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') type SshLeaseRecoveryOperationsContext = { @@ -81,8 +85,15 @@ export class SshLeaseRecoveryOperations { await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) } - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + async removeSshPtyConsumerRecovery( + targetId: string, + expectedClientInstanceId?: string + ): Promise { + await removeSshPtyConsumerRecoveryOperation( + getSshPtyConsumerRecoveryOperations(this), + targetId, + expectedClientInstanceId + ) } getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { @@ -127,6 +138,9 @@ export class SshLeaseRecoveryOperations { markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + this[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) } async markSshRemotePtyLeasesAsync( @@ -195,8 +209,13 @@ export function getSshPtyConsumerRecoveryOperations( return { state: owner[sshLeaseRecoveryOperationsContext].runtime.state, protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshPtyConsumerRecoveries', + mutate + ) } } @@ -238,9 +257,19 @@ export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): Ssh targetId, leases ), - flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), - flushDurableStateOrThrowAsync: () => - flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + flush: () => { + owner[sshLeaseRecoveryOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'sshRemotePtyLeases' + ) + owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush() + }, + runDurableMutation: (mutate) => + runSshLeaseDurableMutation( + owner[sshLeaseRecoveryOperationsContext].runtime, + owner[sshLeaseRecoveryOperationsContext].flushBarriers, + 'sshRemotePtyLeases', + mutate + ) } } diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index d21ab5f04da..ce81cea373b 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -32,7 +32,10 @@ import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operati import { scheduleSave } from './write-scheduling' import { forgetSshConnectionGeneration } from '../../ssh/ssh-connection-generation' -type SshProfileOperationsRuntime = Pick +type SshProfileOperationsRuntime = Pick< + StoreRuntimeState, + 'dirtyProfileStateDomains' | 'protectedSecrets' | 'state' +> const sshProfileOperationsContext = Symbol('SshProfileOperations') type SshProfileOperationsContext = { @@ -146,7 +149,12 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar state: owner[sshProfileOperationsContext].runtime.state, protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), - flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + flush: () => { + owner[sshProfileOperationsContext].runtime.dirtyProfileStateDomains?.add( + 'claudeLivePtySessionIds' + ) + owner[sshProfileOperationsContext].flushBarriers.flush() + } } } diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 026afd12c01..d324c0956cf 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,3 +1,5 @@ +import { serializeCompleteProfileStateDomains } from './profile-state-authority-writes' +import type { ProfileStateDomainReplacement } from './profile-state-authority' import { randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' @@ -30,7 +32,92 @@ export class StateSerializationSecretHandlingOperations { return durable } - buildStateToSave(): { + /** Serialize domains with complete secret handling; unknown domains fall back to a full write. */ + buildStateDomainsToSave(domains: ReadonlySet): + | { + payload: Buffer + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } + | undefined { + // A later save must retry secrets deferred by any domain, until a durable commit succeeds. + if (this.runtime.protectedSecrets.hasPendingEncryption()) { + return undefined + } + const stateToSave: Record = {} + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + const encrypt = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + return encrypted.blob + } + for (const domain of domains) { + switch (domain) { + case 'settings': + stateToSave[domain] = this.buildSettingsToSave(encrypt) + break + case 'workspaceSession': + stateToSave[domain] = this.runtime.state.workspaceSession + break + case 'automations': + case 'automationRuns': + stateToSave[domain] = this.runtime.state[domain] + break + case 'featureInteractionTelemetryBuckets': + stateToSave[domain] = this.runtime.state.featureInteractionTelemetryBuckets + break + case 'ui': + stateToSave[domain] = { + ...this.runtime.state.ui, + browserKagiSessionLink: + encrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink ?? '' + ) || null + } + break + case 'worktreeMeta': + stateToSave[domain] = omitDefaultWorktreeMetaFieldsInMap(this.runtime.state.worktreeMeta) + break + case 'worktreeMetaByIdentity': + if (this.runtime.state.worktreeMetaByIdentity !== undefined) { + stateToSave[domain] = omitDefaultWorktreeMetaFieldsInMap( + projectWorktreeMetaByIdentityOntoLocators( + this.runtime.state.worktreeMetaByIdentity, + this.runtime.state + ) + ) + } + break + case 'worktreeIdentityAliases': + if (this.runtime.state.worktreeIdentityAliases !== undefined) { + stateToSave[domain] = this.runtime.state.worktreeIdentityAliases + } + break + case 'workspaceSessionsByHostId': + if (this.runtime.state.workspaceSessionsByHostId !== undefined) { + stateToSave[domain] = withoutRedundantPartitionGlobals( + this.runtime.state.workspaceSessionsByHostId, + this.runtime.state.workspaceSession + ) + } + break + case 'sshRemotePtyLeases': + stateToSave[domain] = this.runtime.state.sshRemotePtyLeases + break + default: + return undefined + } + } + return { + payload: Buffer.from(JSON.stringify(stateToSave), 'utf8'), + protectedSecretUpdates + } + } + + buildStateToSave(serializeDomains = false): { + domains?: readonly ProfileStateDomainReplacement[] payload: Buffer stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] @@ -122,21 +209,7 @@ export class StateSerializationSecretHandlingOperations { ) }) ), - settings: { - ...stripRetiredGlobalSettings(this.runtime.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.runtime.state.settings.opencodeSessionCookie - ), - opencodeGoApiKey: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - this.runtime.state.settings.opencodeGoApiKey ?? '' - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.runtime.state.settings.httpProxyUrl ?? '' - ) - }, + settings: this.buildSettingsToSave(encryptToSentinel), ui: { ...this.runtime.state.ui, browserKagiSessionLink: encryptOptionalToSentinel( @@ -145,6 +218,24 @@ export class StateSerializationSecretHandlingOperations { ) } } + if ( + serializeDomains && + !('toJSON' in stateToSave && typeof stateToSave.toJSON === 'function') + ) { + const serialized = serializeCompleteProfileStateDomains( + stateToSave, + secretSubs, + protectedStorageDegraded ? 'safeStorage-degraded\0' : '' + ) + return { + domains: serialized.domains, + stateHash: serialized.stateHash, + get payload() { + return serialized.payload + }, + protectedSecretUpdates + } + } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) @@ -158,4 +249,22 @@ export class StateSerializationSecretHandlingOperations { ) return { payload, stateHash, protectedSecretUpdates } } + + private buildSettingsToSave(encrypt: (slot: string, plaintext: string) => string) { + return { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + opencodeGoApiKey: encrypt( + PROTECTED_SECRET_SLOT.opencodeGoApiKey, + this.runtime.state.settings.opencodeGoApiKey ?? '' + ), + httpProxyUrl: encrypt( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + } + } } diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index c3f2059efe1..707110cee21 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -65,6 +65,23 @@ import { installSshLeaseRecoveryOperationsContext } from './ssh-lease-recovery-operations' +export type StoreDomainOperations = WriteSchedulingOperations & + PrimaryStateWriteOperations & + ProjectCollectionOperations & + RepoLifecycleOperations & + MobileTabSelectionPersistence & + SparsePresetPersistence & + AutomationPersistence & + MetadataLineageOperations & + ProfilePreferences & + SessionHostPartitionOperations & + SessionSnapshotOperations & + PtyBindingPersistenceOperations & + SshProfileOperations & + RetiredWorktreeNamePersistence & + SshLeaseRecoveryOperations & + WriteFlushBarrierOperations + export type StoreDomains = { adaptation: LoadedStateAdaptationOperations backups: BackupRecoveryRotationOperations diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index b14ea8ce3a4..ade6bccdbea 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -19,10 +19,20 @@ import type { AutomationListProjectionCache, AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStatePersistenceAuthority } from './profile-state-authority' +import type { AutomationRun } from '../../../shared/automations-types' + +export type DurableProfileStateMutation = { + value: T + /** 'if-dirty' fences an existing change without rewriting an already durable generation. */ + persist?: boolean | 'if-dirty' + rollback?: () => void +} export type StoreRuntimeOptions = { dataFile?: string storageAuthority?: AutomationStorageAuthority + profileStateAuthority?: ProfileStatePersistenceAuthority } /** Mutable coordination state shared only with this Store's private collaborators. */ @@ -30,6 +40,7 @@ export class StoreRuntimeState { state!: PersistedState readonly dataFile: string readonly storageAuthority: AutomationStorageAuthority + readonly profileStateAuthority: ProfileStatePersistenceAuthority | undefined automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage @@ -41,11 +52,19 @@ export class StoreRuntimeState { inFlightAsyncTmpFile: string | null = null backupRotationInFlight = false writesFrozen = false + fatalMutationError: Error | null = null + durableMutationPhase: 'mutate' | 'rollback' | null = null + profileMaintenancePending = false + pendingProfileMaintenance: Promise | null = null + readonly pendingProfileFlushes = new Set>() quitFlushStarted = false quitFlushPromise: Promise | null = null lastWrittenStateHash: string | null = null lastDurableWriteGeneration = -1 firstPendingSaveAt: number | null = null + /** Known dirty domains, or null when a caller requires a complete-document fallback. */ + dirtyProfileStateDomains: Set | null = new Set() + pendingAutomationRunsAfter: readonly AutomationRun[] | undefined githubCacheDirty = false githubCacheGeneration = 0 pendingGithubCacheWrite: Promise | null = null @@ -54,6 +73,7 @@ export class StoreRuntimeState { readonly protectedSecrets = new ProtectedSecretPersistence() loadNeedsSave = false flushOrThrow!: () => void + runDurableMutation!: (mutate: () => DurableProfileStateMutation) => Promise settingsChangeListeners = new Set< ( updates: Partial, @@ -72,6 +92,7 @@ export class StoreRuntimeState { constructor(options: StoreRuntimeOptions = {}) { this.dataFile = options.dataFile ?? getDataFile() this.storageAuthority = options.storageAuthority ?? 'desktop' + this.profileStateAuthority = options.profileStateAuthority this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS }) diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 017583e8f86..2ebf42ff63a 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,3 +1,4 @@ +import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import { setMigrationUnsupportedPty, @@ -12,28 +13,41 @@ import { createStoreDomains, installStoreDomainContexts, STORE_DOMAIN_OPERATION_CLASSES, - type StoreDomains + type StoreDomains, + type StoreDomainOperations } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' -import type { WriteSchedulingOperations } from './write-scheduling' -import type { PrimaryStateWriteOperations } from './primary-state-writes' -import type { ProjectCollectionOperations } from './project-collection-operations' -import type { RepoLifecycleOperations } from './repo-lifecycle-operations' -import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' -import type { SparsePresetPersistence } from './sparse-preset-persistence' -import type { AutomationPersistence } from './automation-persistence' -import type { MetadataLineageOperations } from './metadata-lineage-operations' -import type { ProfilePreferences } from './profile-preferences' -import type { SessionHostPartitionOperations } from './session-host-partitions' -import type { SessionSnapshotOperations } from './session-snapshot-operations' -import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' -import type { SshProfileOperations } from './ssh-profile-operations' -import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' -import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' -import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' +import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' +import { writeVersionedProfileStateExport } from '../profile-state/profile-state-versioned-export' +import { + beginProfileStateMaintenance, + freezeProfileStateWrites, + freezeProfileStateWritesAsync, + type ProfileStateMaintenanceOptions +} from './profile-state-maintenance' +import type { + AsyncProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateStartupPaneAlias, + ProfileStatePersistenceAuthority, + ProfileStateMaintenance +} from './profile-state-authority' -export type StoreOptions = StoreRuntimeOptions +export type StoreOptions = StoreRuntimeOptions & { + /** Storage-form JSON supplied by a read-only profile migration/import boundary. */ + serializedState?: string + collectUnboundPaneAlias?: (entry: ProfileStateStartupPaneAlias) => void + /** Reuse the authority's validated startup read without retaining a cached copy. */ + initialAuthorityState?: ProfileStateAuthorityInitialState +} + +export type PreparedProfileStateExport = { + readonly json: string + commit(): void +} export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -50,12 +64,46 @@ export class Store { private readonly state: PersistedState constructor(options: StoreOptions = {}) { + if (options.profileStateAuthority !== undefined && options.serializedState !== undefined) { + throw new Error('Store cannot use both a profile-state authority and serialized state') + } + if ( + options.initialAuthorityState !== undefined && + (options.profileStateAuthority === undefined || + options.initialAuthorityState.authority !== options.profileStateAuthority) + ) { + throw new Error('Store initial authority state must belong to its profile-state authority') + } + const initial = options.initialAuthorityState + const parsedState = initial?.takeParsedState?.() + const imported = options.serializedState !== undefined this.runtime = new StoreRuntimeState(options) + this.runtime.writesFrozen = imported this.domains = createStoreDomains(this.runtime) installStoreDomainContexts(this, this.domains) this.runtime.flushOrThrow = () => this.flushOrThrow() - const loaded = this.domains.loader.load() - const normalized = normalizePersistedPaneIdentityState(loaded) + this.runtime.runDurableMutation = (mutate) => this.runDurableMutation(mutate) + let loaded: PersistedState + if (options.profileStateAuthority !== undefined) { + if (initial !== undefined) { + loaded = + initial.takeParsedState !== undefined + ? this.domains.loader.loadParsedFromAuthority(parsedState) + : this.domains.loader.loadFromAuthority(initial.serializedState) + } else { + loaded = this.domains.loader.loadFromAuthority( + options.profileStateAuthority.readSerializedState() + ) + } + } else if (options.serializedState !== undefined) { + loaded = this.domains.loader.loadSerialized(options.serializedState) + } else { + loaded = this.domains.loader.load() + } + const normalized = normalizePersistedPaneIdentityState(loaded, { + registerAliases: !imported, + collectUnboundPaneAlias: options.collectUnboundPaneAlias + }) this.state = normalized.state this.runtime.state = this.state this.runtime.activeViewPreference = new ActiveViewPreference( @@ -67,27 +115,38 @@ export class Store { // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to // still be registered, so rows outlive their owner without one (#17776). const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - setMigrationUnsupportedPtyPersistenceListener((entries) => { - this.state.migrationUnsupportedPtyEntries = entries - scheduleSave(this.domains.scheduling) - }) - agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { - this.state.legacyPaneKeyAliasEntries = entries - scheduleSave(this.domains.scheduling) - }) - if ( - normalized.changed || - this.runtime.loadNeedsSave || - adaptedProjectGroups || - sweptRepoIds.length > 0 - ) { - scheduleSave(this.domains.scheduling) + // Imported snapshots cannot own the live hook server or write their source file. + if (!imported) { + for (const entry of initial?.unboundPaneAliases ?? []) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + undefined, + entry.updatedAt + ) + } + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + setMigrationUnsupportedPtyPersistenceListener((entries) => { + this.state.migrationUnsupportedPtyEntries = entries + scheduleSave(this.domains.scheduling) + }) + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + scheduleSave(this.domains.scheduling) + }) + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { + scheduleSave(this.domains.scheduling) + } } } @@ -95,34 +154,163 @@ export class Store { return dirname(this.runtime.dataFile) } - freezeWrites(): void { - this.runtime.writesFrozen = true - if (this.runtime.writeTimer) { - clearTimeout(this.runtime.writeTimer) - this.runtime.writeTimer = null + /** + * Prepare a storage-form export for a database importer. + * + * Secret retention is committed only after the caller durably accepts the + * export. This keeps a failed migration from discarding the prior sealed + * value from the in-memory fallback store. + */ + prepareProfileStateExport(): PreparedProfileStateExport { + const built = this.domains.serialization.buildStateToSave() + let committed = false + return { + json: built.payload.toString('utf8'), + commit: () => { + if (committed) { + return + } + this.runtime.protectedSecrets.commitRetentionUpdates(built.protectedSecretUpdates) + committed = true + } } } + + /** Publish an explicit rollback/compatibility export after flushing current state. */ + writeProfileStateJsonExport(targetPath: string): number | undefined { + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (authority?.writeJsonExport) { + return authority.writeJsonExport(targetPath) + } + + const prepared = this.prepareProfileStateExport() + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, prepared.json) + prepared.commit() + return undefined + } + + /** Publish the latest SQLite revision as a durable, versioned rollback export. */ + writeLatestProfileStateJsonExport(): number | undefined { + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (!authority?.writeJsonExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + + const writeExport = authority.writeJsonExport.bind(authority) + return writeVersionedProfileStateExport(this.runtime.dataFile, writeExport) + } + + /** Publish recovery and canonical JSON checkpoints for older builds. */ + writeLatestProfileStateJsonCompatibilityExport(): number | undefined { + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile exports require an awaited export') + } + if (!authority?.writeJsonCompatibilityExport) { + return undefined + } + this.runtime.dirtyProfileStateDomains = null + this.flushOrThrow() + return authority.writeJsonCompatibilityExport(this.runtime.dataFile) + } + + writeLatestProfileStateJsonExportAsync(): Promise { + if (!this.runtime.profileStateAuthority?.asynchronous) { + return Promise.resolve(this.writeLatestProfileStateJsonExport()) + } + return this.enqueueProfileExport((authority) => + authority.writeLatestJsonExport(this.runtime.dataFile) + ) + } + + writeLatestProfileStateJsonCompatibilityExportAsync(): Promise { + if (!this.runtime.profileStateAuthority?.asynchronous) { + return Promise.resolve(this.writeLatestProfileStateJsonCompatibilityExport()) + } + return this.enqueueProfileExport((authority) => + authority.writeJsonCompatibilityExport(this.runtime.dataFile) + ) + } + + private enqueueProfileExport( + exportState: (authority: AsyncProfileStateAuthority) => Promise + ): Promise { + if ( + this.runtime.writesFrozen || + this.runtime.quitFlushStarted || + this.runtime.profileMaintenancePending + ) { + return Promise.reject(new Error('Cannot export finalized profile persistence')) + } + const authority = this.runtime.profileStateAuthority + if (!authority?.asynchronous) { + return Promise.resolve(undefined) + } + return enqueuePrimaryStateOperation(this.domains.writes, async () => { + this.runtime.dirtyProfileStateDomains = null + if (!(await writeToDiskAsync(this.domains.writes))) { + throw new Error('Profile state changed while preparing its export') + } + return exportState(authority) + }) + } + + /** Freeze writes, then preserve the SQLite family for an explicit recovery decision. */ + quarantineProfileStateDatabase( + quarantineRoot?: string, + reason?: string + ): ProfileStateDatabaseQuarantine { + this.freezeWrites() + const authority = this.runtime.profileStateAuthority + if (authority?.asynchronous) { + throw new Error('Live profile quarantine requires an awaited close') + } + if (!authority?.quarantineDatabase) { + throw new Error('SQLite profile-state quarantine is unavailable') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } + + freezeWrites(): void { + freezeProfileStateWrites(this.runtime) + } + + beginProfileMaintenance( + options?: ProfileStateMaintenanceOptions + ): Promise { + return beginProfileStateMaintenance(this.runtime, this.domains, options) + } + + freezeWritesAsync(): Promise { + return freezeProfileStateWritesAsync(this.runtime) + } + + async quarantineProfileStateDatabaseAsync( + quarantineRoot?: string, + reason?: string + ): Promise { + await this.beginProfileMaintenance({ flush: false }) + const authority = this.runtime.profileStateAuthority + if (!authority?.quarantineDatabase) { + throw new Error('SQLite profile-state quarantine is unavailable') + } + return authority.quarantineDatabase(quarantineRoot, reason) + } } // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below -export interface Store - extends - WriteSchedulingOperations, - PrimaryStateWriteOperations, - ProjectCollectionOperations, - RepoLifecycleOperations, - MobileTabSelectionPersistence, - SparsePresetPersistence, - AutomationPersistence, - MetadataLineageOperations, - ProfilePreferences, - SessionHostPartitionOperations, - SessionSnapshotOperations, - PtyBindingPersistenceOperations, - SshProfileOperations, - RetiredWorktreeNamePersistence, - SshLeaseRecoveryOperations, - WriteFlushBarrierOperations {} +export interface Store extends StoreDomainOperations {} for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 5b7f90cbb8f..96834ffbcf9 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -111,7 +111,19 @@ export function setLocalWorkspaceSession( if (deferSnapshotFiles) { enqueueTerminalScrollbackSnapshotWork(owner, prior, session) } - scheduleSave(context.scheduling) + if ( + remappedAcknowledgements.changed || + remappedActivityCutoffs.changed || + remappedManualUnread.changed + ) { + // UI remaps include protected fields, so keep the complete serializer boundary. + scheduleSave(context.scheduling) + } else { + scheduleSave( + context.scheduling, + remappedLeases.changed ? ['workspaceSession', 'sshRemotePtyLeases'] : ['workspaceSession'] + ) + } } export function enqueueTerminalScrollbackSnapshotWork( diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts index 21fea4ac15c..9515986f86a 100644 --- a/src/main/persistence/loading-store/worktree-identity-metadata.ts +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -15,6 +15,13 @@ import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' type MetadataRuntime = Pick +/** Storage rows changed together by host-qualified metadata writes. */ +export const WORKTREE_METADATA_DOMAINS = [ + 'worktreeMeta', + 'worktreeMetaByIdentity', + 'worktreeIdentityAliases' +] as const + /** Select one readable row without discarding competing alias candidates. */ function resolveAliasIdentityKey(state: PersistedState, alias: string): string | undefined { const identityKeys = state.worktreeIdentityAliases?.[alias] ?? [] @@ -161,7 +168,7 @@ export function getWorktreeMetaForHost( const alias = composeWorktreeHostIdentity(executionHostId, worktreeId) const identityKey = resolveAliasIdentityKey(state, alias) if (changed) { - scheduleSave(scheduling) + scheduleSave(scheduling, WORKTREE_METADATA_DOMAINS) } if (identityKey) { return state.worktreeMetaByIdentity?.[identityKey] @@ -238,6 +245,6 @@ export function setWorktreeMetaForHost( if (!legacy || legacy.hostId === executionHostId) { state.worktreeMeta[worktreeId] = updated } - scheduleSave(scheduling) + scheduleSave(scheduling, WORKTREE_METADATA_DOMAINS) return updated } diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index c08d4367989..aec8a6d5b5f 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -6,6 +6,7 @@ import { getGithubCacheFile } from './user-data-path' import type { StoreRuntimeState } from './store-runtime-state' import type { PrimaryStateWriteOperations } from './primary-state-writes' import { enqueueWrite } from './primary-state-writes' +import { drainProfileStateOperations, runProfileStateFlush } from './profile-state-flush-lifetime' type WriteFlushBarrierOperationsRuntime = Pick< StoreRuntimeState, @@ -17,6 +18,10 @@ type WriteFlushBarrierOperationsRuntime = Pick< | 'githubCacheGeneration' | 'lastDurableWriteGeneration' | 'pendingGithubCacheWrite' + | 'pendingProfileFlushes' + | 'pendingProfileMaintenance' + | 'profileMaintenancePending' + | 'profileStateAuthority' | 'quitFlushPromise' | 'quitFlushStarted' | 'staleGithubCacheTempCleanup' @@ -30,6 +35,7 @@ const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') type WriteFlushBarrierOperationsContext = { runtime: WriteFlushBarrierOperationsRuntime writes: PrimaryStateWriteOperations + bestEffortFinalFlush?: Promise } export class WriteFlushBarrierOperations { @@ -40,136 +46,183 @@ export class WriteFlushBarrierOperations { } flush(): void { - this[writeFlushBarrierOperationsContext].runtime.automationListProjectionCache = null - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + const { runtime, writes } = this[writeFlushBarrierOperationsContext] + runtime.automationListProjectionCache = null + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return + } + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writeGeneration++ + void flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch((error) => + console.error('[persistence] Failed to flush state:', error) + ) return } try { - this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + writes.flushOrThrow() } catch (err) { console.error('[persistence] Failed to flush state:', err) } try { - this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + writes.flushActiveViewPreferenceOrThrow() } catch (err) { console.error('[active-view] Failed to flush preference:', err) } writeGithubCacheSnapshotSync(this) } - flushAsync(): Promise { - if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + flushAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { + const context = this[writeFlushBarrierOperationsContext] + context.bestEffortFinalFlush ??= this.flushFinalOrThrowAsync(options).catch((error) => + console.error('[persistence] Failed to flush final state:', error) + ) + return context.bestEffortFinalFlush + } + + flushFinalOrThrowAsync(options: { exportJsonCompatibility?: boolean } = {}): Promise { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.quitFlushPromise) { + return runtime.quitFlushPromise } - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true - this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( - this, - true - ).catch(() => {}) - return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + runtime.quitFlushStarted = true + runtime.quitFlushPromise = Promise.resolve(runtime.pendingProfileMaintenance) + .catch((error: unknown) => { + // Failed maintenance may re-admit unchanged storage before this final checkpoint. + if (runtime.profileMaintenancePending || runtime.writesFrozen) { + throw error + } + }) + .then(async () => { + if (runtime.profileMaintenancePending) { + return + } + await drainProfileStateOperations([ + ...runtime.pendingProfileFlushes, + runtime.profileStateAuthority?.drainBackups?.(true) + ]) + await flushCurrentStateAsync(this, { final: true }) + if (options.exportJsonCompatibility) { + await runtime.profileStateAuthority?.writeJsonCompatibilityExportAsync?.(runtime.dataFile) + } + }) + .finally(async () => { + if (runtime.profileStateAuthority?.asynchronous) { + runtime.writesFrozen = true + await runtime.profileStateAuthority.close() + } + }) + return runtime.quitFlushPromise } flushPendingAsync(): Promise { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.quitFlushStarted || runtime.profileMaintenancePending) { + return Promise.resolve() + } // Best-effort callers must not livelock while the live app keeps mutating state. - return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + return flushCurrentStateAsync(this, { drainToStableGeneration: false }).catch(() => {}) } flushPendingOrThrowAsync( options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} ): Promise { - if ( - this[writeFlushBarrierOperationsContext].runtime.writesFrozen || - this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted - ) { + const { runtime } = this[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { return Promise.reject(new Error('Cannot flush while persistence is finalized')) } - return flushCurrentStateAsync( - this, - false, - options.signal, - options.drainToStableGeneration, - true - ) + return flushCurrentStateAsync(this, { + signal: options.signal, + drainToStableGeneration: options.drainToStableGeneration, + requireInitialGenerationDurable: true + }) } } export async function flushDurableStateOrThrowAsync( owner: WriteFlushBarrierOperations ): Promise { - if ( - owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || - owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted - ) { + const { runtime, writes } = owner[writeFlushBarrierOperationsContext] + if (runtime.writesFrozen || runtime.profileMaintenancePending || runtime.quitFlushStarted) { throw new Error('Cannot flush while persistence is finalized') } - for (;;) { - if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) - owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + return runProfileStateFlush(runtime, async () => { + for (;;) { + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + await enqueueWrite(writes) + if (generation === runtime.writeGeneration) { + break + } } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break - } - } + }) } export async function flushCurrentStateAsync( owner: WriteFlushBarrierOperations, - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false + { + final = false, + signal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false, + fullCheckpoint = final + }: { + final?: boolean + signal?: AbortSignal + drainToStableGeneration?: boolean + requireInitialGenerationDurable?: boolean + fullCheckpoint?: boolean + } ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable - ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) - owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null - } - owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null - const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration - try { - await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) - } catch (error) { - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( - signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - throw error - } - await (final - ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() - : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + const { runtime, writes } = owner[writeFlushBarrierOperationsContext] + return runProfileStateFlush(runtime, async () => { + const requiredDurableGeneration = requireInitialGenerationDurable + ? runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + runtime.writeTimer = null + } + runtime.firstPendingSaveAt = null + const generation = runtime.writeGeneration + try { + await enqueueWrite(writes, { + fullCheckpoint, signal - )) - await writeGithubCacheSnapshotAsync(owner, final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= - requiredDurableGeneration - ) { + }) + } finally { + await (final + ? runtime.activeViewPreference.flushAsync() + : runtime.activeViewPreference.flushPendingAsync(signal)) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (final || runtime.profileMaintenancePending) { + await runtime.profileStateAuthority?.drainBackups?.(true) + } + } + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + runtime.lastDurableWriteGeneration >= requiredDurableGeneration + ) { + break + } + continue + } + if (generation === runtime.writeGeneration) { break } - continue } - if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { - break - } - } + }) } export async function writeGithubCacheSnapshotAsync( @@ -177,39 +230,28 @@ export async function writeGithubCacheSnapshotAsync( drainToStableGeneration = true, signal?: AbortSignal ): Promise { - if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + const { runtime } = owner[writeFlushBarrierOperationsContext] + if (!runtime.githubCacheDirty) { return } - const previousWrite = - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? - owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const previousWrite = runtime.pendingGithubCacheWrite ?? runtime.staleGithubCacheTempCleanup const nextWrite = previousWrite .then(async () => { - while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + while (runtime.githubCacheDirty) { if (signal?.aborted) { throw new Error('GitHub cache flush aborted') } - const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - const cacheFile = getGithubCacheFile( - owner[writeFlushBarrierOperationsContext].runtime.dataFile - ) + const generation = runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile(runtime.dataFile) const tmpFile = durableWriteTempPath(cacheFile) let renamed = false try { - await writeFile( - tmpFile, - JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), - 'utf-8' - ) - if ( - generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - ) { + await writeFile(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8') + if (generation === runtime.githubCacheGeneration) { await rename(tmpFile, cacheFile) renamed = true - if ( - generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration - ) { - owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + if (generation === runtime.githubCacheGeneration) { + runtime.githubCacheDirty = false } } } finally { @@ -229,34 +271,31 @@ export async function writeGithubCacheSnapshotAsync( console.warn('[persistence] Failed to write github cache snapshot:', err) }) .finally(() => { - if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + if (runtime.pendingGithubCacheWrite === nextWrite) { + runtime.pendingGithubCacheWrite = null } }) - owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + runtime.pendingGithubCacheWrite = nextWrite await nextWrite } export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { - if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + const { runtime } = owner[writeFlushBarrierOperationsContext] + if (!runtime.githubCacheDirty) { return } - if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + if (runtime.pendingGithubCacheWrite) { void writeGithubCacheSnapshotAsync(owner) return } - const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) - const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile(runtime.dataFile) + const generation = runtime.githubCacheGeneration const tmpFile = durableWriteTempPath(cacheFile) try { - writeFileSync( - tmpFile, - JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), - 'utf-8' - ) + writeFileSync(tmpFile, JSON.stringify(runtime.state.githubCache), 'utf-8') renameSync(tmpFile, cacheFile) - if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { - owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + if (generation === runtime.githubCacheGeneration) { + runtime.githubCacheDirty = false } } catch (err) { try { diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 0301da34a7e..30019c5c456 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -9,11 +9,14 @@ type WriteSchedulingOperationsRuntime = Pick< StoreRuntimeState, | 'activeViewPreference' | 'automationListProjectionCache' + | 'dirtyProfileStateDomains' | 'firstPendingSaveAt' | 'pendingWrite' + | 'profileMaintenancePending' | 'quitFlushStarted' | 'writeGeneration' | 'writeTimer' + | 'writesFrozen' > const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') @@ -30,36 +33,44 @@ export class WriteSchedulingOperations { } async waitForPendingWrite(): Promise { - await Promise.all([ - this[writeSchedulingOperationsContext].runtime.pendingWrite, - this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() - ]) + const { runtime } = this[writeSchedulingOperationsContext] + await Promise.all([runtime.pendingWrite, runtime.activeViewPreference.waitForPendingWrite()]) } } -export function scheduleSave(owner: WriteSchedulingOperations): void { - owner[writeSchedulingOperationsContext].runtime.automationListProjectionCache = null - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { +export function scheduleSave( + owner: WriteSchedulingOperations, + dirtyDomains?: readonly string[] +): void { + const { runtime, writes } = owner[writeSchedulingOperationsContext] + runtime.automationListProjectionCache = null + const trackedDomains = runtime.dirtyProfileStateDomains + if (dirtyDomains === undefined) { + runtime.dirtyProfileStateDomains = null + } else if (trackedDomains !== null) { + for (const domain of dirtyDomains) { + trackedDomains.add(domain) + } + } + // A timer admitted after the final snapshot could outlive the awaited shutdown work. + if (runtime.quitFlushStarted || runtime.profileMaintenancePending) { return } - owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 - const now = Date.now() - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now - if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { - clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + runtime.writeGeneration += 1 + if (runtime.writesFrozen) { + return } - const untilMaxWait = Math.max( - 0, - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now - ) + const now = Date.now() + runtime.firstPendingSaveAt ??= now + if (runtime.writeTimer) { + clearTimeout(runtime.writeTimer) + } + const untilMaxWait = Math.max(0, runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now) const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) - owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { - owner[writeSchedulingOperationsContext].runtime.writeTimer = null - owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null - void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + runtime.writeTimer = setTimeout(() => { + runtime.writeTimer = null + runtime.firstPendingSaveAt = null + void enqueueWrite(writes, { skipIfClean: true }).catch(() => {}) }, delay) } diff --git a/src/main/persistence/profile-state-cutover-fixture.test.ts b/src/main/persistence/profile-state-cutover-fixture.test.ts new file mode 100644 index 00000000000..1d1c937e8c6 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-fixture.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from './profile-state-cutover-fixture' +import { + exportProfileStateJson, + importProfileStateJson +} from './profile-state/profile-state-documents' +import { + openProfileStateDatabase, + profileStateDatabaseFile +} from './profile-state/profile-state-database' +import { Store } from './loading-store/store' +import { createStore, dataFile, testState, writeDataFile } from '../persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('profile-state cutover fixture', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-cutover-fixture-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps object insertion order out of semantic comparisons while preserving array order', () => { + expect(canonicalProfileStateJson({ b: 2, a: { d: 4, c: 3 }, rows: ['first', 'second'] })).toBe( + canonicalProfileStateJson({ rows: ['first', 'second'], a: { c: 3, d: 4 }, b: 2 }) + ) + expect(canonicalProfileStateJson({ rows: ['first', 'second'] })).not.toBe( + canonicalProfileStateJson({ rows: ['second', 'first'] }) + ) + expect(canonicalProfileStateJson({ missing: undefined, nullable: null })).toBe( + canonicalProfileStateJson({ nullable: null }) + ) + }) + + it('loads the cross-domain fixture through the legacy Store contract', () => { + const fixture = buildProfileStateCutoverFixture(testState.dir) + writeDataFile(fixture) + + const store = createStore() + store.flushOrThrow() + store.freezeWrites() + + expect(store.getRepos().map((repo) => repo.id)).toEqual(['repo-local', 'repo-remote']) + expect(store.getProjects().map((project) => project.id)).toEqual([ + 'repo:repo-local', + 'repo:repo-remote' + ]) + expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([ + 'repo-local', + 'repo-remote' + ]) + expect(store.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + instanceId: 'instance-local', + linkedPR: 42, + comment: 'Preserve this comment' + }) + expect(store.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(store.listAutomations().map((automation) => automation.id)).toEqual([ + 'automation-fixture' + ]) + expect(store.listAutomationRuns('automation-fixture').map((run) => run.id)).toEqual([ + 'automation-run-fixture' + ]) + expect(store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + + const persisted: unknown = JSON.parse(readFileSync(dataFile(), 'utf-8')) + expect(persisted).toHaveProperty('futureTopLevelExtension', { + keep: 'forward-compatible', + nullable: null + }) + expect(persisted).toHaveProperty( + 'settings.opencodeSessionCookie', + fixture.settings.opencodeSessionCookie + ) + + const reloaded = createStore() + reloaded.freezeWrites() + expect(reloaded.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(reloaded.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(reloaded.listAutomationRuns('automation-fixture')[0]?.outputSnapshot?.content).toBe( + 'fixture output' + ) + expect(reloaded.getSettings().opencodeSessionCookie).toBe('fixture-secret') + }) + + it('imports and exports through the real Store normalization and secret boundaries', () => { + const fixture = buildProfileStateCutoverFixture(testState.dir) + writeDataFile(fixture) + + const source = createStore() + source.flushOrThrow() + const prepared = source.prepareProfileStateExport() + const databaseDirectory = mkdtempSync(join(testState.dir, 'profile-state-db-')) + const opened = openProfileStateDatabase( + profileStateDatabaseFile(databaseDirectory), + 'profile-cutover' + ) + try { + importProfileStateJson(opened.db, prepared.json, { now: () => 456 }) + const exported = exportProfileStateJson(opened.db) + prepared.commit() + + const candidateDirectory = mkdtempSync(join(testState.dir, 'candidate-')) + const candidate = new Store({ + dataFile: join(candidateDirectory, 'orca-data.json'), + serializedState: exported + }) + + expect(candidate.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(candidate.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(candidate.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(candidate.listAutomationRuns('automation-fixture')[0]?.outputSnapshot?.content).toBe( + 'fixture output' + ) + expect(candidate.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + linkedPR: 42, + comment: 'Preserve this comment' + }) + + const persisted: unknown = JSON.parse(candidate.prepareProfileStateExport().json) + expect(persisted).toHaveProperty('futureTopLevelExtension', fixture.futureTopLevelExtension) + expect(persisted).toHaveProperty('settings.opencodeSessionCookie') + expect(existsSync(join(candidateDirectory, 'orca-data.json'))).toBe(false) + } finally { + opened.db.close() + } + }) + + it('fails closed for an invalid serialized import instead of reading a fallback file', () => { + writeDataFile(buildProfileStateCutoverFixture(testState.dir)) + + expect(() => new Store({ dataFile: dataFile(), serializedState: '{not valid json' })).toThrow( + 'Failed to load imported profile state' + ) + }) +}) diff --git a/src/main/persistence/profile-state-cutover-fixture.ts b/src/main/persistence/profile-state-cutover-fixture.ts new file mode 100644 index 00000000000..9d3bdf7ea39 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-fixture.ts @@ -0,0 +1,291 @@ +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { PersistedState } from '../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import type { TerminalTab, TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +const LOCAL_WORKTREE_ID = 'repo-local::/fixture/local' +const REMOTE_WORKTREE_ID = 'repo-remote::/fixture/remote' +const LOCAL_TAB_ID = 'tab-local' +const REMOTE_TAB_ID = 'tab-remote' +const LOCAL_LEAF_ID = 'leaf-local' +const REMOTE_LEAF_ID = 'leaf-remote' +const REMOTE_HOST_ID = 'ssh:build-host' + +export type ProfileStateCutoverFixture = PersistedState & { + futureTopLevelExtension: { + keep: string + nullable: null + } +} + +function fixtureRepo(overrides: Partial): Repo { + return { + id: 'repo-local', + path: '/fixture/local', + displayName: 'Fixture local', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function fixtureProject(overrides: Partial): Project { + return { + id: 'project-fixture', + displayName: 'Fixture project', + badgeColor: '#737373', + sourceRepoIds: ['repo-local', 'repo-remote'], + createdAt: 1, + updatedAt: 2, + ...overrides + } +} + +function fixtureSetup(overrides: Partial): ProjectHostSetup { + return { + id: 'setup-local', + projectId: 'project-fixture', + hostId: 'local', + repoId: 'repo-local', + path: '/fixture/local', + displayName: 'Fixture local', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 2, + ...overrides + } +} + +function fixtureTab(overrides: Partial): TerminalTab { + return { + id: LOCAL_TAB_ID, + ptyId: 'pty-local', + worktreeId: LOCAL_WORKTREE_ID, + title: 'Fixture terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function fixtureLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId }, + titlesByLeafId: { [leafId]: 'Fixture pane' } + } +} + +function fixtureSession(args: { + repoId: string + worktreeId: string + tab: TerminalTab + layout: TerminalLayoutSnapshot +}): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: args.repoId, + activeWorktreeId: args.worktreeId, + activeTabId: args.tab.id, + tabsByWorktree: { [args.worktreeId]: [args.tab] }, + terminalLayoutsByTabId: { [args.tab.id]: args.layout }, + activeTabIdByWorktree: { [args.worktreeId]: args.tab.id }, + activeWorktreeIdsOnShutdown: [args.worktreeId], + browserUrlHistory: [ + { + url: 'https://fixture.test/é😀', + normalizedUrl: 'https://fixture.test/é😀', + title: 'Fixture', + lastVisitedAt: 3, + visitCount: 2 + } + ] + } +} + +function fixtureAutomation(): Automation { + return { + id: 'automation-fixture', + name: 'Fixture automation', + prompt: 'Keep the fixture valid', + precheck: null, + agentId: 'claude', + projectId: 'project-fixture', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: LOCAL_WORKTREE_ID, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 10, + enabled: true, + nextRunAt: 20, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 5, + createdAt: 1, + updatedAt: 2 + } +} + +function fixtureAutomationRun(): AutomationRun { + return { + id: 'automation-run-fixture', + automationId: 'automation-fixture', + title: 'Fixture run', + scheduledFor: 20, + status: 'completed', + trigger: 'manual', + workspaceId: LOCAL_WORKTREE_ID, + workspaceDisplayName: 'Fixture local', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'terminal-fixture', + terminalPaneKey: 'pane-fixture', + terminalPtyId: 'pty-local', + outputSnapshot: { + format: 'plain_text', + content: 'fixture output', + capturedAt: 21, + truncated: false + }, + precheckResult: null, + usage: null, + error: null, + startedAt: 20, + dispatchedAt: 20, + createdAt: 20, + runNumber: 1 + } +} + +function fixtureWorktreeMeta(): WorktreeMeta { + const now = Date.now() + return { + instanceId: 'instance-local', + projectId: 'project-fixture', + hostId: 'local', + projectHostSetupId: 'setup-local', + displayName: 'Fixture local', + comment: 'Preserve this comment', + linkedIssue: null, + linkedPR: 42, + linkedLinearIssue: null, + isArchived: false, + isUnread: true, + isPinned: true, + sortOrder: 1, + lastActivityAt: now, + createdAt: now + } +} + +export function buildProfileStateCutoverFixture( + homedir = '/fixture/home' +): ProfileStateCutoverFixture { + const localTab = fixtureTab({}) + const remoteTab = fixtureTab({ + id: REMOTE_TAB_ID, + ptyId: 'pty-remote', + worktreeId: REMOTE_WORKTREE_ID + }) + const localSession = fixtureSession({ + repoId: 'repo-local', + worktreeId: LOCAL_WORKTREE_ID, + tab: localTab, + layout: fixtureLayout(LOCAL_LEAF_ID, 'pty-local') + }) + const remoteSession = fixtureSession({ + repoId: 'repo-remote', + worktreeId: REMOTE_WORKTREE_ID, + tab: remoteTab, + layout: fixtureLayout(REMOTE_LEAF_ID, 'pty-remote') + }) + const state = getDefaultPersistedState(homedir) + state.repos = [ + fixtureRepo({}), + fixtureRepo({ + id: 'repo-remote', + path: '/fixture/remote', + displayName: 'Fixture remote', + connectionId: 'build-host', + executionHostId: REMOTE_HOST_ID + }) + ] + state.projects = [fixtureProject({})] + state.projectHostSetups = [ + fixtureSetup({}), + fixtureSetup({ + id: 'setup-remote', + hostId: REMOTE_HOST_ID, + repoId: 'repo-remote', + path: '/fixture/remote', + displayName: 'Fixture remote', + connectionId: 'build-host', + executionHostId: REMOTE_HOST_ID + }) + ] + state.worktreeMeta = { + [LOCAL_WORKTREE_ID]: fixtureWorktreeMeta(), + [REMOTE_WORKTREE_ID]: { + ...fixtureWorktreeMeta(), + instanceId: 'instance-remote', + hostId: REMOTE_HOST_ID, + projectHostSetupId: 'setup-remote', + displayName: 'Fixture remote' + } + } + state.workspaceSession = localSession + state.workspaceSessionsByHostId = { [REMOTE_HOST_ID]: remoteSession } + state.sshTargets = [ + { + id: 'build-host', + label: 'Build host', + host: 'build.example.test', + port: 22, + username: 'builder', + source: 'manual', + generation: 3 + } + ] + state.automations = [fixtureAutomation()] + state.automationRuns = [fixtureAutomationRun()] + state.settings = { + ...state.settings, + opencodeSessionCookie: Buffer.from('vitest-sealed:fixture-secret', 'utf-8').toString('base64') + } + state.ui = { ...state.ui, activeView: 'tasks', browserKagiSessionLink: null } + return Object.assign(state, { + futureTopLevelExtension: { keep: 'forward-compatible', nullable: null } + }) +} + +function sortForStableJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortForStableJson) + } + if (!value || typeof value !== 'object') { + return value + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortForStableJson(child)]) + ) +} + +/** Compares state semantics while ignoring object insertion order and preserving array order. */ +export function canonicalProfileStateJson(state: unknown): string { + return JSON.stringify(sortForStableJson(state)) +} diff --git a/src/main/persistence/profile-state-cutover-soak.test.ts b/src/main/persistence/profile-state-cutover-soak.test.ts new file mode 100644 index 00000000000..d31db671d28 --- /dev/null +++ b/src/main/persistence/profile-state-cutover-soak.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from './profile-state-cutover-fixture' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsInProfileState, + type ProfileStateOfflineLocation +} from './profile-state/profile-state-offline-settings' +import { + createProfileStateStore, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state/profile-state-store-factory' +import { profileStateDatabaseFile } from './profile-state/profile-state-database' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const { Store } = await import('./loading-store/store') + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +type MigratedProfile = { + options: ProfileStateStoreFactoryOptions + location: ProfileStateOfflineLocation + first: ProfileStateStoreFactoryResult +} + +function createProfile(profileId: string, theme: string): MigratedProfile { + const directory = mkdtempSync(join(tmpdir(), `orca-profile-state-cutover-${profileId}-`)) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture(directory) + writeFileSync( + dataFile, + JSON.stringify({ + ...fixture, + settings: { ...fixture.settings, theme }, + // Keep enough unrelated data to make repeated complete-document commits meaningful. + soakExtension: { bytes: 'x'.repeat(96 * 1024), nullable: null } + }) + ) + const options: ProfileStateStoreFactoryOptions = { + dataFile, + databaseFile, + profileId, + authorityMode: 'sqlite-candidate' + } + const first = createProfileStateStore(options) + expect(first.backend).toBe('sqlite') + expect(first.migrated).toBe(true) + return { + options, + location: { dataFile, databaseFile, profileId }, + first + } +} + +function removeLegacyJson(profile: MigratedProfile): void { + profile.first.store.freezeWrites() + rmSync(profile.options.dataFile, { force: true }) + expect(existsSync(profile.options.dataFile)).toBe(false) + expect(existsSync(profile.options.databaseFile)).toBe(true) +} + +function reopen(profile: MigratedProfile): ProfileStateStoreFactoryResult { + return createProfileStateStore(profile.options) +} + +function exportJson(store: ProfileStateStoreFactoryResult['store']): unknown { + return JSON.parse(store.prepareProfileStateExport().json) +} + +describe('profile-state candidate cutover soak', () => { + it('keeps the retained JSON export usable for legacy rollback', () => { + const profile = createProfile('rollback-window', 'light') + const retainedJson = readFileSync(profile.options.dataFile) + + profile.first.store.updateSettings({ theme: 'dark', terminalFontSize: 123 }) + profile.first.store.flushOrThrow() + expect(readFileSync(profile.options.dataFile)).toEqual(retainedJson) + + const legacyStore = new Store({ dataFile: profile.options.dataFile }) + expect(legacyStore.getSettings().theme).toBe('light') + expect(legacyStore.getSettings().terminalFontSize).not.toBe(123) + legacyStore.freezeWrites() + profile.first.store.freezeWrites() + }) + + it('migrates a complete profile, removes JSON, and survives restart/domain/offline churn', () => { + const profile = createProfile('soak-primary', 'light') + const initial = exportJson(profile.first.store) + const folderGroup = profile.first.store.createProjectGroup({ + name: 'Fixture folders', + createdFrom: 'manual', + parentPath: '/fixture/folder', + connectionId: 'build-host' + }) + const folderWorkspace = profile.first.store.createFolderWorkspace({ + projectGroupId: folderGroup.id, + name: 'Remote folder fixture', + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + const remoteAutomation = profile.first.store.createAutomation({ + name: 'Remote fixture automation', + prompt: 'Keep the remote fixture valid', + agentId: 'claude', + projectId: 'repo-remote', + workspaceMode: 'existing', + workspaceId: 'repo-remote::/fixture/remote', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 10, + enabled: true, + missedRunGraceMinutes: 5 + }) + profile.first.store.createAutomationRun(remoteAutomation, 30, 'manual') + profile.first.store.flushOrThrow() + removeLegacyJson(profile) + + for (let round = 0; round < 8; round += 1) { + const reopened = reopen(profile) + const currentSession = reopened.store.getWorkspaceSession() + const currentAutomation = reopened.store.listAutomations()[0] + if (!currentAutomation) { + throw new Error('cutover fixture lost its automation') + } + + reopened.store.updateSettings({ + theme: round % 2 === 0 ? 'dark' : 'light', + terminalFontSize: reopened.store.getSettings().terminalFontSize + 1 + }) + reopened.store.updateUI({ activeView: round % 2 === 0 ? 'tasks' : 'terminal' }) + reopened.store.patchWorkspaceSession({ + browserUrlHistory: [ + ...(currentSession.browserUrlHistory ?? []), + { + url: `https://fixture.test/soak/${round}`, + normalizedUrl: `https://fixture.test/soak/${round}`, + title: `Soak ${round}`, + lastVisitedAt: round + 10, + visitCount: 1 + } + ] + }) + reopened.store.setWorktreeMeta('repo-local::/fixture/local', { + comment: `soak-${round}`, + linkedPR: 100 + round + }) + reopened.store.createAutomationRun(currentAutomation, 100 + round, 'manual') + reopened.store.flushOrThrow() + reopened.store.freezeWrites() + + const restarted = reopen(profile) + expect(restarted.store.getWorkspaceSession().activeTabId).toBe('tab-local') + expect(restarted.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(restarted.store.getFolderWorkspace(folderWorkspace.id)).toMatchObject({ + name: 'Remote folder fixture', + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + expect(restarted.store.getWorktreeMeta('repo-local::/fixture/local')).toMatchObject({ + comment: `soak-${round}`, + linkedPR: 100 + round + }) + expect(restarted.store.listAutomationRuns('automation-fixture').length).toBeGreaterThan( + round + 1 + ) + const persistedRemoteAutomation = restarted.store + .listAutomations() + .find((automation) => automation.id === remoteAutomation.id) + expect(persistedRemoteAutomation).toMatchObject({ + executionTargetType: 'ssh', + executionTargetId: 'build-host', + schedulerOwner: 'ssh_bridge', + workspaceId: 'repo-remote::/fixture/remote' + }) + expect( + restarted.store + .listAutomationRuns(remoteAutomation.id) + .some((run) => run.trigger === 'manual') + ).toBe(true) + restarted.store.freezeWrites() + } + + const beforeOffline = readAgentHookSettingsFromProfileState(profile.location) + const offlineUpdate = updateAgentHookSettingsInProfileState(profile.location, false) + expect(offlineUpdate.settingsPath).toBe(profile.options.databaseFile) + expect(readAgentHookSettingsFromProfileState(profile.location).agentStatusHooksEnabled).toBe( + false + ) + + const afterOffline = reopen(profile) + const persisted = exportJson(afterOffline.store) + expect(afterOffline.store.getSettings().agentStatusHooksEnabled).toBe(false) + expect(afterOffline.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(afterOffline.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(afterOffline.store.getFolderWorkspace(folderWorkspace.id)).toMatchObject({ + folderPath: '/fixture/folder', + connectionId: 'build-host' + }) + expect(afterOffline.store.listAutomationRuns('automation-fixture').length).toBeGreaterThan(8) + expect( + afterOffline.store + .listAutomations() + .find((automation) => automation.id === remoteAutomation.id) + ).toMatchObject({ + executionTargetType: 'ssh', + executionTargetId: 'build-host', + schedulerOwner: 'ssh_bridge' + }) + expect(afterOffline.store.listAutomationRuns(remoteAutomation.id)).toHaveLength(1) + expect(persisted).toHaveProperty('soakExtension', { + bytes: 'x'.repeat(96 * 1024), + nullable: null + }) + expect(beforeOffline.agentStatusHooksEnabled).toBe(true) + expect(existsSync(profile.options.dataFile)).toBe(false) + expect(canonicalProfileStateJson(persisted)).not.toBe(canonicalProfileStateJson(initial)) + afterOffline.store.freezeWrites() + }) + + it('switches between independent SQLite profiles without crossing state', () => { + const first = createProfile('switch-first', 'dark') + const second = createProfile('switch-second', 'light') + removeLegacyJson(first) + removeLegacyJson(second) + const firstInitial = reopen(first) + const firstInitialFontSize = firstInitial.store.getSettings().terminalFontSize + firstInitial.store.freezeWrites() + const secondInitial = reopen(second) + const secondInitialFontSize = secondInitial.store.getSettings().terminalFontSize + secondInitial.store.freezeWrites() + + for (let round = 0; round < 6; round += 1) { + const active = round % 2 === 0 ? first : second + const inactive = active === first ? second : first + const activeStore = reopen(active) + activeStore.store.updateSettings({ + theme: active === first ? 'dark' : 'light', + terminalFontSize: (active === first ? 100 : 200) + round + }) + activeStore.store.flushOrThrow() + activeStore.store.freezeWrites() + + const inactiveStore = reopen(inactive) + expect(inactiveStore.store.getSettings().terminalFontSize).toBe( + round === 0 + ? inactive === first + ? firstInitialFontSize + : secondInitialFontSize + : (inactive === first ? 100 : 200) + round - 1 + ) + expect(inactiveStore.store.getWorkspaceSession().activeTabId).toBe('tab-local') + inactiveStore.store.freezeWrites() + } + + const firstFinal = reopen(first) + const secondFinal = reopen(second) + expect(firstFinal.store.getSettings().terminalFontSize).toBe(104) + expect(secondFinal.store.getSettings().terminalFontSize).toBe(205) + expect(firstFinal.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(secondFinal.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + firstFinal.store.freezeWrites() + secondFinal.store.freezeWrites() + }) + + it('allows one stale complete-document writer and rejects the rest', () => { + const profile = createProfile('soak-cas', 'light') + removeLegacyJson(profile) + const staleWriters = Array.from({ length: 7 }, () => reopen(profile)) + + for (const [index, writer] of staleWriters.entries()) { + writer.store.updateSettings({ + theme: index % 2 === 0 ? 'dark' : 'light', + terminalFontSize: 100 + index + }) + } + + let commits = 0 + let conflicts = 0 + for (const writer of staleWriters) { + try { + writer.store.flushOrThrow() + commits += 1 + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'profile-state-revision-conflict' + ) { + conflicts += 1 + } else { + throw error + } + } finally { + writer.store.freezeWrites() + } + } + + expect(commits).toBe(1) + expect(conflicts).toBe(staleWriters.length - 1) + const verifier = reopen(profile) + expect(verifier.store.getSettings().terminalFontSize).toBe(100) + expect(verifier.store.getSettings().opencodeSessionCookie).toBe('fixture-secret') + expect(verifier.store.getWorkspaceSession('ssh:build-host').activeTabId).toBe('tab-remote') + expect(readFileSync(profile.options.databaseFile)).toBeTruthy() + verifier.store.freezeWrites() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access-identity.test.ts b/src/main/persistence/profile-state/profile-state-access-identity.test.ts new file mode 100644 index 00000000000..f0e29776c4d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.test.ts @@ -0,0 +1,30 @@ +import * as fs from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' + +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) + +const platform = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + vi.restoreAllMocks() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } +}) + +it.each([ + ['8de277067b3544d4b65c267d0edab928\n', '8de277067b3544d4b65c267d0edab928'], + ['00000000000000000000000000000000', null], + ['uninitialized\n', null], + ['invalid-machine-id', null], + ['', null] +] as const)('validates the Linux machine identity %j', async (contents, expected) => { + vi.resetModules() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const read = fs.readFileSync + vi.spyOn(fs, 'readFileSync').mockImplementation((path, options) => + path === '/etc/machine-id' ? contents : read(path, options) + ) + const { profileStateAccessMachineIdentity } = await import('./profile-state-access-identity') + expect(profileStateAccessMachineIdentity()).toBe(expected) +}) diff --git a/src/main/persistence/profile-state/profile-state-access-identity.ts b/src/main/persistence/profile-state/profile-state-access-identity.ts new file mode 100644 index 00000000000..2d5d3d1fcff --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-identity.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs' +import { runProcessSync } from '../../../shared/child-process/run-process' +import { parseLinuxProcStartTicks } from '../../daemon/daemon-process-start-time' +import { getPsProcessIdentity } from '../../daemon/daemon-process-identity-query' + +let bootIdentity: string | null | undefined +let machineIdentity: string | null | undefined +let ownProcessIdentity: string | null | undefined + +/** A boot change proves exit only when the record belongs to this machine. */ +export function profileStateAccessMachineIdentity(): string | null { + if (machineIdentity !== undefined) { + return machineIdentity + } + machineIdentity = null + try { + if (process.platform === 'linux') { + const value = readFileSync('/etc/machine-id', 'utf8').trim() + machineIdentity = /^[a-f0-9]{32}$/.test(value) && !/^0+$/.test(value) ? value : null + } else if (process.platform === 'darwin') { + const result = runProcessSync({ + program: '/usr/sbin/sysctl', + args: ['-n', 'kern.hostuuid'], + timeoutMs: 1_000, + maxOutputBytes: 1024 + }) + machineIdentity = result.code === 0 ? result.stdout.trim() || null : null + } + } catch { + // Missing machine identity cannot establish ownership across a reboot. + } + return machineIdentity +} + +/** A kernel boot UUID survives hostname changes without conflating machines sharing a profile. */ +export function profileStateAccessBootIdentity(): string | null { + if (bootIdentity !== undefined) { + return bootIdentity + } + bootIdentity = null + try { + if (process.platform === 'linux') { + bootIdentity = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || null + } else if (process.platform === 'darwin') { + const result = runProcessSync({ + program: '/usr/sbin/sysctl', + args: ['-n', 'kern.bootsessionuuid'], + timeoutMs: 1_000, + maxOutputBytes: 1024 + }) + bootIdentity = result.code === 0 ? result.stdout.trim() || null : null + } + } catch { + // Unavailable identity leaves the conservative hostname and PID checks in force. + } + return bootIdentity +} + +export function profileStateAccessProcessIdentity(pid: number): string | null { + if (pid !== process.pid) { + return readProcessIdentity(pid) + } + if (ownProcessIdentity === undefined) { + ownProcessIdentity = readProcessIdentity(pid) + } + return ownProcessIdentity +} + +function readProcessIdentity(pid: number): string | null { + if (process.platform === 'linux') { + try { + const ticks = parseLinuxProcStartTicks(readFileSync(`/proc/${pid}/stat`, 'utf8')) + return Number.isSafeInteger(ticks) && ticks >= 0 ? `linux-start-ticks:${ticks}` : null + } catch { + return null + } + } + if (process.platform !== 'darwin') { + return null + } + const startedAtMs = getPsProcessIdentity(pid, { utc: true })?.startedAtMs + // Local wall times are ambiguous during daylight-saving transitions. + return startedAtMs == null ? null : `darwin-utc-start-ms:${startedAtMs}` +} diff --git a/src/main/persistence/profile-state/profile-state-access-owner.ts b/src/main/persistence/profile-state/profile-state-access-owner.ts new file mode 100644 index 00000000000..9144d22224a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-owner.ts @@ -0,0 +1,314 @@ +import { randomUUID } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { hostname } from 'node:os' +import { dirname, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { renameFileWithWindowsRetry } from '../../codex-accounts/fs-utils' +import { + START_TIME_TOLERANCE_MS, + startTimesWithinTolerance +} from '../../daemon/daemon-process-start-time' +import { + profileStateAccessBootIdentity, + profileStateAccessMachineIdentity, + profileStateAccessProcessIdentity +} from './profile-state-access-identity' + +export class ProfileStateAccessError extends Error { + readonly code = 'profile-state-access-refused' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateAccessError' + } +} + +export type ProfileStateAccessPaths = ReturnType + +export function profileStateAccessPaths(userDataPath: string) { + mkdirSync(userDataPath, { recursive: true, mode: 0o700 }) + const root = join(realpathSync(userDataPath), '.profile-state-access') + const paths = { + root, + participants: join(root, 'participants'), + candidates: join(root, 'candidates'), + maintenance: join(root, 'maintenance') + } + for (const path of [root, paths.participants, paths.candidates]) { + mkdirSync(path, { recursive: true, mode: 0o700 }) + } + return paths +} + +export const PROFILE_STATE_ACCESS_TOKEN = /^[a-f0-9-]{36}$/ + +type AccessOwner = { + token: string + pid: number + host: string + platform: string + pidNamespace: string | null + bootIdentity?: string | null + machineIdentity?: string | null + processStartIdentity?: string | null +} + +export function profileStateAccessPidNamespace(): string | null { + if (process.platform !== 'linux') { + return null + } + try { + return readlinkSync('/proc/self/ns/pid') + } catch { + return null + } +} + +function readOwner(path: string): AccessOwner | undefined { + try { + if (!lstatSync(path).isFile()) { + throw new ProfileStateAccessError(`Profile state owner is not a regular file: ${path}`) + } + const owner: unknown = JSON.parse(readFileSync(path, 'utf8')) + if ( + typeof owner === 'object' && + owner !== null && + 'token' in owner && + typeof owner.token === 'string' && + PROFILE_STATE_ACCESS_TOKEN.test(owner.token) && + 'pid' in owner && + typeof owner.pid === 'number' && + Number.isSafeInteger(owner.pid) && + owner.pid > 0 && + 'host' in owner && + typeof owner.host === 'string' && + owner.host.length > 0 && + 'platform' in owner && + typeof owner.platform === 'string' && + 'pidNamespace' in owner && + (owner.pidNamespace === null || typeof owner.pidNamespace === 'string') + ) { + return { + token: owner.token, + pid: owner.pid, + host: owner.host, + platform: owner.platform, + pidNamespace: owner.pidNamespace, + bootIdentity: + 'bootIdentity' in owner && typeof owner.bootIdentity === 'string' + ? owner.bootIdentity + : null, + machineIdentity: + 'machineIdentity' in owner && typeof owner.machineIdentity === 'string' + ? owner.machineIdentity + : null, + processStartIdentity: + 'processStartIdentity' in owner && + typeof owner.processStartIdentity === 'string' && + /^(?:linux-start-ticks|darwin-utc-start-ms|wall-time-ms):\d+$/.test( + owner.processStartIdentity + ) && + Number.isSafeInteger(Number(owner.processStartIdentity.split(':')[1])) + ? owner.processStartIdentity + : null + } + } + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return undefined + } + throw new ProfileStateAccessError(`Profile state ownership is unverifiable: ${path}`) + } + throw new ProfileStateAccessError(`Profile state ownership is malformed: ${path}`) +} + +function ownerExited(owner: AccessOwner): boolean { + const currentBoot = profileStateAccessBootIdentity() + const currentMachine = profileStateAccessMachineIdentity() + const sameBoot = Boolean(owner.bootIdentity && owner.bootIdentity === currentBoot) + const sameMachine = Boolean(owner.machineIdentity && owner.machineIdentity === currentMachine) + const sameHost = owner.host === hostname() + if ( + (!sameBoot && !sameHost) || + owner.platform !== process.platform || + (!sameBoot && owner.machineIdentity && currentMachine && !sameMachine) + ) { + return false + } + if ( + sameHost && + sameMachine && + owner.bootIdentity && + currentBoot && + owner.bootIdentity !== currentBoot + ) { + return true + } + // Windows/WSL and Linux PID namespaces cannot establish each other's process absence. + if ( + process.platform === 'linux' && + (owner.pidNamespace === null || owner.pidNamespace !== profileStateAccessPidNamespace()) + ) { + return false + } + try { + process.kill(owner.pid, 0) + } catch (error) { + return hasCode(error, 'ESRCH') + } + const recordedStart = owner.processStartIdentity + const actualStart = + !sameBoot || recordedStart == null ? null : profileStateAccessProcessIdentity(owner.pid) + return ( + actualStart !== null && + recordedStart != null && + actualStart.split(':')[0] === recordedStart.split(':')[0] && + (actualStart.startsWith('darwin-utc-start-ms:') + ? !startTimesWithinTolerance( + Number(actualStart.split(':')[1]), + Number(recordedStart.split(':')[1]), + START_TIME_TOLERANCE_MS + ) + : actualStart !== recordedStart) + ) +} + +export function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +export function removeOwnerEntry(path: string): void { + try { + unlinkSync(path) + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error + } + } +} + +function removeEmptyOwnerDirectory(path: string): void { + try { + rmdirSync(path) + } catch (error) { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY'].some((code) => hasCode(error, code))) { + throw error + } + } +} + +/** Only remove immutable entries whose owner is positively known to have exited. */ +export function reclaimExitedOwner(path: string): void { + let entries: string[] + try { + if (!lstatSync(path).isDirectory()) { + throw new ProfileStateAccessError(`Profile state owner is not a directory: ${path}`) + } + entries = readdirSync(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + for (const entry of entries) { + const token = entry.endsWith('.owner') ? entry.slice(0, -6) : '' + if (!PROFILE_STATE_ACCESS_TOKEN.test(token)) { + throw new ProfileStateAccessError(`Profile state ownership is unverifiable: ${path}`) + } + const owner = readOwner(join(path, entry)) + if (owner === undefined) { + continue + } + if (owner.token !== token || !ownerExited(owner)) { + throw new ProfileStateAccessError( + `Profile state is in use or its owner is unverifiable: ${path}. Stop Orca and orcad on every host using this profile, then retry. If this remains, verify PID ${owner.pid} on ${owner.host} has exited before removing its owner entry ${join(path, entry)}.` + ) + } + removeOwnerEntry(join(path, entry)) + } + // A replacement owner keeps the directory nonempty, even if our observation is stale. + removeEmptyOwnerDirectory(path) +} + +export function publishAccessOwner(paths: ProfileStateAccessPaths, exclusive: boolean) { + const token = randomUUID() + const candidate = join(paths.candidates, token) + const target = exclusive ? paths.maintenance : join(paths.participants, token) + const entry = `${token}.owner` + mkdirSync(candidate, { mode: 0o700 }) + let published = false + try { + writeFileSync( + join(candidate, entry), + JSON.stringify({ + token, + pid: process.pid, + host: hostname(), + platform: process.platform, + pidNamespace: profileStateAccessPidNamespace(), + bootIdentity: profileStateAccessBootIdentity(), + machineIdentity: profileStateAccessMachineIdentity(), + processStartIdentity: profileStateAccessProcessIdentity(process.pid) + }), + { + flag: 'wx', + mode: 0o600 + } + ) + fsyncFileSync(join(candidate, entry)) + bestEffortFsyncDirectorySync(candidate) + for (let attempt = 0; ; attempt += 1) { + try { + renameFileWithWindowsRetry(candidate, target) + published = true + break + } catch (error) { + if (!exclusive || attempt >= 2) { + throw error + } + reclaimExitedOwner(target) + } + } + bestEffortFsyncDirectorySync(dirname(target)) + bestEffortFsyncDirectorySync(paths.candidates) + } catch (error) { + if (published) { + removeOwnerEntry(join(target, entry)) + removeEmptyOwnerDirectory(target) + } + throw error + } finally { + if (!published) { + removeOwnerEntry(join(candidate, entry)) + removeEmptyOwnerDirectory(candidate) + } + } + let released = false + return { + token, + assertActive(): void { + if (released || readOwner(join(target, entry))?.token !== token) { + throw new ProfileStateAccessError('Profile state access has already been released') + } + }, + release(): void { + if (released) { + return + } + removeOwnerEntry(join(target, entry)) + removeEmptyOwnerDirectory(target) + released = true + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-access-process.test.ts b/src/main/persistence/profile-state/profile-state-access-process.test.ts new file mode 100644 index 00000000000..eef48007bf1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access-process.test.ts @@ -0,0 +1,150 @@ +import { once } from 'node:events' +import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { buildSync } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { profileStateAccessPaths } from './profile-state-access-owner' + +const fixture = mkdtempSync(join(tmpdir(), 'orca-state-access-process-')) +const bundle = join(fixture, 'access.cjs') +const children = new Set>() + +beforeAll(() => { + buildSync({ + entryPoints: [resolve(__dirname, 'profile-state-access.ts')], + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) +}) + +afterEach(async () => { + await Promise.all([...children].map(stopChild)) +}) + +afterAll(() => rmSync(fixture, { recursive: true, force: true })) + +async function stopChild(child: ReturnType): Promise { + children.delete(child) + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const closed = once(child, 'close') + child.kill('SIGKILL') + await closed +} + +const CHILD_SOURCE = ` +const fs = require('node:fs') +const [root, bundle, mode] = process.argv.slice(1) +const rename = fs.renameSync +if (mode === 'candidate' || mode === 'published') { + fs.renameSync = (from, to) => { + if (mode === 'candidate' && String(to).endsWith('maintenance')) { + fs.writeSync(1, 'barrier\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + } + rename(from, to) + if (mode === 'published' && String(to).includes('participants')) { + fs.writeSync(1, 'barrier\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + } + } +} +const access = require(bundle) +const owner = mode === 'runtime' || mode === 'published' + ? access.acquireProfileStateRuntimeAdmission(root) + : access.acquireProfileStateMaintenance(root) +fs.writeSync(1, 'ready\\n') +process.stdin.resume() +` + +async function startChild(root: string, mode: string): Promise> { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', CHILD_SOURCE, root, bundle, mode], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + children.add(child) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + await new Promise((resolveReady, reject) => { + let stdout = '' + const onData = (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes('ready\n') || stdout.includes('barrier\n')) { + cleanup() + resolveReady() + } + } + const onExit = () => { + cleanup() + reject(new Error(`Owner child exited before its barrier: ${stderr}`)) + } + const onError = (error: Error) => { + cleanup() + reject(error) + } + const cleanup = () => { + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + } + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onError) + }) + return child +} + +describe('profile state owners across actual process death', () => { + it('excludes recovery while a runtime lives and reclaims its registration after SIGKILL', async () => { + const root = join(fixture, 'runtime') + const child = await startChild(root, 'runtime') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + const maintenance = acquireProfileStateMaintenance(root) + expect(readdirSync(profileStateAccessPaths(root).participants)).toEqual([]) + maintenance.release() + }) + + it('never steals a live maintenance owner with arbitrarily old timestamps', async () => { + const root = join(fixture, 'maintenance') + const child = await startChild(root, 'maintenance') + const gate = profileStateAccessPaths(root).maintenance + for (const file of readdirSync(gate)) { + utimesSync(join(gate, file), 0, 0) + } + utimesSync(gate, 0, 0) + expect(() => acquireProfileStateRuntimeAdmission(root)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + acquireProfileStateRuntimeAdmission(root).release() + }) + + it('treats a crash before complete owner publication as an inert candidate', async () => { + const root = join(fixture, 'candidate') + const child = await startChild(root, 'candidate') + acquireProfileStateRuntimeAdmission(root).release() + await stopChild(child) + acquireProfileStateMaintenance(root).release() + }) + + it('excludes recovery after participant publication even before runtime admission finishes', async () => { + const root = join(fixture, 'published') + const child = await startChild(root, 'published') + expect(() => acquireProfileStateMaintenance(root)).toThrow('in use') + await stopChild(child) + acquireProfileStateMaintenance(root).release() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access.test.ts b/src/main/persistence/profile-state/profile-state-access.test.ts new file mode 100644 index 00000000000..5def614309e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.test.ts @@ -0,0 +1,527 @@ +import * as fs from 'node:fs' +import { randomUUID } from 'node:crypto' +import { tmpdir, hostname } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission, + assertProfileStateMaintenance, + type ProfileStateMaintenance +} from './profile-state-access' +import { profileStateAccessPaths, reclaimExitedOwner } from './profile-state-access-owner' +import * as identity from './profile-state-access-identity' +import * as processStart from '../../daemon/daemon-process-start-time' + +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('../../daemon/daemon-process-start-time', async (importOriginal) => ({ + ...(await importOriginal()) +})) +vi.mock('./profile-state-access-identity', async (importOriginal) => ({ + ...(await importOriginal()) +})) + +const roots: string[] = [] +function root(): string { + const path = fs.mkdtempSync(join(tmpdir(), 'orca-state-access-')) + roots.push(path) + return path +} + +afterEach(() => { + vi.restoreAllMocks() + for (const path of roots.splice(0)) { + fs.rmSync(path, { recursive: true, force: true }) + } +}) + +describe('profile state admission and maintenance', () => { + it.skipIf(process.platform === 'win32')( + 'releases its published owner when directory sync fails', + () => { + const path = root() + const syncFile = fs.fsyncSync + let syncCount = 0 + const sync = vi.spyOn(fs, 'fsyncSync').mockImplementation((fd) => { + if (++syncCount === 3) { + throw new Error('directory sync failed') + } + syncFile(fd) + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('directory sync failed') + const paths = profileStateAccessPaths(path) + expect(fs.readdirSync(paths.participants)).toEqual([]) + expect(fs.readdirSync(paths.candidates)).toEqual([]) + sync.mockRestore() + acquireProfileStateMaintenance(path).release() + } + ) + + it('does not publish an owner whose contents could not be synced', () => { + const path = root() + const sync = vi.spyOn(fs, 'fsyncSync').mockImplementationOnce(() => { + throw new Error('owner sync failed') + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('owner sync failed') + const paths = profileStateAccessPaths(path) + expect(fs.readdirSync(paths.participants)).toEqual([]) + expect(fs.readdirSync(paths.candidates)).toEqual([]) + sync.mockRestore() + acquireProfileStateMaintenance(path).release() + }) + + it('allows concurrent normal writers and refuses maintenance until every admission releases', () => { + const path = root() + const first = acquireProfileStateRuntimeAdmission(path) + const second = acquireProfileStateRuntimeAdmission(path) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + first.release() + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + second.release() + const maintenance = acquireProfileStateMaintenance(path) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + maintenance.release() + acquireProfileStateRuntimeAdmission(path).release() + }) + + it('refuses a runtime publishing after maintenance has acquired and scanned', () => { + const path = root() + const rename = fs.renameSync + let maintenance: ProfileStateMaintenance | undefined + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).includes('participants')) { + maintenance = acquireProfileStateMaintenance(path) + } + rename(from, to) + }) + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + expect(maintenance).toBeDefined() + maintenance?.release() + expect(fs.readdirSync(profileStateAccessPaths(path).participants)).toEqual([]) + }) + + it('refuses maintenance when runtime publication precedes its final admission check', () => { + const path = root() + const rename = fs.renameSync + let refused = false + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + rename(from, to) + if (String(to).includes('participants')) { + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + refused = true + } + }) + const admission = acquireProfileStateRuntimeAdmission(path) + expect(refused).toBe(true) + admission.release() + }) + + it('rejects released and fabricated maintenance owners and binds valid owners to exact profile paths', () => { + const path = root() + const other = root() + const profileId = 'profile-test' + const profileDir = join(path, 'profiles', profileId) + fs.mkdirSync(profileDir, { recursive: true }) + const files = { + profileId, + dataFile: join(profileDir, 'orca-data.json'), + databasePath: join(profileDir, 'profile-state.db') + } + const owner = acquireProfileStateMaintenance(path) + assertProfileStateMaintenance(owner, files) + expect(() => assertProfileStateMaintenance({ ...owner }, files)).toThrow('acquired') + const wrong = acquireProfileStateMaintenance(other) + expect(() => assertProfileStateMaintenance(wrong, files)).toThrow('paths') + expect(() => + assertProfileStateMaintenance(owner, { ...files, profileId: '../escape' }) + ).toThrow('acquired') + owner.release() + expect(() => assertProfileStateMaintenance(owner, files)).toThrow('released') + wrong.release() + }) + + it.skipIf(process.platform === 'win32')( + 'refuses symlinked profile directories outside the protected root', + () => { + const path = root() + const outside = root() + fs.mkdirSync(join(path, 'profiles')) + fs.symlinkSync(outside, join(path, 'profiles', 'escaped')) + const owner = acquireProfileStateMaintenance(path) + expect(() => + assertProfileStateMaintenance(owner, { + profileId: 'escaped', + dataFile: join(outside, 'orca-data.json'), + databasePath: join(outside, 'profile-state.db') + }) + ).toThrow('paths') + owner.release() + } + ) + + it.each(['', '../outside', 'x'.repeat(129)])( + 'rejects invalid recovery profile IDs: %s', + (profileId) => { + const path = root() + const owner = acquireProfileStateMaintenance(path) + expect(() => owner.assertProfile(profileId, path, path)).toThrow('acquired') + owner.release() + acquireProfileStateRuntimeAdmission(path).release() + } + ) +}) + +function staleGate( + path: string, + pid = 12345, + host = hostname(), + extra: { + bootIdentity?: string + machineIdentity?: string + startedAtMs?: number + processStartIdentity?: string + } = {} +): string { + const gate = profileStateAccessPaths(path).maintenance + fs.mkdirSync(gate) + const token = randomUUID() + const record = join(gate, `${token}.owner`) + fs.writeFileSync( + record, + JSON.stringify({ + token, + pid, + host, + platform: process.platform, + pidNamespace: process.platform === 'linux' ? fs.readlinkSync('/proc/self/ns/pid') : null, + ...extra + }) + ) + return record +} + +describe('profile state owner reclamation', () => { + it('reclaims a local owner from a previous boot even when its PID is now live', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('current-boot') + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('same-machine') + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'previous-boot', + machineIdentity: 'same-machine' + }) + const kill = vi.spyOn(process, 'kill') + acquireProfileStateMaintenance(path).release() + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('keeps a differently booted machine with the same hostname unverifiable', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('current-boot') + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('this-machine') + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'another-boot', + machineIdentity: 'another-machine' + }) + const kill = vi.spyOn(process, 'kill') + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each(['current-boot', null])( + 'does not reclaim another host with a cloned machine identity (current boot: %s)', + (currentBoot) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue(currentBoot) + vi.spyOn(identity, 'profileStateAccessMachineIdentity').mockReturnValue('cloned-machine') + const owner = staleGate(path, 12345, 'another-host', { + bootIdentity: 'another-boot', + machineIdentity: 'cloned-machine' + }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent on this host'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + } + ) + + it('reclaims a reused PID only when its recorded process start differs on the same boot', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: 'linux-start-ticks:1000' + }) + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('cannot reclaim a live Linux owner when wall-clock time changes', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + if (!platform) { + throw new Error('Missing platform descriptor') + } + const path = root() + const read = fs.readFileSync + const fields = Array.from({ length: 20 }, () => '0') + fields[0] = 'S' + fields[19] = '987654' + let clock = 1_700_000_000_000 + const wallStart = vi + .spyOn(processStart, 'getProcessStartedAtMs') + .mockImplementation(() => clock) + vi.spyOn(fs, 'readFileSync').mockImplementation((file, options) => { + if (file === '/proc/12345/stat') { + return `12345 (orca daemon) ${fields.join(' ')}` + } + return read(file, options) + }) + vi.spyOn(fs, 'readlinkSync').mockReturnValue('pid:[same-namespace]') + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(process, 'kill').mockReturnValue(true) + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const processStartIdentity = identity.profileStateAccessProcessIdentity(12345) + expect(processStartIdentity).toBe('linux-start-ticks:987654') + if (processStartIdentity === null) { + throw new Error('Expected process identity') + } + const owner = staleGate(path, 12345, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity + }) + clock += 60_000 + // Linux identity emulation must not change the host filesystem's publication/fsync flags. + expect(() => reclaimExitedOwner(profileStateAccessPaths(path).maintenance)).toThrow( + 'unverifiable' + ) + expect(fs.existsSync(owner)).toBe(true) + expect(wallStart).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('does not compare legacy epoch timestamps with raw process identity', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + startedAtMs: 1000 + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each([ + 'wall-time-ms:1000', + 'linux-start-ticks:invalid', + 'linux-start-ticks:99999999999999999' + ])('does not compare incompatible or malformed identity %s', (recorded) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue( + 'linux-start-ticks:2000' + ) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: recorded + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('does not interpret an unavailable process start as proof of PID reuse', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue(null) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: 'linux-start-ticks:1000' + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it.each([ + ['darwin-utc-start-ms:100000', 'darwin-utc-start-ms:101000', false], + ['darwin-utc-start-ms:100000', 'darwin-utc-start-ms:101501', true], + ['wall-time-ms:100000', 'darwin-utc-start-ms:3700000', false] + ] as const)('compares macOS start identities safely: %s / %s', (recorded, actual, exited) => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + vi.spyOn(identity, 'profileStateAccessProcessIdentity').mockReturnValue(actual) + const owner = staleGate(path, process.pid, hostname(), { + bootIdentity: 'same-boot', + processStartIdentity: recorded + }) + if (exited) { + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + } else { + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + } + }) + + it('retries a transient Windows owner publication lock', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + if (!platform) { + throw new Error('Missing platform descriptor') + } + const path = root() + const rename = fs.renameSync + const publish = vi + .spyOn(fs, 'renameSync') + .mockImplementationOnce(() => { + throw Object.assign(new Error('scanner holds directory'), { code: 'EPERM' }) + }) + .mockImplementation(rename) + vi.spyOn(Atomics, 'wait').mockReturnValue('timed-out') + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const admission = acquireProfileStateRuntimeAdmission(path) + expect(publish).toHaveBeenCalledTimes(2) + admission.release() + expect(fs.readdirSync(profileStateAccessPaths(path).participants)).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('recognizes an exited owner after a hostname change on the same kernel boot', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('same-boot') + const owner = staleGate(path, 12345, 'previous-hostname', { bootIdentity: 'same-boot' }) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + }) + acquireProfileStateMaintenance(path).release() + expect(fs.existsSync(owner)).toBe(false) + }) + + it('keeps a differently booted host unverifiable after a hostname change', () => { + const path = root() + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue('different-boot') + const owner = staleGate(path, 12345, 'previous-hostname', { bootIdentity: 'owner-boot' }) + const kill = vi.spyOn(process, 'kill') + expect(() => acquireProfileStateMaintenance(path)).toThrow('verify PID 12345') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it('does not infer exit from a PID in another platform on a shared root', () => { + const path = root() + const owner = staleGate(path) + const record: unknown = JSON.parse(fs.readFileSync(owner, 'utf8')) + if (typeof record !== 'object' || record === null) { + throw new Error('Owner fixture missing') + } + fs.writeFileSync( + owner, + JSON.stringify({ ...record, platform: process.platform === 'win32' ? 'linux' : 'win32' }) + ) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent here'), { code: 'ESRCH' }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(kill).not.toHaveBeenCalled() + expect(fs.existsSync(owner)).toBe(true) + }) + + it.skipIf(process.platform !== 'linux')( + 'refuses a different Linux PID namespace even with the same hostname', + () => { + const path = root() + const owner = staleGate(path) + const record: unknown = JSON.parse(fs.readFileSync(owner, 'utf8')) + if (typeof record !== 'object' || record === null) { + throw new Error('Owner fixture missing') + } + fs.writeFileSync(owner, JSON.stringify({ ...record, pidNamespace: 'pid:[foreign]' })) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + } + ) + + it.each(['EPERM', 'EINVAL', 'EACCES'])('refuses unverifiable process query %s', (code) => { + const path = root() + const owner = staleGate(path) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error(code), { code }) + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('refuses live reused PIDs regardless of old timestamps', () => { + const path = root() + const owner = staleGate(path, process.pid) + fs.utimesSync(owner, 0, 0) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('refuses foreign host and malformed owners without reclaiming them', () => { + const path = root() + const owner = staleGate(path, process.pid, `${hostname()}-other`) + expect(() => acquireProfileStateMaintenance(path)).toThrow('unverifiable') + fs.writeFileSync(owner, '{}') + expect(() => acquireProfileStateMaintenance(path)).toThrow('malformed') + expect(fs.existsSync(owner)).toBe(true) + }) + + it('cannot remove a replacement published while it releases its own token', () => { + const path = root() + const original = acquireProfileStateMaintenance(path) + const unlink = fs.unlinkSync + let replacement: ProfileStateMaintenance | undefined + let intercepted = false + vi.spyOn(fs, 'unlinkSync').mockImplementation((entry) => { + unlink(entry) + if (!intercepted && String(entry).includes('maintenance')) { + intercepted = true + replacement = acquireProfileStateMaintenance(path) + } + }) + original.release() + expect(replacement).toBeDefined() + replacement?.assertActive() + expect(() => acquireProfileStateRuntimeAdmission(path)).toThrow('in use') + replacement?.release() + }) + + it('cannot remove a replacement published after stale-owner observation', () => { + const path = root() + const old = staleGate(path) + const kill = process.kill + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === 12345) { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + return kill(pid, signal) + }) + const unlink = fs.unlinkSync + let replacement: ProfileStateMaintenance | undefined + vi.spyOn(fs, 'unlinkSync').mockImplementation((entry) => { + unlink(entry) + if (entry === old) { + replacement = acquireProfileStateMaintenance(path) + } + }) + expect(() => acquireProfileStateMaintenance(path)).toThrow('in use') + replacement?.assertActive() + expect(replacement).toBeDefined() + replacement?.release() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-access.ts b/src/main/persistence/profile-state/profile-state-access.ts new file mode 100644 index 00000000000..724549dc7de --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-access.ts @@ -0,0 +1,99 @@ +import { lstatSync, readdirSync, realpathSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { + ProfileStateAccessError, + hasCode, + profileStateAccessPaths, + publishAccessOwner, + reclaimExitedOwner +} from './profile-state-access-owner' +export { ProfileStateAccessError } from './profile-state-access-owner' + +export type ProfileStateRuntimeAdmission = { + assertActive(): void + release(): void +} + +export type ProfileStateMaintenance = ProfileStateRuntimeAdmission & { + assertProfile(profileId: string, dataFile: string, databasePath: string): void +} + +const maintenanceRoots = new WeakMap() + +/** Bind destructive operations to a genuine, live maintenance owner for these exact profile paths. */ +export function assertProfileStateMaintenance( + maintenance: ProfileStateMaintenance, + profile: { profileId: string; dataFile: string; databasePath: string } +): void { + const root = maintenanceRoots.get(maintenance) + if (root === undefined || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profile.profileId)) { + throw new ProfileStateAccessError( + 'Profile state recovery requires an acquired maintenance owner' + ) + } + maintenance.assertActive() + const expectedDirectory = join(root, 'profiles', profile.profileId) + for (const [path, expectedName] of [ + [profile.dataFile, 'orca-data.json'], + [profile.databasePath, 'profile-state.db'] + ] as const) { + if ( + !samePath(realpathSync(dirname(path)), expectedDirectory) || + !samePath(basename(path), expectedName) + ) { + throw new ProfileStateAccessError( + 'Profile state recovery paths do not belong to the maintenance root' + ) + } + try { + if (lstatSync(path).isSymbolicLink()) { + throw new ProfileStateAccessError('Profile state recovery cannot replace a symbolic link') + } + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error + } + } + } +} + +function samePath(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** Admit before any profile read, and retain until every Store and worker has stopped. */ +export function acquireProfileStateRuntimeAdmission( + userDataPath: string +): ProfileStateRuntimeAdmission { + const paths = profileStateAccessPaths(userDataPath) + const owner = publishAccessOwner(paths, false) + try { + reclaimExitedOwner(paths.maintenance) + return owner + } catch (error) { + owner.release() + throw error + } +} + +/** Exclude startup and all participating readers/writers through durable recovery publication. */ +export function acquireProfileStateMaintenance(userDataPath: string): ProfileStateMaintenance { + const paths = profileStateAccessPaths(userDataPath) + const owner = publishAccessOwner(paths, true) + try { + for (const entry of readdirSync(paths.participants)) { + reclaimExitedOwner(join(paths.participants, entry)) + } + const maintenance: ProfileStateMaintenance = { + ...owner, + assertProfile(profileId, dataFile, databasePath): void { + assertProfileStateMaintenance(maintenance, { profileId, dataFile, databasePath }) + } + } + maintenanceRoots.set(maintenance, dirname(paths.root)) + return maintenance + } catch (error) { + owner.release() + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-active-location.ts b/src/main/persistence/profile-state/profile-state-active-location.ts new file mode 100644 index 00000000000..15c38fe3ac1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-active-location.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile +} from '../../../shared/profile-state-storage-paths' +import type { ProfileStateOfflineLocation } from './profile-state-offline-settings' +import { ProfileStateRecoveryCommandError } from '../../../shared/profile-state-recovery-command' + +/** Resolve the active profile files for offline profile-state commands. */ +export function getActiveProfileStateLocation( + userDataPath: string +): ProfileStateOfflineLocation | undefined { + const indexPath = join(userDataPath, 'orca-profile-index.json') + const candidates = [indexPath, `${indexPath}.bak`].filter(existsSync) + if (candidates.length === 0) { + return undefined + } + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) + if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) { + continue + } + const profileId = parsed.activeProfileId + if ( + typeof profileId === 'string' && + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) && + parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId) + ) { + return { + dataFile: getOrcaProfileDataFile(profileId, userDataPath), + databaseFile: getOrcaProfileStateDatabaseFile(profileId, userDataPath), + profileId + } + } + } catch { + // Try the profile-index backup before failing closed. + } + } + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + `Could not read active profile index ${indexPath}` + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts new file mode 100644 index 00000000000..48b46b1505a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -0,0 +1,537 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import * as profileStateDocuments from './profile-state-documents' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { + bootstrapProfileStateAuthority as bootstrapProfileStateAuthorityImpl, + classifyProfileStateStorage, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +const { Store } = await import('../loading-store/store') + +const temporaryDirectories: string[] = [] +const authoritiesToClose: NonNullable< + ReturnType['authority'] +>[] = [] + +function bootstrapProfileStateAuthority( + options: Parameters[0] +): ReturnType { + const result = bootstrapProfileStateAuthorityImpl(options) + if (result.authority !== undefined) { + authoritiesToClose.push(result.authority) + } + return result +} + +afterEach(() => { + for (const authority of authoritiesToClose.splice(0)) { + authority.close?.() + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function createDirectory(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-profile-state-bootstrap-')) + temporaryDirectories.push(root) + const directory = join(root, 'profiles', 'profile-bootstrap-test') + mkdirSync(directory, { recursive: true }) + return directory +} + +function paths(directory: string): { + dataFile: string + databaseFile: string + profileId: string +} { + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'profile-bootstrap-test' + } +} + +describe('profile state authority bootstrap', () => { + it('classifies all four storage-presence states without opening SQLite', () => { + const directory = createDirectory() + const { dataFile, databaseFile } = paths(directory) + + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('neither') + writeFileSync(dataFile, '{}') + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('json-only') + + const opened = openProfileStateDatabase(databaseFile, 'profile-bootstrap-test') + opened.db.close() + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('both') + + rmSync(dataFile) + expect(classifyProfileStateStorage(dataFile, databaseFile)).toBe('sqlite-only') + }) + + it('imports JSON-only state through Store export and returns the SQLite authority', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync( + options.dataFile, + JSON.stringify({ settings: { theme: 'dark' }, futureExtension: { keep: true } }) + ) + + const result = bootstrapProfileStateAuthority(options) + + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.authority?.readSerializedState()).toContain('futureExtension') + expect(existsSync(options.dataFile)).toBe(true) + expect(existsSync(profileStateJsonExportPath(options.dataFile, 1))).toBe(true) + expect(readFileSync(profileStateJsonExportPath(options.dataFile, 1), 'utf8')).toContain( + 'futureExtension' + ) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('both') + + if (result.authority === undefined) { + throw new Error('JSON migration did not return a SQLite authority') + } + const store = new Store({ + dataFile: options.dataFile, + profileStateAuthority: result.authority + }) + expect(store.getSettings().theme).toBe('dark') + + const repeated = bootstrapProfileStateAuthority(options) + expect(repeated.authority?.readSerializedState()).toContain('futureExtension') + }) + + it('fails closed when both files are present without a matching acceptance marker', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + ProfileStateAuthorityBootstrapError + ) + }) + + it('rejects a legacy JSON edit after migration instead of selecting stale SQLite state', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'light' } })) + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + ProfileStateAuthorityBootstrapError + ) + }) + + it.each([1, 2])( + 'requires explicit recovery for unreleased schema %s without changing its bytes', + (version) => { + const options = paths(createDirectory()) + const raw = JSON.stringify({ settings: { theme: 'dark' }, futureDomain: { retained: true } }) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + profileStateDocuments.importProfileStateJson(opened.db, raw, { + acceptedLegacyJsonHash: profileStateDocuments.hashProfileStateJson(raw) + }) + if (version === 1) { + opened.db.exec( + 'DROP TABLE profile_state_automation_runs; DROP TABLE profile_state_automation_runs_meta' + ) + } else { + opened.db.exec('DELETE FROM profile_state_automation_runs_meta') + } + opened.db.pragma(`user_version = ${version}`) + opened.db.close() + const before = readFileSync(options.databaseFile) + for (const withJson of [false, true]) { + if (withJson) { + writeFileSync(options.dataFile, raw) + } + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + ProfileStateRecoveryRequiredError + ) + expect(readFileSync(options.databaseFile)).toEqual(before) + } + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(exportPath, raw) + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath + }) + const recovered = bootstrapProfileStateAuthority(options) + expect(recovered.migrated).toBe(true) + expect(JSON.parse(recovered.authority?.readSerializedState() ?? '{}')).toMatchObject({ + futureDomain: { retained: true } + }) + } + ) + + it('returns no authority for a brand-new profile', () => { + const directory = createDirectory() + const result = bootstrapProfileStateAuthority(paths(directory)) + + expect(result).toEqual({ classification: 'neither', authority: undefined, migrated: false }) + }) + + it('cleans failed empty-profile initialization before a successful retry', () => { + const directory = createDirectory() + const options = { ...paths(directory), allowEmptyProfileState: true } + const initializationFailure = new Error('injected initial schema failure') + const originalExec = Database.prototype.exec + const execSpy = vi + .spyOn(Database.prototype, 'exec') + .mockImplementation(function (this: Database, sql) { + originalExec.call(this, sql) + if (sql.includes('CREATE TABLE')) { + const row = this.prepare('PRAGMA database_list').get() + if (typeof row?.file !== 'string') { + throw new Error('Expected a file-backed database during initialization') + } + expect(existsSync(`${row.file}-journal`)).toBe(true) + for (const suffix of ['-wal', '-shm']) { + writeFileSync(`${row.file}${suffix}`, 'interrupted schema initialization') + } + throw initializationFailure + } + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrowError( + expect.objectContaining({ cause: initializationFailure }) + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('neither') + expect(readdirSync(directory)).toEqual([]) + + execSpy.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.migrated).toBe(false) + expect(retry.authority).toBeDefined() + expect(retry.authority?.readSerializedState()).toBeUndefined() + expect(bootstrapProfileStateAuthority(options).classification).toBe('sqlite-only') + }) + + it('preserves JSON created while an empty database is being initialized', () => { + const options = { ...paths(createDirectory()), allowEmptyProfileState: true } + const source = '{"settings":{"theme":"dark"}}' + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + writeFileSync(options.dataFile, source) + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + 'Profile state storage changed while creating an empty database' + ) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + expect(readdirSync(dirname(options.dataFile))).toEqual(['orca-data.json']) + }) + + it.each(['legacy-backup', 'sqlite-export'])( + 'preserves a %s created while an empty database is being initialized', + (artifact) => { + const options = { ...paths(createDirectory()), allowEmptyProfileState: true } + const path = + artifact === 'legacy-backup' + ? `${options.dataFile}.bak.0` + : profileStateJsonExportPath(options.dataFile, 1) + const source = '{"settings":{"theme":"dark"}}' + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + writeFileSync(path, source) + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(path, 'utf8')).toBe(source) + expect(readdirSync(dirname(path))).toEqual([basename(path)]) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'treats an orphaned SQLite %s sidecar as authority evidence with or without JSON', + (suffix) => { + const options = paths(createDirectory()) + const sidecar = `${options.databaseFile}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe( + 'sqlite-only' + ) + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('both') + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') + } + ) + + it('validates and returns an existing SQLite-only authority', () => { + const directory = createDirectory() + const options = paths(directory) + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + const result = bootstrapProfileStateAuthority(options) + + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.authority?.readSerializedState()).toBeUndefined() + }) + + it('rejects malformed SQLite-only state before Store can select it', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.databaseFile, 'not sqlite') + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + }) + + it('reports retained exports when an established SQLite profile needs recovery', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const bootstrap = bootstrapProfileStateAuthority(options) + bootstrap.authority?.close?.() + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + const exportPathRevision2 = profileStateJsonExportPath(options.dataFile, 2) + const exportPathRevision10 = profileStateJsonExportPath(options.dataFile, 10) + writeFileSync(exportPathRevision2, readFileSync(exportPath)) + writeFileSync(exportPathRevision10, readFileSync(exportPath)) + writeFileSync( + `${options.dataFile}.sqlite-export.9007199254740992.json`, + readFileSync(exportPath) + ) + writeFileSync(options.databaseFile, 'not sqlite') + + try { + bootstrapProfileStateAuthority(options) + throw new Error('expected recovery-required startup failure') + } catch (error) { + expect(error).toBeInstanceOf(ProfileStateRecoveryRequiredError) + if (!(error instanceof ProfileStateRecoveryRequiredError)) { + throw error + } + expect(error.dataFile).toBe(options.dataFile) + expect(error.databaseFile).toBe(options.databaseFile) + expect(error.exportPaths).toEqual([exportPathRevision10, exportPathRevision2, exportPath]) + } + }) + + it('does not create a database when the legacy JSON cannot be imported', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, '{ malformed') + + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + 'Failed to load imported profile state' + ) + expect(existsSync(options.databaseFile)).toBe(false) + }) + + it.each([0, 1, 2, 3, 4])( + 'migrates usable legacy backup slot %s after a corrupt primary', + (slot) => { + const options = paths(createDirectory()) + const damaged = '{ malformed primary' + const recovered = JSON.stringify({ settings: { theme: 'dark' }, retainedBackup: slot }) + writeFileSync(options.dataFile, damaged) + for (let index = 0; index < slot; index += 1) { + writeFileSync(`${options.dataFile}.bak.${index}`, '{ damaged backup') + } + writeFileSync(`${options.dataFile}.bak.${slot}`, recovered) + + const result = bootstrapProfileStateAuthority(options) + expect(result.migrated).toBe(true) + expect(JSON.parse(result.authority?.readSerializedState() ?? '{}')).toMatchObject({ + settings: { theme: 'dark' }, + retainedBackup: slot + }) + expect(readFileSync(options.dataFile, 'utf8')).toBe(damaged) + expect(readFileSync(`${options.dataFile}.bak.${slot}`, 'utf8')).toBe(recovered) + expect(bootstrapProfileStateAuthority(options).migrated).toBe(false) + } + ) + + it('preserves every source when legacy primary and backups are unusable', () => { + const options = paths(createDirectory()) + writeFileSync(options.dataFile, '{ malformed primary') + writeFileSync(`${options.dataFile}.bak.0`, '{ malformed backup') + expect(() => bootstrapProfileStateAuthority(options)).toThrow( + ProfileStateAuthorityBootstrapError + ) + expect(readFileSync(options.dataFile, 'utf8')).toBe('{ malformed primary') + expect(readFileSync(`${options.dataFile}.bak.0`, 'utf8')).toBe('{ malformed backup') + expect(existsSync(options.databaseFile)).toBe(false) + }) + + it('cleans a temporary database when import fails after opening SQLite', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const importFailure = new Error('injected import failure') + const importSpy = vi + .spyOn(profileStateDocuments, 'importProfileStateJson') + .mockImplementation(() => { + throw importFailure + }) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow(importFailure) + expect(classifyProfileStateStorage(options.dataFile, options.databaseFile)).toBe('json-only') + expect(existsSync(options.databaseFile)).toBe(false) + expect( + readdirSync(directory).some((name) => name.includes('.migration.') && name.endsWith('.tmp')) + ).toBe(false) + + importSpy.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.migrated).toBe(true) + expect(retry.authority?.readSerializedState()).toContain('"dark"') + }) + + it('refuses a pre-existing retained export before migrating JSON again', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + mkdirSync(exportPath) + + expect(() => bootstrapProfileStateAuthority(options)).toThrow() + expect(existsSync(options.dataFile)).toBe(true) + expect(existsSync(options.databaseFile)).toBe(false) + + rmSync(exportPath, { recursive: true }) + const retry = bootstrapProfileStateAuthority(options) + expect(retry.classification).toBe('json-only') + expect(retry.migrated).toBe(true) + expect(retry.authority?.readSerializedState()).toContain('"dark"') + }) + + it('boots the revisioned export through the legacy Store after SQLite corruption', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + + writeFileSync(options.databaseFile, 'corrupt sqlite primary') + writeFileSync(options.dataFile, readFileSync(exportPath)) + const rollbackStore = new Store({ dataFile: options.dataFile }) + expect(rollbackStore.getSettings().theme).toBe('dark') + rollbackStore.freezeWrites() + }) + + it('quarantines SQLite and restores a selected export for legacy rollback', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const bootstrap = bootstrapProfileStateAuthority(options) + bootstrap.authority?.close?.() + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(options.databaseFile, 'corrupt sqlite primary') + writeFileSync(`${options.databaseFile}-wal`, 'corrupt wal sidecar') + const restoredJson = readFileSync(exportPath) + + const result = restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath, + quarantineRoot: join(directory, 'quarantine') + }) + + expect(result.removedDatabaseFiles).toEqual( + expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(existsSync(exportPath)).toBe(false) + expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( + 'corrupt sqlite primary' + ) + expect(readFileSync(join(result.quarantine.directory, 'profile-state.db-wal'), 'utf8')).toBe( + 'corrupt wal sidecar' + ) + + const rollbackStore = new Store({ dataFile: options.dataFile }) + expect(rollbackStore.getSettings().theme).toBe('dark') + rollbackStore.freezeWrites() + }) + + it('validates the selected export before quarantining or replacing anything', () => { + const directory = createDirectory() + const options = paths(directory) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + bootstrapProfileStateAuthority(options) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + writeFileSync(exportPath, '{ malformed') + const databaseBytes = readFileSync(options.databaseFile) + const dataBytes = readFileSync(options.dataFile) + + expect(() => + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(dirname(options.dataFile)))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath + }) + ).toThrow('Profile state JSON is invalid') + expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) + expect(readFileSync(options.dataFile)).toEqual(dataBytes) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts new file mode 100644 index 00000000000..35f722b307f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -0,0 +1,192 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +import { publishProfileStateDatabase } from './profile-state-database-publication' +import type { + ProfileStateAuthorityInitialState, + ProfileStateStartupPaneAlias +} from '../loading-store/profile-state-authority' +import { Store } from '../loading-store/store' +import { isProfileStateSqliteAvailable, openProfileStateDatabase } from './profile-state-database' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { + PROFILE_STATE_LEGACY_BACKUP_COUNT, + profileStateLegacyBackupPath +} from './profile-state-legacy-backup-path' +import { + assertProfileStateCanInitialize, + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' +export { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-recovery-required' +import { + classifyProfileStateStorage, + profileStateDatabaseFiles, + type ProfileStateStorageClassification +} from './profile-state-storage-classification' + +export { classifyProfileStateStorage } from './profile-state-storage-classification' +export type { ProfileStateStorageClassification } from './profile-state-storage-classification' + +export type ProfileStateAuthorityBootstrapResult = { + classification: ProfileStateStorageClassification + migrated: boolean +} & ( + | { + authority: ProfileStateSqliteAuthority + initialState: ProfileStateAuthorityInitialState + } + | { authority: undefined; initialState?: never } +) + +export type ProfileStateAuthorityBootstrapOptions = { + dataFile: string + databaseFile: string + profileId: string + /** Establish empty profiles before their first Store write. */ + allowEmptyProfileState?: boolean +} + +/** Normalize legacy state once, then hand one validated authority to Store. */ +export function bootstrapProfileStateAuthority( + options: ProfileStateAuthorityBootstrapOptions +): ProfileStateAuthorityBootstrapResult { + const classification = classifyProfileStateStorage(options.dataFile, options.databaseFile) + if (classification === 'neither' && options.allowEmptyProfileState !== true) { + return { classification, authority: undefined, migrated: false } + } + if (!isProfileStateSqliteAvailable()) { + if (classification === 'neither' || classification === 'json-only') { + return { classification, authority: undefined, migrated: false } + } + throw new ProfileStateAuthorityBootstrapError( + 'SQLite profile state is present but this runtime cannot validate it' + ) + } + if (classification === 'json-only' || classification === 'neither') { + assertProfileStateCanInitialize(options) + } + if (classification === 'json-only') { + return migrateJsonOnlyProfile(options) + } + if (classification === 'neither') { + mkdirSync(dirname(options.databaseFile), { recursive: true }) + createEmptyProfileStateDatabase(options) + } else if (classification === 'sqlite-only' && !existsSync(options.databaseFile)) { + throw new ProfileStateAuthorityBootstrapError( + 'SQLite profile state has an orphaned database sidecar' + ) + } + + const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + const initialState = + classification === 'both' + ? authority.readAcceptedState(readFileSync(options.dataFile, 'utf8')) + : authority.readInitialState() + if (initialState === undefined) { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state has both JSON and SQLite storage without a matching acceptance marker' + ) + } + return { classification, authority, initialState, migrated: false } + } catch (error) { + authority.close() + if ( + error instanceof ProfileStateAuthorityBootstrapError || + (error instanceof ProfileStateDatabaseOpenError && error.code === 'newer-schema') + ) { + throw error + } + throw new ProfileStateRecoveryRequiredError(options, error) + } +} + +function createEmptyProfileStateDatabase({ + dataFile, + databaseFile, + profileId +}: ProfileStateAuthorityBootstrapOptions): void { + const temporaryDatabaseFile = `${databaseFile}.empty.${process.pid}.${randomUUID()}.tmp` + let published = false + try { + const opened = openProfileStateDatabase(temporaryDatabaseFile, profileId) + opened.db.close() + if (classifyProfileStateStorage(dataFile, databaseFile) !== 'neither') { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state storage changed while creating an empty database' + ) + } + assertProfileStateCanInitialize({ dataFile, databaseFile, profileId }) + if (!publishProfileStateDatabase(temporaryDatabaseFile, databaseFile)) { + throw new ProfileStateAuthorityBootstrapError( + 'Profile state storage changed while creating an empty database' + ) + } + published = true + } finally { + if (!published) { + for (const path of profileStateDatabaseFiles(temporaryDatabaseFile)) { + rmSync(path, { force: true }) + } + } + } +} + +function migrateJsonOnlyProfile( + options: ProfileStateAuthorityBootstrapOptions +): ProfileStateAuthorityBootstrapResult { + const rawJson = readFileSync(options.dataFile, 'utf8') + const { prepared, unboundPaneAliases } = prepareLegacyProfileState(options.dataFile, rawJson) + const migrated = migrateProfileStateToSqlite({ + ...options, + expectedLegacyJson: rawJson, + serializedState: prepared.json + }) + prepared.commit() + return { + classification: 'json-only', + ...migrated, + initialState: { ...migrated.initialState, unboundPaneAliases }, + migrated: true + } +} + +function prepareLegacyProfileState(dataFile: string, rawJson: string) { + try { + return prepareLegacySnapshot(dataFile, rawJson) + } catch (error) { + // Import the first usable legacy backup without overwriting the damaged source. + for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { + const path = profileStateLegacyBackupPath(dataFile, index) + if (!existsSync(path)) { + continue + } + try { + const prepared = prepareLegacySnapshot(dataFile, readFileSync(path, 'utf8')) + console.warn(`[profile-state] Recovered legacy state from ${path}`) + return prepared + } catch { + // A corrupt backup must not prevent trying the remaining legacy ring. + } + } + throw new ProfileStateAuthorityBootstrapError( + `Failed to load imported profile state or its legacy backups: ${dataFile}. ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function prepareLegacySnapshot(dataFile: string, serializedState: string) { + const unboundPaneAliases: ProfileStateStartupPaneAlias[] = [] + const store = new Store({ + dataFile, + serializedState, + collectUnboundPaneAlias: (entry) => unboundPaneAliases.push(entry) + }) + return { prepared: store.prepareProfileStateExport(), unboundPaneAliases } +} diff --git a/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts new file mode 100644 index 00000000000..b53dc25fd69 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-export-fencing.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +describe('profile export snapshot revision', () => { + it.each(['json', 'compatibility-sync', 'compatibility-async'] as const)( + 'refuses a competing revision before publishing %s', + async (kind) => { + const root = mkdtempSync(join(tmpdir(), 'orca-export-fence-')) + const database = join(root, 'profile-state.db') + const target = join(root, 'retained.json') + const owner = new ProfileStateSqliteAuthority(database, 'export-fence') + const peer = new ProfileStateSqliteAuthority(database, 'export-fence') + try { + owner.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + writeFileSync(target, 'retained export') + owner.assertCurrentRevision() + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"light"}' }]) + await expect( + Promise.resolve().then(() => { + if (kind === 'json') { + return owner.writeJsonExport(target) + } + if (kind === 'compatibility-sync') { + return owner.writeJsonCompatibilityExport(target) + } + return owner.writeJsonCompatibilityExportAsync(target) + }) + ).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + expect(readFileSync(target, 'utf8')).toBe('retained export') + expect(JSON.parse(peer.readSerializedState() ?? '{}').settings.theme).toBe('light') + } finally { + owner.close() + peer.close() + rmSync(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-authority-exports.ts b/src/main/persistence/profile-state/profile-state-authority-exports.ts new file mode 100644 index 00000000000..39e4027415c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-authority-exports.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + durableWriteTempPath, + writeFileDurable, + writeFileDurableSync +} from '../../durable-file-write' +import { + readProfileStateSnapshot, + stageProfileStateJsonCompatibility, + acceptProfileStateJsonCompatibility +} from './profile-state-documents' +import type Database from '../../sqlite/sync-database' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +function readExportSnapshot(db: Database.Database, expectedRevision?: number) { + const snapshot = readProfileStateSnapshot(db) + if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) { + throw new ProfileStateRevisionConflictError(expectedRevision, snapshot.revision) + } + return snapshot +} + +/** Publish a durable JSON rollback/compatibility export without changing authority. */ +export function writeProfileStateAuthorityJsonExport( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): number { + const snapshot = readExportSnapshot(db, expectedRevision) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + return snapshot.revision +} + +/** Stage both accepted versions before replacing canonical JSON for an older build. */ +export function writeProfileStateAuthorityCompatibilityExport( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): number | undefined { + const snapshot = readExportSnapshot(db, expectedRevision) + if (snapshot.revision === 0) { + return undefined + } + writeCompatibilityRecoveryExport(targetPath, snapshot) + const retained = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : undefined + mkdirSync(dirname(targetPath), { recursive: true }) + stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) + return snapshot.revision +} + +export async function writeProfileStateAuthorityCompatibilityExportAsync( + db: Database.Database, + targetPath: string, + expectedRevision?: number +): Promise { + const snapshot = readExportSnapshot(db, expectedRevision) + if (snapshot.revision === 0) { + return undefined + } + writeCompatibilityRecoveryExport(targetPath, snapshot) + const retained = await readFile(targetPath, 'utf8').catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + await mkdir(dirname(targetPath), { recursive: true }) + stageProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision, retained) + await writeFileDurable(durableWriteTempPath(targetPath), targetPath, snapshot.json) + acceptProfileStateJsonCompatibility(db, snapshot.json, snapshot.revision) + return snapshot.revision +} + +function writeCompatibilityRecoveryExport( + dataFile: string, + snapshot: { json: string; revision: number } +): void { + writeVersionedProfileStateExport(dataFile, (path) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(path), path, snapshot.json) + return snapshot.revision + }) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts new file mode 100644 index 00000000000..8ac387db9aa --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-equality.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { clearProfileStateAutomationRuns } from './profile-state-automation-runs' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const databases: { db: ReturnType['db']; directory: string }[] = [] + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-history-equality-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + const runs = [{ id: 'run', extension: { content: '雪 🐋' } }] + const payload = JSON.stringify(runs) + importProfileStateJson(db, JSON.stringify({ settings: {}, automationRuns: runs })) + const result = { db, directory, runs, payload } + databases.push(result) + return result +} + +afterEach(() => { + for (const { db, directory } of databases.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('automation history replacement equality', () => { + it.each([false, true])( + 'keeps revision and timestamp for equal history (whitespace: %s)', + (spaces) => { + const { db, payload, runs } = fixture() + const before = readProfileStateSnapshot(db) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'automationRuns', + payload: spaces ? JSON.stringify(runs, null, 2) : payload, + now: () => { + throw new Error('An unchanged replacement must not request a timestamp') + } + } + ] + }) + ).toEqual({ changed: false, revision: 1, changedDomains: [] }) + expect(readProfileStateSnapshot(db)).toEqual(before) + } + ) + + it.each([ + { + payload: '[{"id":"ignored","id":"run","extension":{"value":1,"value":2}}, {"id":"next"}]', + presence: 'array' + }, + { payload: '[{"id":"same"},{"id":"same","extension":true}]', presence: 'document' }, + { payload: '[{"extension":true}]', presence: 'document' }, + { payload: '[]', presence: 'array' }, + { payload: 'null', presence: 'null' }, + { payload: null, presence: 'absent' } + ])('preserves canonical JSON and storage transitions for $payload', ({ payload, presence }) => { + const { db } = fixture() + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: {}, + ...(payload === null ? {} : { automationRuns: JSON.parse(payload) }) + }) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence + }) + if (presence === 'array' || presence === 'null') { + expect( + db.prepare('SELECT content_hash FROM profile_state_automation_runs_meta').get() + ).toEqual({ + content_hash: hashProfileStateJson(JSON.stringify(JSON.parse(payload ?? 'null'))) + }) + } + }) + + it('derives prepared history from the checked payload instead of caller metadata', () => { + const { db } = fixture() + const replacement = { + domain: 'automationRuns', + payload: '[{"id":"actual"}]', + automationRunsValue: [{ id: 'forged' }] + } + + writeProfileStateDomains(db, { expectedRevision: 1, replacements: [replacement] }) + + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: {}, + automationRuns: [{ id: 'actual' }] + }) + }) + + it('fences a stale caller even when its history still matches', () => { + const { db, payload } = fixture() + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'settings', payload: '{"theme":"dark"}' }] + }) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateRevision(db)).toBe(2) + }) + + it('rejects malformed JSON before trusting an equal stored hash', () => { + const { db } = fixture() + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson('[') + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[' }] + }) + ).toThrow('Profile state domain payload is invalid JSON: automationRuns') + expect(db.isTransaction).toBe(false) + expect(readProfileStateRevision(db)).toBe(1) + }) + + it('normalizes an equal document payload when normalized storage is not established', () => { + const { db, payload } = fixture() + clearProfileStateAutomationRuns(db) + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'automationRuns'" + ).run(payload, hashProfileStateJson(payload)) + const before = readProfileStateSnapshot(db) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + expect(readProfileStateSnapshot(db).json).toBe(before.json) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence: 'array' + }) + }) + + it('rejects a corrupt document placeholder before accepting equal normalized history', () => { + const { db, payload } = fixture() + db.prepare( + "UPDATE profile_state_documents SET payload = 'true' WHERE domain = 'automationRuns'" + ).run() + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload }] + }) + ).toThrow('Profile state document hash mismatch: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts b/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts new file mode 100644 index 00000000000..adf99619242 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-migration.ts @@ -0,0 +1,46 @@ +import type Database from '../../sqlite/sync-database' +import { + readProfileStateRevision, + assertProfileStateDocumentRevision +} from './profile-state-revision' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' +import { + ProfileStateDocumentCorruptionError, + validateProfileStateDocumentRow +} from './profile-state-document-validation' +import { + markAutomationRunsDocumentStorage, + compactAutomationRunsDocument +} from './profile-state-automation-runs-storage' + +export function migrateAutomationRunsStorage(db: Database.Database, storedVersion: number): void { + if (storedVersion < 2) { + markAutomationRunsDocumentStorage(db) + } else { + const meta = db + .prepare('SELECT domain FROM profile_state_automation_runs_meta WHERE domain = ?') + .get('automationRuns') + if (meta === undefined) { + // Schema 2 cannot distinguish cleared history from a lost projection marker. + if ( + readProfileStateRevision(db) !== 0 || + db.prepare('SELECT 1 FROM profile_state_documents LIMIT 1').get() !== undefined || + db.prepare('SELECT 1 FROM profile_state_automation_runs LIMIT 1').get() !== undefined + ) { + throw new ProfileStateDocumentCorruptionError( + 'Schema 2 automationRuns storage is ambiguous; restore a validated backup or JSON export', + 'automationRuns' + ) + } + markAutomationRunsDocumentStorage(db) + } + } + const revision = readProfileStateRevision(db) + for (const row of db.prepare('SELECT * FROM profile_state_documents').all()) { + const document = validateProfileStateDocumentRow(row) + assertProfileStateDocumentRevision(document.revision, revision, document.domain) + } + if (readProfileStateAutomationRunsDocument(db, revision) !== undefined) { + compactAutomationRunsDocument(db) + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-model.ts b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts new file mode 100644 index 00000000000..b02418cf8ea --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-model.ts @@ -0,0 +1,49 @@ +export const PROFILE_STATE_AUTOMATION_RUNS_TABLE = 'profile_state_automation_runs' +export const PROFILE_STATE_AUTOMATION_RUNS_META_TABLE = 'profile_state_automation_runs_meta' + +export const AUTOMATION_RUNS_DOMAIN = 'automationRuns' +export const AUTOMATION_RUNS_ABSENT = 'absent' +export const AUTOMATION_RUNS_NULL = 'null' +export const AUTOMATION_RUNS_ARRAY = 'array' +export const AUTOMATION_RUNS_DOCUMENT = 'document' + +export type AutomationRunsPresence = + | typeof AUTOMATION_RUNS_DOCUMENT + | typeof AUTOMATION_RUNS_ABSENT + | typeof AUTOMATION_RUNS_NULL + | typeof AUTOMATION_RUNS_ARRAY + +export type AutomationRunsMeta = { + presence: AutomationRunsPresence + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type AutomationRunPayload = { + id: string + ordinal: number + payload: string + contentHash: string +} + +export type NormalizedAutomationRunRow = AutomationRunPayload & { + revision: number + updatedAt: number +} + +export type AutomationRunIdentity = { + id: string + ordinal: number + contentHash: string +} + +export type ParsedAutomationRunsReplacement = + | { presence: typeof AUTOMATION_RUNS_ABSENT; contentHash: ''; runs?: undefined } + | { presence: typeof AUTOMATION_RUNS_NULL; contentHash: string; runs?: undefined } + | { + presence: typeof AUTOMATION_RUNS_ARRAY + contentHash: string + runs: readonly AutomationRunPayload[] + } diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts new file mode 100644 index 00000000000..32da30b8784 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-payload.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto' +import { hashProfileStatePayload, isRecord } from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_NULL, + type AutomationRunPayload, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' + +export function parseAutomationRunsReplacement( + payload: string | null +): ParsedAutomationRunsReplacement | undefined { + if (payload === null) { + return parseAutomationRunsValue(undefined) + } + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return undefined + } + return parseAutomationRunsValue(parsed) +} + +export function parseAutomationRunsValue( + value: unknown +): ParsedAutomationRunsReplacement | undefined { + if (value === undefined) { + return { presence: AUTOMATION_RUNS_ABSENT, contentHash: '' } + } + if (value === null) { + return { presence: AUTOMATION_RUNS_NULL, contentHash: hashProfileStatePayload('null') } + } + if (!Array.isArray(value)) { + return undefined + } + return parseAutomationRunValues(value) +} + +export function parseAutomationRunValues( + values: readonly unknown[] +): ParsedAutomationRunsReplacement | undefined { + const ids = new Set() + const runs: AutomationRunPayload[] = [] + const aggregate = createHash('sha256').update('[') + for (const [ordinal, value] of values.entries()) { + if (!isRecord(value) || typeof value.id !== 'string' || ids.has(value.id)) { + return undefined + } + const runPayload = JSON.stringify(value) + if (runPayload === undefined) { + return undefined + } + if (ordinal > 0) { + aggregate.update(',') + } + aggregate.update(runPayload, 'utf8') + ids.add(value.id) + runs.push({ + id: value.id, + ordinal, + payload: runPayload, + contentHash: hashProfileStatePayload(runPayload) + }) + } + return { + presence: AUTOMATION_RUNS_ARRAY, + contentHash: aggregate.update(']').digest('hex'), + runs + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts new file mode 100644 index 00000000000..c8e4087a1a8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-reader.ts @@ -0,0 +1,151 @@ +import { createHash } from 'node:crypto' +import type Database from '../../sqlite/sync-database' +import { readProfileStateRevision } from './profile-state-revision' +import { + hashProfileStatePayload, + ProfileStateDocumentCorruptionError, + type ProfileStateDocument, + type ProfileStateParsedDocument, + type ProfileStateValidatedDocument +} from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_DOMAIN, + AUTOMATION_RUNS_NULL, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunsMeta +} from './profile-state-automation-runs-model' +import { + assertNoNormalizedAutomationRuns, + parseNormalizedAutomationRunRow +} from './profile-state-automation-runs-validation' + +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' + +type Representation = 'serialized' | 'parsed' | 'validated' +type ReadDocument = + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + +/** Undefined means explicit document storage; null means the domain is absent. */ +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision?: number +): ProfileStateDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision: number, + representation: 'parsed' +): ProfileStateParsedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision: number, + representation: 'validated' +): ProfileStateValidatedDocument | null | undefined +export function readProfileStateAutomationRunsDocument( + db: Database.Database, + profileRevision = readProfileStateRevision(db), + representation: Representation = 'serialized' +): ReadDocument | null | undefined { + const meta = readCurrentAutomationRunsState(db, profileRevision) + if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { + return undefined + } + if (meta.presence === AUTOMATION_RUNS_ABSENT) { + assertNoNormalizedAutomationRuns(db) + return null + } + if (meta.presence === AUTOMATION_RUNS_NULL) { + if (meta.contentHash !== hashProfileStatePayload('null')) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns null hash mismatch', + AUTOMATION_RUNS_DOMAIN + ) + } + assertNoNormalizedAutomationRuns(db) + return makeAutomationRunsDocument( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: null } + : { payload: 'null' } + ) + } + + // Sort only stable keys; payloads stay outside the sorter and extra columns cannot shadow the key. + const references = db.prepare( + `SELECT run_id FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ORDER BY ordinal` + ) + const row = db.prepare( + `SELECT run_id, ordinal, payload, content_hash, revision, updated_at FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?` + ) + const payloads: string[] = [] + const values: unknown[] = [] + const aggregate = createHash('sha256').update('[') + const ids = new Set() + let index = 0 + for (const reference of references.iterate()) { + const parsed = parseNormalizedAutomationRunRow( + row.get(reference.run_id), + representation === 'parsed' + ) + if (parsed.id !== reference.run_id || parsed.ordinal !== index || ids.has(parsed.id)) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns ordering is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + parsed.revision > meta.revision || + (parsed.revision === meta.revision && parsed.updatedAt !== meta.updatedAt) + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row metadata is inconsistent', + AUTOMATION_RUNS_DOMAIN + ) + } + ids.add(parsed.id) + if (index > 0) { + aggregate.update(',') + } + aggregate.update(parsed.payload, 'utf8') + if (representation === 'parsed') { + values.push(parsed.value) + } else if (representation === 'serialized') { + payloads.push(parsed.payload) + } + index++ + } + const contentHash = aggregate.update(']').digest('hex') + if (contentHash !== meta.contentHash) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns aggregate hash mismatch', + AUTOMATION_RUNS_DOMAIN + ) + } + return makeAutomationRunsDocument( + meta, + representation === 'validated' + ? {} + : representation === 'parsed' + ? { value: values } + : { payload: `[${payloads.join(',')}]` } + ) +} + +function makeAutomationRunsDocument( + meta: AutomationRunsMeta, + content: { payload: string } | { value: unknown } | Record +): ReadDocument { + return { + domain: AUTOMATION_RUNS_DOMAIN, + ...content, + domainVersion: meta.domainVersion, + revision: meta.revision, + updatedAt: meta.updatedAt, + contentHash: meta.contentHash + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts b/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts new file mode 100644 index 00000000000..389498aa364 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-serialization.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { openProfileStateDatabase } from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +describe('automation history serialization', () => { + it.each(['import', 'replacement', 'delta'] as const)( + 'preserves JSON ordering, escaping and unknown fields through %s', + (operation) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-automation-run-serialization-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected an automation run fixture') + } + const runs = [ + { + ...fixtureRun, + id: 'second', + extension: { + '3': 3, + '1': 1, + z: '雪 🐋\ud800', + a: ['\\', '\n', '"', null], + omitted: undefined + } + }, + { ...fixtureRun, id: 'first', extension: { fractional: -0 } } + ] + const settings = { note: 'unchanged' } + const expected = JSON.stringify({ settings, automationRuns: runs }) + importProfileStateJson(db, JSON.stringify({ settings, automationRuns: runs.toReversed() })) + if (operation === 'import') { + importProfileStateJson(db, expected, { expectedRevision: 1 }) + } else { + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: + operation === 'replacement' + ? [{ domain: 'automationRuns', payload: JSON.stringify(runs) }] + : [], + ...(operation === 'delta' ? { automationRunsAfter: runs } : {}) + }) + } + + expect(readProfileStateSnapshot(db)).toMatchObject({ revision: 2, json: expected }) + expect( + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: runs + }) + ).toEqual({ changed: false, revision: 2, changedDomains: [] }) + } finally { + db.close() + rmSync(directory, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts b/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts new file mode 100644 index 00000000000..c9ee590d369 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-storage.ts @@ -0,0 +1,49 @@ +import type Database from '../../sqlite/sync-database' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { assertProfileStateDocumentRevision } from './profile-state-revision' +import { + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_DOMAIN, + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + type AutomationRunsMeta +} from './profile-state-automation-runs-model' +import { + assertNoNormalizedAutomationRuns, + parseAutomationRunsMeta +} from './profile-state-automation-runs-validation' + +export function readCurrentAutomationRunsState( + db: Database.Database, + actualRevision: number +): AutomationRunsMeta { + const row = db + .prepare( + `SELECT domain, presence, domain_version, revision, updated_at, content_hash + FROM ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} WHERE domain = ?` + ) + .get(AUTOMATION_RUNS_DOMAIN) + const meta = parseAutomationRunsMeta(row) + assertProfileStateDocumentRevision(meta.revision, actualRevision, AUTOMATION_RUNS_DOMAIN) + if (meta.presence === AUTOMATION_RUNS_DOCUMENT) { + assertNoNormalizedAutomationRuns(db) + } + return meta +} + +export function markAutomationRunsDocumentStorage(db: Database.Database): void { + db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + (domain, presence, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, 1, 0, 0, '') + ON CONFLICT(domain) DO UPDATE SET presence = excluded.presence, + domain_version = 1, revision = 0, updated_at = 0, content_hash = ''` + ).run(AUTOMATION_RUNS_DOMAIN, AUTOMATION_RUNS_DOCUMENT) +} + +/** Retain the key's JSON position without retaining a second history payload. */ +export function compactAutomationRunsDocument(db: Database.Database): void { + db.prepare( + `UPDATE profile_state_documents SET payload = 'null', content_hash = ? + WHERE domain = ? AND payload <> 'null'` + ).run(hashProfileStatePayload('null'), AUTOMATION_RUNS_DOMAIN) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts b/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts new file mode 100644 index 00000000000..98d0f79cc4b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-validation.ts @@ -0,0 +1,154 @@ +import type Database from '../../sqlite/sync-database' +import { + hashProfileStatePayload, + isRecord, + ProfileStateDocumentCorruptionError +} from './profile-state-document-validation' +import { + AUTOMATION_RUNS_ABSENT, + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_DOMAIN, + AUTOMATION_RUNS_DOCUMENT, + AUTOMATION_RUNS_NULL, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunIdentity, + type AutomationRunsMeta, + type NormalizedAutomationRunRow +} from './profile-state-automation-runs-model' + +export function assertNoNormalizedAutomationRuns(db: Database.Database): void { + const row = db + .prepare(`SELECT COUNT(*) AS count FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`) + .get() + if (isRecord(row) && row.count !== 0) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns rows exist for an empty domain', + AUTOMATION_RUNS_DOMAIN + ) + } +} + +export function parseAutomationRunsMeta(row: unknown): AutomationRunsMeta { + if ( + !isRecord(row) || + row.domain !== AUTOMATION_RUNS_DOMAIN || + (row.presence !== AUTOMATION_RUNS_ABSENT && + row.presence !== AUTOMATION_RUNS_DOCUMENT && + row.presence !== AUTOMATION_RUNS_NULL && + row.presence !== AUTOMATION_RUNS_ARRAY) || + typeof row.domain_version !== 'number' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + typeof row.content_hash !== 'string' + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns metadata is malformed', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + !Number.isSafeInteger(row.domain_version) || + row.domain_version < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < (row.presence === AUTOMATION_RUNS_DOCUMENT ? 0 : 1) || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + (row.presence === AUTOMATION_RUNS_ABSENT || row.presence === AUTOMATION_RUNS_DOCUMENT + ? row.content_hash !== '' + : !/^[a-f0-9]{64}$/.test(row.content_hash)) + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns metadata is invalid', + AUTOMATION_RUNS_DOMAIN + ) + } + if ( + row.presence === AUTOMATION_RUNS_DOCUMENT && + (row.revision !== 0 || row.updated_at !== 0 || row.domain_version !== 1) + ) { + throw new ProfileStateDocumentCorruptionError( + 'AutomationRuns document storage marker is invalid', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + presence: row.presence, + domainVersion: row.domain_version, + revision: row.revision, + updatedAt: row.updated_at, + contentHash: row.content_hash + } +} + +export function parseNormalizedAutomationRunRow( + row: unknown, + retainParsedValue = false +): NormalizedAutomationRunRow & { value?: unknown } { + if ( + !isRecord(row) || + typeof row.run_id !== 'string' || + typeof row.ordinal !== 'number' || + typeof row.payload !== 'string' || + typeof row.content_hash !== 'string' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 0 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + hashProfileStatePayload(row.payload) !== row.content_hash + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + // Individually invalid fragments can splice into a valid aggregate with matching IDs. + let parsed: unknown + try { + parsed = JSON.parse(row.payload) + } catch { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is invalid JSON', + AUTOMATION_RUNS_DOMAIN + ) + } + if (!isRecord(parsed) || parsed.id !== row.run_id) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row identity is corrupt', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + id: row.run_id, + ordinal: row.ordinal, + payload: row.payload, + contentHash: row.content_hash, + revision: row.revision, + updatedAt: row.updated_at, + ...(retainParsedValue ? { value: parsed } : {}) + } +} + +export function parseAutomationRunIdentity(row: unknown): AutomationRunIdentity { + if ( + !isRecord(row) || + typeof row.run_id !== 'string' || + typeof row.ordinal !== 'number' || + typeof row.content_hash !== 'string' || + !Number.isSafeInteger(row.ordinal) || + row.ordinal < 0 + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns row is malformed', + AUTOMATION_RUNS_DOMAIN + ) + } + return { + id: row.run_id, + ordinal: row.ordinal, + contentHash: row.content_hash + } +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts new file mode 100644 index 00000000000..31f4eb4f1d1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs-writer.ts @@ -0,0 +1,158 @@ +import type Database from '../../sqlite/sync-database' +import type { PreparedProfileStateMutation } from './profile-state-domain-write-validation' +import { + AUTOMATION_RUNS_ARRAY, + AUTOMATION_RUNS_DOMAIN, + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE, + type AutomationRunIdentity, + type ParsedAutomationRunsReplacement +} from './profile-state-automation-runs-model' +import { + parseAutomationRunValues, + parseAutomationRunsReplacement, + parseAutomationRunsValue +} from './profile-state-automation-runs-payload' +import { parseAutomationRunIdentity } from './profile-state-automation-runs-validation' +import { + readCurrentAutomationRunsState, + compactAutomationRunsDocument +} from './profile-state-automation-runs-storage' + +export type AutomationRunsWritePreparation = { + changed: boolean + incoming: ParsedAutomationRunsReplacement + domainVersion: number + now?: () => number +} + +export function rebuildProfileStateAutomationRunsProjection( + db: Database.Database, + payload: string, + domainVersion: number, + updatedAt: number, + revision: number +): boolean { + const incoming = parseAutomationRunsReplacement(payload) + if (incoming === undefined) { + return false + } + const now = resolveAutomationRunsTimestamp(() => updatedAt) + applyIncomingAutomationRuns(db, incoming, domainVersion, now, revision) + return true +} + +export function prepareProfileStateAutomationRunsReplacement( + db: Database.Database, + replacement: PreparedProfileStateMutation, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const current = readCurrentAutomationRunsState(db, actualRevision) + const incoming = parseAutomationRunsValue(replacement.automationRunsValue) + if (incoming === undefined) { + return undefined + } + return { + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, + incoming, + domainVersion: replacement.domainVersion, + now: replacement.now + } +} + +export function prepareProfileStateAutomationRunsDelta( + db: Database.Database, + after: readonly unknown[], + domainVersion: number, + now: () => number, + actualRevision: number +): AutomationRunsWritePreparation | undefined { + const incoming = parseAutomationRunValues(after) + if (incoming === undefined) { + return undefined + } + const current = readCurrentAutomationRunsState(db, actualRevision) + return { + changed: current.presence !== incoming.presence || current.contentHash !== incoming.contentHash, + incoming, + domainVersion, + now + } +} + +export function applyProfileStateAutomationRuns( + db: Database.Database, + preparation: AutomationRunsWritePreparation, + nextRevision: number +): void { + const now = resolveAutomationRunsTimestamp(preparation.now ?? Date.now) + applyIncomingAutomationRuns( + db, + preparation.incoming, + preparation.domainVersion, + now, + nextRevision + ) +} + +function applyIncomingAutomationRuns( + db: Database.Database, + incoming: ParsedAutomationRunsReplacement, + domainVersion: number, + now: number, + nextRevision: number +): void { + const existingRows = new Map() + for (const row of db + .prepare(`SELECT run_id, ordinal, content_hash FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`) + .all()) { + const parsed = parseAutomationRunIdentity(row) + existingRows.set(parsed.id, parsed) + } + + const upsert = db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} + (run_id, ordinal, payload, content_hash, revision, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET ordinal = excluded.ordinal, + payload = excluded.payload, content_hash = excluded.content_hash, + revision = excluded.revision, updated_at = excluded.updated_at` + ) + if (incoming.presence === AUTOMATION_RUNS_ARRAY) { + for (const run of incoming.runs) { + const existing = existingRows.get(run.id) + existingRows.delete(run.id) + if (existing?.ordinal === run.ordinal && existing.contentHash === run.contentHash) { + continue + } + upsert.run(run.id, run.ordinal, run.payload, run.contentHash, nextRevision, now) + } + } + const remove = db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} WHERE run_id = ?`) + for (const id of existingRows.keys()) { + remove.run(id) + } + + db.prepare( + `INSERT INTO ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} + (domain, presence, domain_version, revision, updated_at, content_hash) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET presence = excluded.presence, + domain_version = excluded.domain_version, revision = excluded.revision, + updated_at = excluded.updated_at, content_hash = excluded.content_hash` + ).run( + AUTOMATION_RUNS_DOMAIN, + incoming.presence, + domainVersion, + nextRevision, + now, + incoming.contentHash + ) + compactAutomationRunsDocument(db) +} + +function resolveAutomationRunsTimestamp(nowFactory: () => number): number { + const now = nowFactory() + if (!Number.isSafeInteger(now) || now < 0) { + throw new Error('Profile state domain update timestamp is invalid: automationRuns') + } + return now +} diff --git a/src/main/persistence/profile-state/profile-state-automation-runs.ts b/src/main/persistence/profile-state/profile-state-automation-runs.ts new file mode 100644 index 00000000000..8b81cf07019 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-runs.ts @@ -0,0 +1,22 @@ +import type Database from '../../sqlite/sync-database' +import { markAutomationRunsDocumentStorage } from './profile-state-automation-runs-storage' +import { PROFILE_STATE_AUTOMATION_RUNS_TABLE } from './profile-state-automation-runs-model' + +export { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' +export type { AutomationRunPayload } from './profile-state-automation-runs-model' +export type { AutomationRunsWritePreparation } from './profile-state-automation-runs-writer' +export { + applyProfileStateAutomationRuns, + prepareProfileStateAutomationRunsDelta, + prepareProfileStateAutomationRunsReplacement, + rebuildProfileStateAutomationRunsProjection +} from './profile-state-automation-runs-writer' +export { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs-reader' + +export function clearProfileStateAutomationRuns(db: Database.Database): void { + db.prepare(`DELETE FROM ${PROFILE_STATE_AUTOMATION_RUNS_TABLE}`).run() + markAutomationRunsDocumentStorage(db) +} diff --git a/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts b/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts new file mode 100644 index 00000000000..bb28c491f0e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-automation-storage-upgrade.test.ts @@ -0,0 +1,380 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { openProfileStateDatabase, profileStatePragmaNumber } from './profile-state-database' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' +import { hashProfileStatePayload } from './profile-state-document-validation' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { readProfileStateDomain } from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' + +const directories: string[] = [] +const connections: Database.Database[] = [] +const staleRuns = [{ id: 'deleted-run', status: 'running', output: 'stale history'.repeat(1_000) }] +const liveRuns = [{ id: 'live-run', status: 'completed', future: { retained: true } }] + +afterEach(() => { + for (const db of connections.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function databasePath(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-automation-upgrade-')) + directories.push(directory) + return join(directory, 'profile-state.db') +} + +function openDatabase(path: string): Database.Database { + const { db } = openProfileStateDatabase(path, 'profile-a') + connections.push(db) + return db +} + +function expectRejectedDatabaseUntouched(path: string, profileId = 'profile-a'): void { + const before = readFileSync(path) + expect(() => { + const { db } = openProfileStateDatabase(path, profileId) + connections.push(db) + }).toThrowError( + expect.objectContaining({ + code: profileId === 'profile-a' ? 'unreadable' : 'identity-mismatch' + }) + ) + expect(readFileSync(path)).toEqual(before) +} + +type LegacyProjection = { + presence: 'array' | 'null' | 'absent' + runs?: readonly { id: string; [key: string]: unknown }[] +} + +function seedLegacyDatabase( + version: 1 | 2, + root: Record, + projection?: LegacyProjection, + revision = projection ? 2 : 1 +): string { + const path = databasePath() + const db = new Database(path) + try { + db.exec(` + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + `) + db.prepare('INSERT INTO profile_state_meta VALUES (?, ?)').run('profile_id', 'profile-a') + db.prepare('INSERT INTO profile_state_meta VALUES (?, ?)').run('revision', String(revision)) + for (const [domain, value] of Object.entries(root)) { + const payload = JSON.stringify(value) + db.prepare('INSERT INTO profile_state_documents VALUES (?, ?, 1, 1, 100, ?)').run( + domain, + payload, + hashProfileStatePayload(payload) + ) + } + if (version === 2) { + db.exec(` + CREATE TABLE profile_state_automation_runs_meta ( + domain TEXT PRIMARY KEY NOT NULL, presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + CREATE TABLE profile_state_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + `) + if (projection) { + const runs = projection.runs ?? [] + const payload = projection.presence === 'array' ? JSON.stringify(runs) : 'null' + db.prepare( + 'INSERT INTO profile_state_automation_runs_meta VALUES (?, ?, 1, 2, 200, ?)' + ).run( + 'automationRuns', + projection.presence, + projection.presence === 'absent' ? '' : hashProfileStatePayload(payload) + ) + for (const [ordinal, run] of runs.entries()) { + const runPayload = JSON.stringify(run) + db.prepare('INSERT INTO profile_state_automation_runs VALUES (?, ?, ?, ?, 2, 200)').run( + run.id, + ordinal, + runPayload, + hashProfileStatePayload(runPayload) + ) + } + } + } + db.pragma(`user_version = ${version}`) + } finally { + db.close() + } + return path +} + +describe('automation storage schema upgrades', () => { + it.each([ + { label: 'supported runs', value: liveRuns }, + { label: 'explicit null', value: null }, + { label: 'unknown object', value: { futureFormat: [1, 2] } }, + { label: 'duplicate identifiers', value: [{ id: 'same' }, { id: 'same', future: true }] } + ])('preserves version 1 $label and domain ordering', ({ value }) => { + const root = { settings: { theme: 'dark' }, automationRuns: value, futureDomain: [2, 1] } + const db = openDatabase(seedLegacyDatabase(1, root)) + + expect(profileStatePragmaNumber(db, 'user_version')).toBe(PROFILE_STATE_DATABASE_SCHEMA_VERSION) + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual({ + presence: 'document' + }) + expect(db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get()).toEqual( + { + count: 0 + } + ) + }) + + it('normalizes a version 1 document on replacement without duplicating its payload', () => { + const root = { settings: {}, automationRuns: liveRuns, futureDomain: [2, 1] } + const db = openDatabase(seedLegacyDatabase(1, root)) + + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(liveRuns), + expectedRevision: 1 + }) + ).toEqual({ changed: true, revision: 2 }) + + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + }) + + it.each([ + { presence: 'array', runs: liveRuns, expected: liveRuns }, + { presence: 'array', runs: [], expected: [] }, + { presence: 'null', expected: null }, + { presence: 'absent', expected: undefined } + ] as const)( + 'keeps version 2 $presence authority while removing stale retained history', + (value) => { + const root = { settings: {}, automationRuns: staleRuns, futureDomain: { kept: true } } + const path = seedLegacyDatabase(2, root, value) + const db = openDatabase(path) + const expected = { ...root, automationRuns: value.expected } + + expect(exportProfileStateJson(db)).toBe(JSON.stringify(expected)) + expect(readProfileStateDomain(path, 'profile-a', 'automationRuns')).toEqual( + value.expected === undefined + ? { kind: 'missing' } + : { kind: 'value', value: value.expected } + ) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + expect(profileStatePragmaNumber(db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + } + ) + + it.each([ + { label: 'stale run array', root: { automationRuns: staleRuns } }, + { label: 'empty array', root: { automationRuns: [] } }, + { label: 'explicit null', root: { automationRuns: null } }, + { label: 'unknown value', root: { automationRuns: { futureFormat: true } } }, + { label: 'absent domain', root: {} } + ])('refuses ambiguous version 2 $label without rewriting it', ({ root }) => { + const path = seedLegacyDatabase(2, root) + expectRejectedDatabaseUntouched(path) + const db = new Database(path) + connections.push(db) + expect(profileStatePragmaNumber(db, 'user_version')).toBe(2) + expect(db.prepare('SELECT domain FROM profile_state_automation_runs_meta').all()).toEqual([]) + expect( + db.prepare('SELECT domain, payload FROM profile_state_documents ORDER BY rowid').all() + ).toEqual( + Object.entries(root).map(([domain, value]) => ({ domain, payload: JSON.stringify(value) })) + ) + }) + + it('upgrades a genuinely empty version 2 database', () => { + const db = openDatabase(seedLegacyDatabase(2, {}, undefined, 0)) + expect(exportProfileStateJson(db)).toBe('{}') + expect(importProfileStateJson(db, JSON.stringify({ automationRuns: liveRuns }))).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ automationRuns: liveRuns }) + }) + + it.each(['legacy document', 'normalized row', 'projection metadata'])( + 'rolls back version 2 migration with corrupt %s', + (target) => { + const path = seedLegacyDatabase( + 2, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const tampered = new Database(path) + try { + if (target === 'legacy document') { + tampered.exec("UPDATE profile_state_documents SET content_hash = 'broken'") + } else if (target === 'normalized row') { + tampered.exec("UPDATE profile_state_automation_runs SET content_hash = 'broken'") + } else { + tampered.exec("UPDATE profile_state_automation_runs_meta SET content_hash = 'broken'") + } + } finally { + tampered.close() + } + + expectRejectedDatabaseUntouched(path) + const db = new Database(path) + connections.push(db) + expect(profileStatePragmaNumber(db, 'user_version')).toBe(2) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: JSON.stringify(staleRuns) }) + } + ) +}) + +describe('rejected schema upgrades preserve source bytes', () => { + it.each([1, 2] as const)('rejects another profile in schema %s before migration', (version) => { + const path = seedLegacyDatabase( + version, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + expectRejectedDatabaseUntouched(path, 'profile-b') + }) + + it.each([1, 2] as const)( + 'rejects an incompatible schema %s document column before migration', + (version) => { + const path = seedLegacyDatabase( + version, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const db = new Database(path) + try { + db.exec(` + ALTER TABLE profile_state_documents RENAME TO old_documents; + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload BLOB NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + INSERT INTO profile_state_documents SELECT * FROM old_documents; + DROP TABLE old_documents; + `) + } finally { + db.close() + } + expectRejectedDatabaseUntouched(path) + } + ) + + it.each([ + { label: 'negative schema version', sql: 'PRAGMA user_version = -1' }, + { label: 'missing run table', sql: 'DROP TABLE profile_state_automation_runs' }, + { label: 'missing metadata table', sql: 'DROP TABLE profile_state_automation_runs_meta' } + ])('rejects a version 2 database with $label before migration', ({ sql }) => { + const path = seedLegacyDatabase( + 2, + { automationRuns: staleRuns }, + { presence: 'array', runs: liveRuns } + ) + const db = new Database(path) + try { + db.exec(sql) + } finally { + db.close() + } + expectRejectedDatabaseUntouched(path) + }) +}) + +describe('required automation storage metadata', () => { + it.each([ + { label: 'cleared array', payload: '[]' }, + { label: 'explicit null', payload: 'null' }, + { label: 'removed domain', payload: null } + ])('refuses lost metadata after $label instead of resurrecting retained state', ({ payload }) => { + const path = databasePath() + const db = openDatabase(path) + importProfileStateJson(db, JSON.stringify({ automationRuns: staleRuns })) + writeProfileStateDomain(db, { domain: 'automationRuns', payload, expectedRevision: 1 }) + db.exec('DELETE FROM profile_state_automation_runs_meta') + + expect(() => exportProfileStateJson(db)).toThrow() + expect(readProfileStateDomain(path, 'profile-a', 'automationRuns')).toMatchObject({ + kind: 'unreadable' + }) + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(liveRuns), + expectedRevision: 2 + }) + ).toThrow() + expect(db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get()).toEqual( + { count: 0 } + ) + }) + + it.each(['revision = 1', 'updated_at = 1', 'domain_version = 2', "content_hash = 'unexpected'"])( + 'rejects a malformed document marker (%s)', + (assignment) => { + const db = openDatabase(databasePath()) + importProfileStateJson(db, JSON.stringify({ automationRuns: { futureFormat: true } })) + db.exec(`UPDATE profile_state_automation_runs_meta SET ${assignment}`) + expect(() => exportProfileStateJson(db)).toThrow() + } + ) + + it('keeps one authority through complete imports of supported, unknown, and absent history', () => { + const db = openDatabase(databasePath()) + for (const root of [ + { settings: {}, automationRuns: liveRuns }, + { settings: {}, automationRuns: { futureFormat: [2, 1] } }, + { settings: {} }, + { settings: {}, automationRuns: null }, + { settings: {}, automationRuns: [] } + ]) { + importProfileStateJson(db, JSON.stringify(root)) + expect(exportProfileStateJson(db)).toBe(JSON.stringify(root)) + expect( + db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs_meta').get() + ).toEqual({ count: 1 }) + } + }) + + it('rejects reopening a current database with lost metadata before changing its bytes', () => { + const path = databasePath() + const { db } = openProfileStateDatabase(path, 'profile-a') + db.exec('DELETE FROM profile_state_automation_runs_meta') + db.close() + const before = readFileSync(path) + + expect(() => openDatabase(path)).toThrow() + expect(readFileSync(path)).toEqual(before) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-job.ts b/src/main/persistence/profile-state/profile-state-backup-job.ts new file mode 100644 index 00000000000..f10ef15368c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-job.ts @@ -0,0 +1,33 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { validateProfileStateSnapshot } from './profile-state-documents' + +export type ProfileStateBackupJob = { + databasePath: string + profileId: string + targetPath: string + temporaryPath?: string +} + +/** Own every connection until the copy and its strict validation finish. */ +export async function writeProfileStateBackup(job: ProfileStateBackupJob): Promise { + const opened = openProfileStateDatabaseReadOnly(job.databasePath, job.profileId) + try { + await writeProfileStateDatabaseSnapshotAsync(opened.db, job.targetPath, { + temporaryPath: job.temporaryPath, + validateStagedSnapshot: (stagingPath) => + validateProfileStateBackup(stagingPath, job.profileId) + }) + } finally { + opened.db.close() + } +} + +function validateProfileStateBackup(path: string, profileId: string): void { + const snapshot = openProfileStateDatabaseReadOnly(path, profileId) + try { + validateProfileStateSnapshot(snapshot.db) + } finally { + snapshot.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-backup-path.test.ts b/src/main/persistence/profile-state/profile-state-backup-path.test.ts new file mode 100644 index 00000000000..593d490ce11 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-path.test.ts @@ -0,0 +1,76 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupFiles, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' + +const directories: string[] = [] +afterEach(() => { + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +function location(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-backup-path-')) + directories.push(directory) + return join(directory, 'profile-state.db') +} + +describe('profile state database backup discovery', () => { + it('lists immutable backup IDs newest-first without opening SQLite', () => { + const path = location() + const first = createProfileStateDatabaseBackupId(1_000) + const last = createProfileStateDatabaseBackupId(2_000) + writeFileSync(profileStateDatabaseBackupPath(path, first), 'first') + writeFileSync(profileStateDatabaseBackupPath(path, last), 'second') + writeFileSync(`${path}.backup.${last}.db.tmp`, 'incomplete staging') + writeFileSync(`${path}.backup.invalid.db`, 'unrelated') + expect(profileStateDatabaseBackups(path)).toEqual([ + { id: last, path: profileStateDatabaseBackupPath(path, last), createdAtMs: 2_000 }, + { id: first, path: profileStateDatabaseBackupPath(path, first), createdAtMs: 1_000 } + ]) + }) + + it('keeps reserved artifact names visible when their type needs recovery', () => { + const path = location() + const id = createProfileStateDatabaseBackupId() + mkdirSync(profileStateDatabaseBackupPath(path, id)) + expect(profileStateDatabaseBackups(path).map((backup) => backup.id)).toEqual([id]) + }) + + it('includes every existing backup sidecar in the recovery archive inventory', () => { + const path = location() + const backup = profileStateDatabaseBackupPath(path, createProfileStateDatabaseBackupId()) + const files = [backup, `${backup}-wal`, `${backup}-shm`, `${backup}-journal`] + for (const file of files) { + writeFileSync(file, 'recovery evidence') + } + expect(profileStateDatabaseBackupFiles(path)).toEqual(files) + }) + + it.each([ + '../profile-state.db', + '1-../../outside', + '0-00000000-0000-4000-8000-000000000000', + '9007199254740992-00000000-0000-4000-8000-000000000000' + ])('rejects invalid or escaping IDs: %s', (id) => + expect(() => profileStateDatabaseBackupPath(location(), id)).toThrow('ID is invalid') + ) + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('rejects unsafe backup times: %s', (time) => { + expect(() => createProfileStateDatabaseBackupId(time)).toThrow('positive safe integer') + }) + + it('treats only a missing directory as an empty recovery inventory', () => { + const path = location() + expect(profileStateDatabaseBackups(join(path, 'profile-state.db'))).toEqual([]) + writeFileSync(path, 'not a directory') + expect(() => profileStateDatabaseBackups(join(path, 'profile-state.db'))).toThrow() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-path.ts b/src/main/persistence/profile-state/profile-state-backup-path.ts new file mode 100644 index 00000000000..03a65e37229 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-path.ts @@ -0,0 +1,67 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' + +const BACKUP_ID_PATTERN = + /^([1-9]\d*)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/ + +export type ProfileStateDatabaseBackup = { + id: string + path: string + createdAtMs: number +} + +export function createProfileStateDatabaseBackupId(now = Date.now()): string { + if (!Number.isSafeInteger(now) || now <= 0) { + throw new Error('Profile state backup timestamp must be a positive safe integer') + } + return `${now}-${randomUUID()}` +} + +export function profileStateDatabaseBackupPath(databaseFile: string, id: string): string { + if (parseBackupCreatedAt(id) === undefined) { + throw new Error('Profile state backup ID is invalid') + } + return `${databaseFile}.backup.${id}.db` +} + +/** Enumerate immutable recovery artifacts without opening the possibly damaged primary. */ +export function profileStateDatabaseBackups( + databaseFile: string +): readonly ProfileStateDatabaseBackup[] { + const directory = dirname(databaseFile) + const prefix = `${basename(databaseFile)}.backup.` + let entries: string[] + try { + entries = readdirSync(directory) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } + return entries + .flatMap((name) => { + if (typeof name !== 'string' || !name.startsWith(prefix) || !name.endsWith('.db')) { + return [] + } + const id = name.slice(prefix.length, -3) + const createdAtMs = parseBackupCreatedAt(id) + return createdAtMs === undefined ? [] : [{ id, path: join(directory, name), createdAtMs }] + }) + .sort((left, right) => right.createdAtMs - left.createdAtMs || right.id.localeCompare(left.id)) +} + +function parseBackupCreatedAt(id: string): number | undefined { + const match = BACKUP_ID_PATTERN.exec(id) + const timestamp = match ? Number(match[1]) : 0 + return Number.isSafeInteger(timestamp) && timestamp > 0 ? timestamp : undefined +} + +/** Preserve sidecars too if an external writer has opened an otherwise immutable backup. */ +export function profileStateDatabaseBackupFiles(databaseFile: string): readonly string[] { + return profileStateDatabaseBackups(databaseFile).flatMap(({ path }) => + profileStateDatabaseFiles(path).filter(existsSync) + ) +} diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts new file mode 100644 index 00000000000..72df43fdbae --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts @@ -0,0 +1,275 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import * as snapshots from './profile-state-database-snapshot' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const directories: string[] = [] +const rotations: ProfileStateBackupRotation[] = [] +const databases: ReturnType[] = [] +const HOUR = 60 * 60 * 1000 + +afterEach(async () => { + for (const rotation of rotations.splice(0)) { + rotation.stop() + await rotation.drain() + } + for (const opened of databases.splice(0)) { + opened.db.close() + } + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-backup-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'backup-profile') + databases.push(opened) + const clock = { now: Date.now() } + const rotation = createRotation(databasePath, () => clock.now) + const write = (generation: number) => { + importProfileStateJson(opened.db, JSON.stringify({ settings: { generation } })) + } + write(1) + return { directory, databasePath, opened, rotation, write, clock } +} + +function createRotation(databasePath: string, now: () => number = Date.now) { + const rotation = new ProfileStateBackupRotation(databasePath, 'backup-profile', now) + rotations.push(rotation) + return rotation +} + +function readBackup(path: string) { + const opened = openProfileStateDatabaseReadOnly(path, 'backup-profile') + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } +} + +describe('automatic SQLite recovery generations', () => { + it('copies committed WAL state once, without a live JSON writer or snapshot sidecars', async () => { + const { directory, databasePath, rotation, write } = fixture() + write(2) + rotation.schedule() + rotation.schedule() + await rotation.drain() + const backups = profileStateDatabaseBackups(databasePath) + expect(backups).toHaveLength(1) + expect(readBackup(backups[0].path)).toEqual({ settings: { generation: 2 } }) + expect(existsSync(join(directory, 'orca-data.json'))).toBe(false) + expect(readdirSync(directory).filter((name) => name.includes('.backup.'))).toEqual([ + backups[0].path.slice(directory.length + 1) + ]) + }) + + it('keeps five hourly generations and remembers cadence across reopen', async () => { + const { databasePath, rotation, write, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + for (let generation = 1; generation <= 7; generation++) { + clock.now = beginning + (generation - 1) * HOUR + write(generation) + rotation.schedule() + await rotation.drain() + } + const backups = profileStateDatabaseBackups(databasePath) + expect(backups).toHaveLength(5) + expect(backups.map(({ path }) => readBackup(path).settings.generation)).toEqual([7, 6, 5, 4, 3]) + const reopened = createRotation(databasePath, () => clock.now) + reopened.schedule() + await reopened.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(backups) + clock.now = beginning + 7 * HOUR - 1 + reopened.schedule() + await reopened.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(backups) + clock.now = beginning + 7 * HOUR + write(8) + reopened.schedule() + await reopened.drain() + expect( + profileStateDatabaseBackups(databasePath).map( + ({ path }) => readBackup(path).settings.generation + ) + ).toEqual([8, 7, 6, 5, 4]) + }) + + it('preserves all earlier backups on failure and retries without rejecting a committed write', async () => { + const { databasePath, rotation, opened, write, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + rotation.schedule() + await rotation.drain() + const retained = profileStateDatabaseBackups(databasePath) + const snapshot = vi + .spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync') + .mockRejectedValueOnce(new Error('disk full')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + clock.now = beginning + HOUR + write(2) + rotation.schedule() + await expect(rotation.drain()).resolves.toBeUndefined() + expect(JSON.parse(readProfileStateSnapshot(opened.db).json).settings.generation).toBe(2) + expect(profileStateDatabaseBackups(databasePath)).toEqual(retained) + expect(log).toHaveBeenCalledOnce() + rotation.schedule() + await rotation.drain() + expect(snapshot).toHaveBeenCalledOnce() + clock.now = beginning + HOUR + 60_000 + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(2) + }) + + it('does not prune previous generations when the new copy fails strict validation', async () => { + const { databasePath, rotation, clock } = fixture() + const beginning = Date.now() + clock.now = beginning + rotation.schedule() + await rotation.drain() + const retained = profileStateDatabaseBackups(databasePath) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockRejectedValueOnce( + new Error('staged validation failed') + ) + clock.now = beginning + HOUR + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toEqual(retained) + }) + + it('cancels a queued backup before opening a source after Store close', async () => { + const { databasePath, rotation } = fixture() + rotation.schedule() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + rotation.stop() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(profileStateDatabaseBackups(databasePath)).toEqual([]) + }) + + it('blocks synchronous quarantine until retention pruning finishes', async () => { + const { databasePath, rotation, clock } = fixture() + for (let generation = 0; generation < 5; generation++) { + rotation.schedule() + await rotation.drain() + clock.now += HOUR + } + const oldest = profileStateDatabaseBackups(databasePath)[4].path + const remove = fsPromises.rm + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => { + if (path === oldest) { + begin() + await gate + } + await remove(path, options) + }) + rotation.schedule() + try { + await started + rotation.stop() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(6) + expect(() => rotation.assertIdle()).toThrow('Flush pending') + } finally { + release() + await rotation.drain() + } + expect(() => rotation.assertIdle()).not.toThrow() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(5) + }) + + it('owns an in-flight source until completion and blocks synchronous quarantine', async () => { + const { databasePath, rotation, opened } = fixture() + const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + let begin: () => void = () => {} + let release: () => void = () => {} + const started = new Promise((resolve) => { + begin = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( + async (source, target) => { + begin() + await gate + await realSnapshot(source, target) + } + ) + rotation.schedule() + await started + rotation.stop() + opened.db.close() + databases.splice(databases.indexOf(opened), 1) + expect(() => rotation.assertIdle()).toThrow('Flush pending') + release() + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(readBackup(profileStateDatabaseBackups(databasePath)[0].path)).toEqual({ + settings: { generation: 1 } + }) + }) + + it('does not treat a reserved-name directory as a recent successful backup', async () => { + const { databasePath, rotation } = fixture() + const invalid = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + mkdirSync(invalid) + rotation.schedule() + await rotation.drain() + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(2) + expect(existsSync(invalid)).toBe(true) + }) + + it('preserves a backup with sidecars without counting it toward cadence or retention', async () => { + const { databasePath, rotation, clock } = fixture() + rotation.schedule() + await rotation.drain() + const original = profileStateDatabaseBackups(databasePath)[0].path + writeFileSync(`${original}-journal`, 'external unfinished write') + const reopened = createRotation(databasePath, () => clock.now) + for (let generation = 0; generation < 6; generation++) { + reopened.schedule() + await reopened.drain() + clock.now += HOUR + } + expect(existsSync(original)).toBe(true) + expect(existsSync(`${original}-journal`)).toBe(true) + expect(profileStateDatabaseBackups(databasePath)).toHaveLength(6) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.ts new file mode 100644 index 00000000000..a9b301f044e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.ts @@ -0,0 +1,130 @@ +import { lstat, rm } from 'node:fs/promises' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { runProfileStateBackup } from './profile-state-backup-worker' +import { removeAbandonedProfileStateBackupFiles } from './profile-state-backup-temporary-files' + +const BACKUP_COUNT = 5 +const BACKUP_INTERVAL_MS = 60 * 60 * 1000 +const BACKUP_RETRY_MS = 60 * 1000 + +/** Immutable generations keep every previous recovery point until publication succeeds. */ +export class ProfileStateBackupRotation { + private pending: Promise | undefined + private stopped = false + private readonly cancellation = new AbortController() + private nextAttemptAt = 0 + + constructor( + private readonly databasePath: string, + private readonly profileId: string, + private readonly now: () => number = Date.now, + private readonly runBackup = runProfileStateBackup + ) {} + + schedule(): void { + if (this.stopped || this.pending || this.now() < this.nextAttemptAt) { + return + } + const pending = Promise.resolve() + .then(() => this.rotate()) + .catch((error: unknown) => { + this.nextAttemptAt = this.now() + BACKUP_RETRY_MS + if (this.stopped && (this.cancellation.signal.aborted || isMissingPath(error))) { + return + } + console.error('[persistence] Failed to back up profile state database:', error) + }) + .finally(() => { + if (this.pending === pending) { + this.pending = undefined + } + }) + this.pending = pending + } + + async drain(): Promise { + await this.pending + } + + stop(): void { + this.stopped = true + this.cancellation.abort() + } + + assertIdle(): void { + if (this.pending) { + throw new Error('Flush pending profile state backups before quarantining the database') + } + } + + private async rotate(): Promise { + if (this.stopped) { + return + } + const now = this.now() + await removeAbandonedProfileStateBackupFiles(this.databasePath, now) + const latest = (await this.regularBackups())[0] + if (this.stopped) { + return + } + if (latest && now - latest.createdAtMs < BACKUP_INTERVAL_MS) { + this.nextAttemptAt = Math.min(latest.createdAtMs, now) + BACKUP_INTERVAL_MS + return + } + const target = profileStateDatabaseBackupPath( + this.databasePath, + createProfileStateDatabaseBackupId(now) + ) + await this.runBackup( + { + databasePath: this.databasePath, + profileId: this.profileId, + targetPath: target + }, + this.cancellation.signal + ) + this.nextAttemptAt = this.now() + BACKUP_INTERVAL_MS + for (const backup of (await this.regularBackups()).slice(BACKUP_COUNT)) { + await rm(backup.path, { force: true }) + } + } + + private async regularBackups(): Promise> { + const backups = profileStateDatabaseBackups(this.databasePath) + const candidates = await Promise.all( + backups.map(async (backup) => { + try { + if (!(await lstat(backup.path)).isFile()) { + return undefined + } + for (const suffix of ['-wal', '-shm', '-journal']) { + const sidecar = await lstat(`${backup.path}${suffix}`).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + }) + if (sidecar !== undefined) { + return undefined + } + } + return backup + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + } + }) + ) + return candidates.filter((backup) => backup !== undefined) + } +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts new file mode 100644 index 00000000000..a37de695fa5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-temporary-files.test.ts @@ -0,0 +1,91 @@ +import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as identity from './profile-state-access-identity' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { + profileStateBackupTemporaryPath, + removeAbandonedProfileStateBackupFiles +} from './profile-state-backup-temporary-files' + +vi.mock('./profile-state-access-identity', () => ({ + profileStateAccessBootIdentity: () => 'test-boot' +})) +vi.mock('./profile-state-access-owner', () => ({ + profileStateAccessPidNamespace: () => 'test-namespace' +})) + +const roots: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('abandoned profile database backups', () => { + it('does not infer ownership when kernel identity is unavailable', async () => { + vi.spyOn(identity, 'profileStateAccessBootIdentity').mockReturnValue(null) + const probe = vi.spyOn(process, 'kill') + await removeAbandonedProfileStateBackupFiles('/missing/profile-state.db', Date.now()) + expect(probe).not.toHaveBeenCalled() + expect(profileStateBackupTemporaryPath('/profile/backup.db')).not.toContain('.owner-') + }) + + it('removes only old temporary files whose owner is proven exited', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-backup-orphans-')) + roots.push(root) + const databasePath = join(root, 'profile-state.db') + const backup = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const now = Date.now() + const old = new Date(now - 2 * 60 * 60_000) + const temporary = profileStateBackupTemporaryPath(backup) + const orphan = temporary.replace(`.${process.pid}.`, '.12345.') + const live = temporary.replace(`.${process.pid}.`, '.12346.') + const unknown = temporary.replace(`.${process.pid}.`, '.12347.') + const recent = profileStateBackupTemporaryPath(backup).replace(`.${process.pid}.`, '.12345.') + const remote = orphan.replace(/owner-[a-f0-9]{64}/, `owner-${'0'.repeat(64)}`) + const legacy = `${backup}.12345.${now}.ab12.tmp` + const unrelated = `${databasePath}.unrelated.tmp` + for (const path of [ + backup, + orphan, + `${orphan}-wal`, + `${orphan}-shm`, + `${orphan}-journal`, + live, + unknown, + recent, + remote, + legacy, + unrelated + ]) { + writeFileSync(path, 'retained') + if (path !== recent) { + utimesSync(path, old, old) + } + } + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 12345) { + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + if (pid === 12347) { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + return true + }) + await removeAbandonedProfileStateBackupFiles(databasePath, now) + expect(readdirSync(root).sort()).toEqual( + [backup, live, unknown, recent, remote, legacy, unrelated] + .map((path) => basename(path)) + .sort() + ) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts b/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts new file mode 100644 index 00000000000..eb0424abbc2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-temporary-files.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto' +import { lstat, readdir, rm } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { profileStateAccessBootIdentity } from './profile-state-access-identity' +import { profileStateAccessPidNamespace } from './profile-state-access-owner' +import { basename, dirname, join } from 'node:path' + +const ORPHAN_AGE_MS = 60 * 60_000 +const BACKUP_TEMP_PATTERN = + /^([1-9]\d*)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.db\.owner-([a-f0-9]{64})\.([1-9]\d*)\.\d+\.[0-9a-f]+\.tmp(?:-(?:wal|shm|journal))?$/ + +export function profileStateBackupTemporaryPath(targetPath: string): string { + const scope = currentProcessScope() + return durableWriteTempPath(scope ? `${targetPath}.owner-${scope}` : targetPath) +} + +function currentProcessScope(): string | undefined { + const boot = profileStateAccessBootIdentity() + const namespace = profileStateAccessPidNamespace() + if (!boot || (process.platform === 'linux' && namespace === null)) { + return undefined + } + return createHash('sha256') + .update(JSON.stringify([process.platform, boot, namespace])) + .digest('hex') +} + +/** A dead process proves the temporary database and its journals cannot still be in use. */ +export async function removeAbandonedProfileStateBackupFiles( + databasePath: string, + now: number +): Promise { + const scope = currentProcessScope() + if (!scope) { + return + } + const directory = dirname(databasePath) + const prefix = `${basename(databasePath)}.backup.` + for (const name of await readdir(directory)) { + if (!name.startsWith(prefix)) { + continue + } + const match = BACKUP_TEMP_PATTERN.exec(name.slice(prefix.length)) + if (!match || match[2] !== scope || !ownerExited(Number(match[3]))) { + continue + } + const path = join(directory, name) + try { + const info = await lstat(path) + if (info.isFile() && now - info.mtimeMs >= ORPHAN_AGE_MS) { + await rm(path, { force: true }) + } + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + throw error + } + } + } +} + +function ownerExited(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return false + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ESRCH' + } +} diff --git a/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts new file mode 100644 index 00000000000..9213e4e0042 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker-entry.ts @@ -0,0 +1,30 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { writeProfileStateBackup } from './profile-state-backup-job' +import { isRecord } from './profile-state-document-validation' + +if (!parentPort) { + throw new Error('Profile state backup must run on a worker thread') +} +const port = parentPort +const request: unknown = workerData +if ( + !isRecord(request) || + typeof request.databasePath !== 'string' || + typeof request.profileId !== 'string' || + typeof request.targetPath !== 'string' || + (request.temporaryPath !== undefined && typeof request.temporaryPath !== 'string') +) { + throw new Error('Invalid profile state backup request') +} + +void writeProfileStateBackup({ + databasePath: request.databasePath, + profileId: request.profileId, + targetPath: request.targetPath, + temporaryPath: request.temporaryPath +}) + .then( + () => port.postMessage({ ok: true }), + (error: unknown) => port.postMessage({ ok: false, error: String(error) }) + ) + .finally(() => port.close()) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts new file mode 100644 index 00000000000..310c1471420 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -0,0 +1,236 @@ +import { build } from 'esbuild' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from './profile-state-documents' +import { + runProfileStateBackupWorker, + resolveProfileStateBackupWorkerPath +} from './profile-state-backup-worker' +import * as backupWorker from './profile-state-backup-worker' + +const directories: string[] = [] +let workerDirectory: string +let workerPath: string + +beforeAll(async () => { + workerDirectory = mkdtempSync(join(tmpdir(), 'orca-backup-worker-entry-')) + workerPath = join(workerDirectory, 'profile-state-backup-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +afterAll(() => rmSync(workerDirectory, { recursive: true, force: true })) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-backup-worker-')) + directories.push(directory) + const job = { + databasePath: join(directory, 'profile-state.db'), + targetPath: join(directory, 'backup.db'), + profileId: 'worker-test' + } + const opened = openProfileStateDatabase(job.databasePath, job.profileId) + importProfileStateJson(opened.db, '{"settings":{"theme":"dark"}}') + opened.db.close() + return { directory, job } +} + +function script(directory: string, source: string): string { + const path = join(directory, 'worker.cjs') + writeFileSync(path, source) + return path +} + +describe('profile state backup worker', () => { + it('runs the built entry and releases every handle before recovery can move its files', async () => { + const { directory, job } = fixture() + await runProfileStateBackupWorker(job, { workerPath }) + const snapshot = openProfileStateDatabaseReadOnly(job.targetPath, job.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(snapshot.db).json)).toEqual({ + settings: { theme: 'dark' } + }) + } finally { + snapshot.db.close() + } + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db'))).toEqual([ + 'backup.db' + ]) + rmSync(directory, { recursive: true }) + expect(existsSync(directory)).toBe(false) + }) + + it.each([ + [ + 'a mismatched stored hash', + (db: ReturnType['db']) => { + db.prepare('UPDATE profile_state_documents SET content_hash = ?').run('0'.repeat(64)) + }, + 'hash mismatch' + ], + [ + 'an independently hashed invalid domain fragment', + (db: ReturnType['db']) => { + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'settings'" + ).run('{', hashProfileStateJson('{')) + }, + 'invalid JSON' + ], + [ + 'an independently hashed invalid normalized history fragment', + (db: ReturnType['db']) => { + importProfileStateJson(db, '{"automationRuns":[{"id":"run-1","output":"ok"}]}') + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE run_id = ?' + ).run('{', hashProfileStateJson('{'), 'run-1') + }, + 'invalid JSON' + ] + ] as const)('%s', async (_description, corrupt, expectedError) => { + const { job } = fixture() + const primary = openProfileStateDatabase(job.databasePath, job.profileId) + corrupt(primary.db) + primary.db.close() + const before = readFileSync(job.databasePath) + const retained = `${job.targetPath}.prior` + writeFileSync(retained, 'previous recovery point') + + await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) + expect(existsSync(job.targetPath)).toBe(false) + expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') + expect(readFileSync(job.databasePath)).toEqual(before) + }) + + it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { + const { directory, job } = fixture() + const delayedWorker = script( + directory, + ` + const { parentPort, workerData } = require('node:worker_threads') + parentPort.postMessage({ ok: ${ok}, error: 'backup failed' }) + setTimeout(() => { + require('node:fs').writeFileSync(workerData.targetPath, 'handles released') + parentPort.close() + }, 50) + ` + ) + const result = runProfileStateBackupWorker(job, { workerPath: delayedWorker }) + await (ok === 'true' ? result : expect(result).rejects.toThrow('backup failed')) + expect(readFileSync(job.targetPath, 'utf8')).toBe('handles released') + }) + + it.each([ + ['throw new Error("worker boot failed")', 'worker boot failed'], + ['process.exit(0)', 'without completion'], + ['require("node:worker_threads").parentPort.postMessage({ wrong: true })', 'Invalid profile'], + ['setInterval(() => {}, 1000)', 'timed out'] + ])('fails closed for worker failure: %s', async (source, error) => { + const { directory, job } = fixture() + const failedWorker = script(directory, source) + await expect( + runProfileStateBackupWorker(job, { workerPath: failedWorker, timeoutMs: 500 }) + ).rejects.toThrow(error) + expect(existsSync(job.targetPath)).toBe(false) + rmSync(directory, { recursive: true }) + }) + + it.each(['timeout', 'cancel'] as const)( + 'cleans a terminated %s worker only after exit', + async (mode) => { + const { directory, job } = fixture() + const ready = join(directory, 'ready') + const worker = script( + directory, + ` + const { workerData } = require('node:worker_threads') + const fs = require('node:fs') + for (const suffix of ['', '-wal', '-shm', '-journal']) fs.writeFileSync(workerData.temporaryPath + suffix, 'incomplete') + fs.writeFileSync(${JSON.stringify(ready)}, 'ready') + setInterval(() => {}, 1000) + ` + ) + const cancellation = new AbortController() + const pending = runProfileStateBackupWorker(job, { + workerPath: worker, + timeoutMs: 500, + signal: cancellation.signal + }) + const failed = expect(pending).rejects.toThrow(mode === 'cancel' ? 'cancelled' : 'timed out') + await vi.waitFor(() => expect(existsSync(ready)).toBe(true)) + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength(4) + if (mode === 'cancel') { + cancellation.abort() + } + await failed + expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toEqual([]) + expect(existsSync(job.databasePath)).toBe(true) + } + ) + + it('reports a missing bundle and leaves the primary untouched', async () => { + const { directory, job } = fixture() + const before = readFileSync(job.databasePath) + await expect( + runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) + ).rejects.toThrow() + expect(readFileSync(job.databasePath)).toEqual(before) + }) + + it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { + const { directory, job } = fixture() + let started: () => void = () => {} + const beginning = new Promise((resolve) => { + started = resolve + }) + const dispatch = vi + .spyOn(backupWorker, 'runProfileStateBackup') + .mockImplementation((request) => { + started() + return runProfileStateBackupWorker(request, { workerPath }) + }) + const rotation = new ProfileStateBackupRotation(job.databasePath, job.profileId) + rotation.schedule() + rotation.schedule() + await beginning + rotation.stop() + expect(() => rotation.assertIdle()).toThrow('Flush pending') + await rotation.drain() + expect(() => rotation.assertIdle()).not.toThrow() + expect(dispatch).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(job.databasePath)).toHaveLength(1) + rmSync(directory, { recursive: true }) + }) + + it('finds entries beside the launcher and above Rollup shared chunks', () => { + expect(resolveProfileStateBackupWorkerPath(workerDirectory)).toBe(workerPath) + expect(resolveProfileStateBackupWorkerPath(join(workerDirectory, 'chunks'))).toBe(workerPath) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.ts b/src/main/persistence/profile-state/profile-state-backup-worker.ts new file mode 100644 index 00000000000..287ca2ce34b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -0,0 +1,87 @@ +import { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { profileStateBackupTemporaryPath } from './profile-state-backup-temporary-files' +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { dirname, join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { + currentWorkerEntryLayout, + resolveWorkerThreadEntryPath +} from '../../worker-thread-entry-path' +import { type ProfileStateBackupJob, writeProfileStateBackup } from './profile-state-backup-job' +import { isRecord } from './profile-state-document-validation' + +const WORKER_FILENAME = 'profile-state-backup-worker-entry.js' +const BACKUP_TIMEOUT_MS = 10 * 60_000 + +export function resolveProfileStateBackupWorkerPath(moduleDir = __dirname): string { + const entry = resolveWorkerThreadEntryPath(currentWorkerEntryLayout(moduleDir), WORKER_FILENAME) + // Rollup can place this launcher in a shared chunk beside the worker entries. + return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry +} + +/** Desktop validation runs off the UI thread; plain-Node backups retain the native async path. */ +export function runProfileStateBackup( + job: ProfileStateBackupJob, + signal?: AbortSignal +): Promise { + return process.versions.electron + ? runProfileStateBackupWorker(job, { signal }) + : writeProfileStateBackup(job) +} + +export async function runProfileStateBackupWorker( + job: ProfileStateBackupJob, + options: { workerPath?: string; timeoutMs?: number; signal?: AbortSignal } = {} +): Promise { + options.signal?.throwIfAborted() + const temporaryPath = profileStateBackupTemporaryPath(job.targetPath) + try { + await new Promise((resolve, reject) => { + const workerPath = options.workerPath ?? resolveProfileStateBackupWorkerPath() + const worker = new Worker(workerPath, { workerData: { ...job, temporaryPath }, execArgv: [] }) + let completed = false + let failure: Error | undefined + const terminate = (reason: Error): void => { + failure ??= reason + void worker.terminate().catch((error: unknown) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + } + const abort = (): void => terminate(new Error('Profile state backup cancelled')) + options.signal?.addEventListener('abort', abort, { once: true }) + const timer = setTimeout( + () => terminate(new Error('Profile state backup worker timed out')), + options.timeoutMs ?? BACKUP_TIMEOUT_MS + ) + worker.on('message', (response: unknown) => { + if (!isRecord(response) || typeof response.ok !== 'boolean') { + failure = new Error('Invalid profile state backup worker response') + } else if (!response.ok) { + failure = new Error(String(response.error)) + } else { + completed = true + } + }) + worker.on('error', (error) => { + failure = error instanceof Error ? error : new Error(String(error)) + }) + // Even an error response leaves handles open until the worker actually exits. + worker.once('exit', (code) => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + if (failure || code !== 0 || !completed) { + reject( + failure ?? new Error(`Profile state backup worker exited without completion (${code})`) + ) + } else { + resolve() + } + }) + }) + } finally { + await Promise.all( + profileStateDatabaseFiles(temporaryPath).map((path) => rm(path, { force: true })) + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts new file mode 100644 index 00000000000..967822332fd --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-bootstrap-publication-race.test.ts @@ -0,0 +1,178 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { profileStateJsonExportPath } from './profile-state-export-path' +import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' +import { formatProfileStateStartupFailure } from './profile-state-startup-failure' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const roots: string[] = [] +const authorities: ProfileStateAuthority[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const authority of authorities.splice(0)) { + authority.close?.() + } + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +describe('first database publication with competing startup', () => { + it.each(['empty', 'legacy'])( + 'explains unavailable hard links without publishing a partial %s database', + (kind) => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-no-hardlinks-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'unsupported-publication', + allowEmptyProfileState: true + } + const json = '{"settings":{"theme":"dark"}}' + if (kind === 'legacy') { + fs.writeFileSync(options.dataFile, json) + } + const link = vi.spyOn(fs, 'linkSync').mockImplementation(() => { + throw Object.assign(new Error('hard links unsupported'), { + code: 'ENOTSUP', + syscall: 'link' + }) + }) + let failure: unknown + try { + bootstrapProfileStateAuthority(options) + } catch (error) { + failure = error + } + expect(failure).toMatchObject({ code: 'profile-state-publication-unavailable' }) + const message = formatProfileStateStartupFailure(failure) + expect(message).toContain('hard links') + expect(message).toContain(root) + expect(message).toContain('complete Orca data directory') + expect(message).not.toContain('rollback') + expect(fs.existsSync(options.databaseFile)).toBe(false) + expect(fs.readdirSync(root)).toEqual(kind === 'legacy' ? ['orca-data.json'] : []) + if (kind === 'legacy') { + expect(fs.readFileSync(options.dataFile, 'utf8')).toBe(json) + } + link.mockRestore() + const retry = bootstrapProfileStateAuthority(options) + expect(retry.authority).toBeDefined() + if (retry.authority) { + authorities.push(retry.authority) + } + } + ) + + it('names its migration export after the snapshot actually captured', () => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-migration-export-race-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'migration-export-race', + expectedLegacyJson: '{"settings":{"theme":"dark"}}', + serializedState: '{"settings":{"theme":"dark"}}' + } + fs.writeFileSync(options.dataFile, options.expectedLegacyJson) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + link(from, to) + if (to === options.databaseFile) { + const peer = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"light"}' }]) + } finally { + peer.close() + } + } + }) + + const migrated = migrateProfileStateToSqlite(options) + authorities.push(migrated.authority) + expect(migrated.authority.revision).toBe(2) + expect(fs.existsSync(profileStateJsonExportPath(options.dataFile, 1))).toBe(false) + expect( + JSON.parse(fs.readFileSync(profileStateJsonExportPath(options.dataFile, 2), 'utf8')) + ).toEqual({ + settings: { theme: 'light' } + }) + expect(fs.readFileSync(options.dataFile, 'utf8')).toBe(options.expectedLegacyJson) + }) + + it.each(['empty', 'legacy'])('cannot replace an acknowledged competing %s profile', (kind) => { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-bootstrap-publication-race-')) + roots.push(root) + const options = { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'publication-race', + allowEmptyProfileState: true + } + if (kind === 'legacy') { + fs.writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + } + const committed = { + settings: { theme: 'light' }, + extension: { acknowledged: true, value: null } + } + let competing = false + const open = () => { + const result = bootstrapProfileStateAuthority(options) + if (result.authority) { + authorities.push(result.authority) + } + return result + } + const race = (target: fs.PathLike) => { + if (competing || target !== options.databaseFile) { + return + } + competing = true + const winner = open().authority + if (!winner) { + throw new Error('Competing startup did not establish SQLite') + } + winner.writeSerializedState(Buffer.from(JSON.stringify(committed))) + expect(JSON.parse(winner.readSerializedState() ?? 'null')).toEqual(committed) + winner.close?.() + authorities.splice(authorities.indexOf(winner), 1) + } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + race(to) + rename(from, to) + }) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + race(to) + link(from, to) + }) + + let publicationError: unknown + try { + open() + } catch (error) { + publicationError = error + } + expect(competing).toBe(true) + expect(JSON.parse(open().authority?.readSerializedState() ?? 'null')).toEqual(committed) + expect(publicationError).toMatchObject({ message: expect.stringContaining('storage changed') }) + expect(fs.readdirSync(root).some((name) => name.endsWith('.tmp'))).toBe(false) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts new file mode 100644 index 00000000000..177c79dd874 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-domain-writes.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + openProfileStateDatabaseReadOnly, + openProfileStateDatabase +} from './profile-state-database' +import { readProfileStateRevision } from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const fixtures: { authority: ProfileStateSqliteAuthority; directory: string }[] = [] + +function fixture(established: boolean) { + const directory = mkdtempSync(join(tmpdir(), 'orca-complete-domain-write-')) + const databasePath = join(directory, 'state.db') + openProfileStateDatabase(databasePath, 'profile').db.close() + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile') + fixtures.push({ authority, directory }) + if (established) { + authority.writeSerializedState( + Buffer.from('{"settings":{"theme":"light"},"automationRuns":[{"id":"old"}],"removeMe":true}') + ) + } + const readRevision = () => { + const { db } = openProfileStateDatabaseReadOnly(databasePath, 'profile') + try { + return readProfileStateRevision(db) + } finally { + db.close() + } + } + return { authority, databasePath, readRevision } +} + +afterEach(() => { + for (const { authority, directory } of fixtures.splice(0)) { + authority.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +const invalidReplacements = [ + { + name: 'sibling-key injection', + value: [{ domain: 'extension', payload: 'null,"settings":{"theme":"dark"}' }] + }, + { + name: 'duplicate domains', + value: [ + { domain: 'settings', payload: '{}' }, + { domain: 'settings', payload: 'null' } + ] + }, + { name: 'non-string payload', value: [{ domain: 'settings', payload: true }] }, + { name: 'missing payload', value: [{ domain: 'settings' }] }, + { name: 'empty domain', value: [{ domain: '', payload: '{}' }] }, + { name: 'non-array replacements', value: { settings: '{}' } } +] + +describe.each([false, true])('complete domain writes (established: %s)', (established) => { + it.each(invalidReplacements)('rejects $name without changing state or revision', ({ value }) => { + const { authority, readRevision } = fixture(established) + const before = authority.readSerializedState() + const revision = readRevision() + + expect(() => { + // @ts-expect-error Intentionally malformed input must fail runtime validation. + authority.writeCompleteSerializedDomains(value) + }).toThrow() + + expect(authority.readSerializedState()).toBe(before) + expect(readRevision()).toBe(revision) + }) + + it('preserves null, history order and unknown fields while deleting omitted domains', () => { + const { authority } = fixture(established) + const future = { '3': 3, '1': 1, unicode: '雪 🐋\ud800', nested: { z: null, a: [] } } + const runs = [{ id: 'z', extension: future }, { id: 'a' }] + + authority.writeCompleteSerializedDomains([ + { domain: 'settings', payload: 'null' }, + { domain: 'automationRuns', payload: JSON.stringify(runs) }, + { domain: 'future', payload: JSON.stringify(future) }, + { domain: 'deleted', payload: null } + ]) + + expect(authority.readSerializedState()).toBe( + JSON.stringify({ settings: null, automationRuns: runs, future }) + ) + authority.writeCompleteSerializedDomains([]) + expect(authority.readSerializedState()).toBe('{}') + }) + + it('accepts an empty complete profile', () => { + const { authority } = fixture(established) + + authority.writeCompleteSerializedDomains([]) + + expect(authority.readSerializedState()).toBe('{}') + }) +}) + +describe('complete domain revision fencing', () => { + it('rejects an older complete replacement after a concurrent writer commits', () => { + const { authority, databasePath } = fixture(true) + authority.readSerializedState() + const other = new ProfileStateSqliteAuthority(databasePath, 'profile') + try { + other.writeCompleteSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + } finally { + other.close() + } + + expect(() => + authority.writeCompleteSerializedDomains([ + { domain: 'settings', payload: '{"theme":"light"}' } + ]) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(authority.readSerializedState()).toBe('{"settings":{"theme":"dark"}}') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-complete-replacements.ts b/src/main/persistence/profile-state/profile-state-complete-replacements.ts new file mode 100644 index 00000000000..2f7ea20f537 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-complete-replacements.ts @@ -0,0 +1,36 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import type { openProfileStateDatabase } from './profile-state-database' + +export function buildCompleteDocumentReplacements( + db: ReturnType['db'], + replacements: readonly ProfileStateDomainReplacement[] +): ProfileStateDomainReplacement[] { + const domains = new Set(replacements.map(({ domain }) => domain)) + const incoming = new Set(domains) + for (const row of db + .prepare(`SELECT domain FROM profile_state_documents + UNION SELECT domain FROM profile_state_automation_runs_meta WHERE presence <> 'document'`) + .all()) { + if (isDomainRow(row)) { + domains.add(row.domain) + } + } + + return [ + ...replacements, + ...[...domains] + .filter((domain) => !incoming.has(domain)) + .map((domain) => ({ domain, payload: null })) + ] +} + +function isDomainRow(value: unknown): value is { domain: string } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'domain' in value && + typeof value.domain === 'string' && + value.domain.length > 0 + ) +} diff --git a/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts new file mode 100644 index 00000000000..e9d6a54e0e5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-crash-recovery.test.ts @@ -0,0 +1,210 @@ +import { spawnProcess } from '../../../shared/child-process/run-process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + exportProfileStateJson, + importProfileStateJson, + readProfileStateRevision +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +const crashDuringWriteScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(process.argv[1]) + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; BEGIN IMMEDIATE') + db.prepare('DELETE FROM profile_state_documents').run() + db.prepare('DELETE FROM profile_state_automation_runs').run() + db.prepare('DELETE FROM profile_state_automation_runs_meta').run() + db.prepare(\` + UPDATE profile_state_meta + SET value = ? + WHERE key = 'revision' + \`).run('999') + process.stdout.write('transaction-ready\\n') + setInterval(() => {}, 1_000) +` + +const crashAfterCommittedWriteScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const { createHash } = require('node:crypto') + const db = new DatabaseSync(process.argv[1]) + const payload = '{"theme":"committed"}' + const hash = createHash('sha256').update(payload).digest('hex') + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; BEGIN IMMEDIATE') + db.prepare('DELETE FROM profile_state_documents').run() + db.prepare('DELETE FROM profile_state_automation_runs').run() + db.prepare("UPDATE profile_state_automation_runs_meta SET presence = 'document', domain_version = 1, revision = 0, updated_at = 0, content_hash = ''").run() + db.prepare(\` + INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)\` + ).run('settings', payload, 1, 999, 999, hash) + db.prepare(\` + UPDATE profile_state_meta + SET value = ? + WHERE key = 'revision' + \`).run('999') + db.exec('COMMIT') + process.stdout.write('transaction-committed\\n') + setInterval(() => {}, 1_000) +` + +const crashDuringCheckpointScript = ` + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const dbPath = process.argv[1] + const writer = new DatabaseSync(dbPath, { timeout: 5_000 }) + const reader = new DatabaseSync(dbPath, { timeout: 5_000 }) + writer.exec('PRAGMA wal_autocheckpoint = 0') + writer.exec('BEGIN IMMEDIATE') + writer.prepare( + \`UPDATE profile_state_meta SET value = value WHERE key = 'revision'\` + ).run() + writer.exec('COMMIT') + reader.exec('BEGIN') + reader.prepare("SELECT value FROM profile_state_meta WHERE key = 'revision'").get() + // SQLITE_PRAGMA is action code 19; the reader keeps TRUNCATE checkpointing active after this callback returns. + writer.setAuthorizer((action) => { + if (action === 19) { + process.stdout.write('checkpoint-started\\n') + } + return 0 + }) + writer.prepare('PRAGMA wal_checkpoint(TRUNCATE)').all() + process.stdout.write('checkpoint-complete\\n') +` + +async function killAfterChildReady(dbPath: string, script: string, marker: string): Promise { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', script, dbPath], + timeoutMs: null + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + + try { + await new Promise((resolve, reject) => { + let output = '' + const onData = (chunk: Buffer | string): void => { + output += String(chunk) + if (output.includes(marker)) { + resolve() + } + } + child.stdout.on('data', onData) + child.once('error', reject) + }) + child.kill('SIGKILL') + await new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', reject) + }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + } +} + +async function killAfterUncommittedWrite(dbPath: string): Promise { + await killAfterChildReady(dbPath, crashDuringWriteScript, 'transaction-ready') +} + +describe('profile state crash recovery', () => { + it('rolls back an uncommitted SQLite write and accepts the next import', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture() + + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(fixture), { now: () => 100 }) + initial.db.close() + + await killAfterUncommittedWrite(dbPath) + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(1) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(fixture) + ) + + const replacement = { + ...fixture, + futureTopLevelExtension: { keep: 'replacement', nullable: null } + } + expect( + importProfileStateJson(recovered.db, JSON.stringify(replacement), { now: () => 200 }) + ).toBe(2) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(replacement) + ) + } finally { + recovered.db.close() + } + }) + + it('preserves a committed SQLite write after process termination', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(buildProfileStateCutoverFixture()), { + now: () => 100 + }) + initial.db.close() + + await killAfterChildReady(dbPath, crashAfterCommittedWriteScript, 'transaction-committed') + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(999) + expect(JSON.parse(exportProfileStateJson(recovered.db))).toEqual({ + settings: { theme: 'committed' } + }) + } finally { + recovered.db.close() + } + }) + + it('recovers a committed database when checkpointing is interrupted', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-checkpoint-crash-')) + temporaryDirectories.push(directory) + const dbPath = profileStateDatabaseFile(directory) + const fixture = buildProfileStateCutoverFixture() + const initial = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(initial.db, JSON.stringify(fixture), { now: () => 100 }) + initial.db.close() + + await killAfterChildReady(dbPath, crashDuringCheckpointScript, 'checkpoint-started') + + const recovered = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(recovered.readOnly).toBe(false) + expect(readProfileStateRevision(recovered.db)).toBe(1) + expect(canonicalProfileStateJson(JSON.parse(exportProfileStateJson(recovered.db)))).toBe( + canonicalProfileStateJson(fixture) + ) + } finally { + recovered.db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-current-json-command.test.ts b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts new file mode 100644 index 00000000000..9b3a83d788c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-current-json-command.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { rollbackProfileState } from './profile-state-recovery-command' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { profileStateJsonExportPath } from './profile-state-export-path' +const roots: string[] = [] +const profileId = 'current-json-recovery' +const originalState = { settings: { theme: 'dark', httpProxyUrl: 'sealed:original' } } +const editedState = { + settings: { + theme: 'light', + httpProxyUrl: 'sealed:older-build', + electronHttp1CompatibilityMode: true + }, + futureDomain: { opaque: [null, '\ud800', { futureKey: 'keep me' }] }, + accounts: { token: 'sealed:account-token' } +} +const editedJson = `${JSON.stringify(editedState, null, 2)}\n` + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-current-json-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const exportPath = profileStateJsonExportPath(dataFile, 1) + const originalJson = JSON.stringify(originalState) + writeFileSync(dataFile, originalJson) + const migration = migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: originalJson, + serializedState: originalJson + }) + migration.authority.close() + const backupPath = profileStateDatabaseBackupPath( + databaseFile, + createProfileStateDatabaseBackupId() + ) + writeFileSync(backupPath, readFileSync(databaseFile)) + writeFileSync(dataFile, editedJson) + return { root, directory, dataFile, databaseFile, exportPath, backupPath, profileId } +} + +function rollback(profile: ReturnType) { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + expect(() => acquireProfileStateRuntimeAdmission(profile.root)).toThrow('in use') + return rollbackProfileState(profile.root, { kind: 'current-json' }, maintenance) + } finally { + maintenance.release() + } +} + +function snapshot(profile: ReturnType) { + return [profile.dataFile, profile.databaseFile, profile.exportPath, profile.backupPath].map( + (path) => readFileSync(path) + ) +} + +describe('adopting JSON edited by an older build', () => { + it('preserves both authorities and retained exports before adopting exact JSON bytes', async () => { + const profile = fixture() + const before = snapshot(profile) + const previousQuarantine = join(profile.directory, 'profile-state-corrupt-earlier') + mkdirSync(previousQuarantine) + writeFileSync(join(previousQuarantine, 'evidence'), 'preserve earlier recovery') + expect(() => bootstrapProfileStateAuthority(profile)).toThrow('acceptance marker') + const result = rollback(profile) + expect(result).toMatchObject({ + revision: null, + storage: 'json', + restoredPath: profile.dataFile + }) + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + expect(existsSync(profile.databaseFile)).toBe(false) + expect(existsSync(profile.exportPath)).toBe(false) + expect(existsSync(profile.backupPath)).toBe(false) + expect(readFileSync(join(previousQuarantine, 'evidence'), 'utf8')).toBe( + 'preserve earlier recovery' + ) + for (const [index, path] of [ + profile.dataFile, + profile.databaseFile, + profile.exportPath, + profile.backupPath + ].entries()) { + expect(readFileSync(join(result.quarantineDirectory, basename(path)))).toEqual(before[index]) + } + const reopened = bootstrapProfileStateAuthority(profile) + expect(reopened.migrated).toBe(true) + try { + const restored: unknown = JSON.parse(reopened.authority?.readSerializedState() ?? 'null') + expect(restored).toMatchObject(editedState) + } finally { + reopened.authority?.close() + } + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it.each(['invalid JSON', '[]', 'null'])( + 'refuses invalid current JSON without changing either authority: %s', + async (raw) => { + const profile = fixture() + writeFileSync(profile.dataFile, raw) + const before = snapshot(profile) + expect(() => rollback(profile)).toThrow('Profile state JSON') + expect(snapshot(profile)).toEqual(before) + } + ) + + it('refuses a missing canonical JSON without choosing a retained export', async () => { + const profile = fixture() + rmSync(profile.dataFile) + const before = readFileSync(profile.databaseFile) + expect(() => rollback(profile)).toThrow('ENOENT') + expect(readFileSync(profile.databaseFile)).toEqual(before) + expect(existsSync(profile.exportPath)).toBe(true) + }) + + it('refuses while a profile owner holds admission', async () => { + const profile = fixture() + const before = snapshot(profile) + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => rollback(profile)).toThrow('in use') + expect(snapshot(profile)).toEqual(before) + } finally { + admission.release() + } + }) + + it('refuses an unresolved move without removing its journal', async () => { + const profile = fixture() + const before = snapshot(profile) + const moves = join(profile.root, 'profile-move-intents') + mkdirSync(moves) + const journal = join(moves, '00000000-0000-0000-0000-000000000001.json') + writeFileSync(journal, '{"partial":true}') + expect(() => rollback(profile)).toThrow('pending project move') + expect(snapshot(profile)).toEqual(before) + expect(readFileSync(journal, 'utf8')).toBe('{"partial":true}') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-errors.ts b/src/main/persistence/profile-state/profile-state-database-errors.ts new file mode 100644 index 00000000000..65b58470a5e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-errors.ts @@ -0,0 +1,17 @@ +export type ProfileStateDatabaseOpenErrorCode = + | 'unreadable' + | 'identity-mismatch' + | 'invalid-profile-id' + | 'newer-schema' + +export class ProfileStateDatabaseOpenError extends Error { + readonly code: ProfileStateDatabaseOpenErrorCode + readonly cause: unknown + + constructor(code: ProfileStateDatabaseOpenErrorCode, message: string, cause?: unknown) { + super(message) + this.name = 'ProfileStateDatabaseOpenError' + this.code = code + this.cause = cause + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts b/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts new file mode 100644 index 00000000000..4f82cd61a46 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-export-crash.test.ts @@ -0,0 +1,112 @@ +import { spawnProcess } from '../../../shared/child-process/run-process' +import { mkdirSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { build } from 'esbuild' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { importProfileStateJson } from './profile-state-documents' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './profile-state-database' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +const interruptedExportScript = ` + import { openProfileStateDatabaseReadOnly } from './profile-state-database' + import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' + const { db } = openProfileStateDatabaseReadOnly(process.argv[2], 'profile-a') + writeProfileStateDatabaseSnapshotAsync(db, process.argv[3], { + validateStagedSnapshot: async (path) => { + process.stdout.write(path + '\\n') + await new Promise(() => setInterval(() => {}, 1_000)) + } + }).catch((error) => { console.error(error); process.exit(1) }) +` + +async function killBeforePublication(sourcePath: string, targetPath: string): Promise { + const childEntry = join(dirname(sourcePath), 'interrupted-export.cjs') + await build({ + stdin: { + contents: interruptedExportScript, + resolveDir: resolve('src/main/persistence/profile-state'), + loader: 'ts' + }, + outfile: childEntry, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) + const child = spawnProcess({ + program: process.execPath, + args: [childEntry, sourcePath, targetPath], + timeoutMs: 10_000 + }) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + const exited = new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', reject) + }) + const temporaryPath = await new Promise((resolve, reject) => { + let output = '' + child.stdout.on('data', (chunk: Buffer | string) => { + output += String(chunk) + if (output.includes('\n')) { + resolve(output.trim()) + } + }) + child.once('close', () => reject(new Error('Export exited before staging completed'))) + child.once('error', reject) + }) + child.kill('SIGKILL') + await exited + return temporaryPath +} + +describe('profile state database export crash recovery', () => { + it('leaves the destination intact when the production backup dies before publication', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-export-crash-')) + temporaryDirectories.push(directory) + const sourcePath = profileStateDatabaseFile(directory) + const source = openProfileStateDatabase(sourcePath, 'profile-a') + importProfileStateJson(source.db, JSON.stringify({ settings: { theme: 'dark' } }), { + now: () => 100 + }) + source.db.close() + + const targetPath = join(directory, 'recovery', 'profile-state.db') + mkdirSync(join(directory, 'recovery'), { recursive: true }) + writeFileSync(targetPath, 'known-good-destination') + const interruptedPath = await killBeforePublication(sourcePath, targetPath) + + expect(readFileSync(targetPath, 'utf8')).toBe('known-good-destination') + expect(existsSync(interruptedPath)).toBe(true) + rmSync(interruptedPath, { force: true }) + + const recoveredSource = openProfileStateDatabase(sourcePath, 'profile-a') + try { + await writeProfileStateDatabaseSnapshotAsync(recoveredSource.db, targetPath) + } finally { + recoveredSource.db.close() + } + const snapshot = openProfileStateDatabaseReadOnly(targetPath, 'profile-a') + try { + expect( + snapshot.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('revision') + ).toEqual({ value: '1' }) + } finally { + snapshot.db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-publication.ts b/src/main/persistence/profile-state/profile-state-database-publication.ts new file mode 100644 index 00000000000..eb265b43398 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-publication.ts @@ -0,0 +1,38 @@ +import { dirname } from 'node:path' +import { publishFileDurableSync } from '../../durable-file-write' + +class ProfileStateDatabasePublicationError extends Error { + readonly code = 'profile-state-publication-unavailable' as const + + constructor(databaseFile: string, cause: unknown) { + super( + [ + `Orca could not safely publish profile state in ${dirname(databaseFile)}.`, + 'This location must support hard links, and Orca needs permission to create them.', + 'Close Orca and orcad before checking folder permissions or moving the complete Orca data directory to a writable local filesystem that supports hard links, such as APFS, NTFS, or ext4.', + 'Keep the original directory and all recovery files.' + ].join('\n'), + { cause } + ) + this.name = 'ProfileStateDatabasePublicationError' + } +} + +/** Preserve atomic no-overwrite publication while explaining filesystem refusals. */ +export function publishProfileStateDatabase(stagingFile: string, databaseFile: string): boolean { + try { + return publishFileDurableSync(stagingFile, databaseFile) + } catch (error) { + if ( + error instanceof Error && + 'syscall' in error && + error.syscall === 'link' && + 'code' in error && + typeof error.code === 'string' && + ['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EPERM', 'EACCES', 'EXDEV'].includes(error.code) + ) { + throw new ProfileStateDatabasePublicationError(databaseFile, error) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-quarantine.ts b/src/main/persistence/profile-state/profile-state-database-quarantine.ts new file mode 100644 index 00000000000..8e15f9fcf0d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-quarantine.ts @@ -0,0 +1,89 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' + +export type ProfileStateDatabaseQuarantine = { + directory: string + manifestPath: string + copiedFiles: readonly string[] +} + +/** + * Preserve a damaged profile database family before a caller attempts repair or fallback. + * Originals remain in place so this operation cannot turn a recovery failure into data loss. + */ +export function quarantineProfileStateDatabase( + databasePath: string, + profileId: string, + quarantineRoot = dirname(databasePath), + reason = 'profile-state-database-recovery', + recoveryFiles: readonly string[] = [] +): ProfileStateDatabaseQuarantine { + if (databasePath.length === 0 || databasePath.includes('\0') || profileId.length === 0) { + throw new Error('Profile state quarantine arguments are invalid') + } + const sourceFiles = profileStateDatabaseFiles(databasePath).filter(existsSync) + if (sourceFiles.length === 0 && recoveryFiles.length === 0) { + throw new Error('Profile state database family does not exist') + } + + const directory = join(quarantineRoot, `profile-state-corrupt-${Date.now()}-${randomUUID()}`) + const targets = new Set() + mkdirSync(directory, { recursive: true, mode: 0o700 }) + try { + const copies: { source: string; target: string }[] = [] + for (const sourcePath of sourceFiles) { + const targetName = + sourcePath === databasePath + ? 'profile-state.db' + : `profile-state.db${sourcePath.slice(databasePath.length)}` + const targetPath = join(directory, targetName) + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) + } + for (const sourcePath of new Set(recoveryFiles)) { + const targetPath = join(directory, basename(sourcePath)) + if ( + targets.has(targetPath) || + existsSync(targetPath) || + basename(sourcePath) === 'manifest.json' + ) { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + copies.push({ source: sourcePath, target: targetPath }) + targets.add(targetPath) + } + copyProfileStateRecoveryFiles(copies) + const manifestPath = join(directory, 'manifest.json') + // Let the destination filesystem detect case or Unicode aliases of the manifest name. + if (existsSync(manifestPath)) { + throw new Error('Profile recovery artifact name conflicts with the quarantine manifest') + } + for (const { target } of copies) { + hardenSqliteDatabaseFiles(target) + fsyncFileSync(target) + } + writeFileDurableSync( + durableWriteTempPath(manifestPath), + manifestPath, + JSON.stringify({ + schemaVersion: 1, + profileId, + reason, + capturedAt: new Date().toISOString(), + sourceFiles: sourceFiles.map((sourcePath) => sourcePath.slice(databasePath.length)), + recoveryFiles: [...new Set(recoveryFiles)].map((sourcePath) => basename(sourcePath)) + }) + ) + bestEffortFsyncDirectorySync(directory) + return { directory, manifestPath, copiedFiles: [...targets] } + } catch (error) { + rmSync(directory, { recursive: true, force: true }) + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.test.ts b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts new file mode 100644 index 00000000000..5a0fb0c868f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.test.ts @@ -0,0 +1,296 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import * as durableFileWrite from '../../durable-file-write' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { profileStateJsonExportPath } from './profile-state-export-path' + +const directories: string[] = [] +const profileId = 'profile-recovery-test' +const savedJson = JSON.stringify({ + settings: { theme: 'dark', httpProxyUrl: 'safe-storage-sealed-ciphertext' }, + ui: { unknownField: { keep: true } }, + opaqueExtension: { sequence: 71 } +}) + +afterEach(() => { + vi.restoreAllMocks() + for (const path of directories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +async function fixture(options: { profileId?: string; empty?: boolean; json?: string } = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-database-recovery-')) + directories.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const maintenance = acquireProfileStateMaintenance(root) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const exportPath = profileStateJsonExportPath(dataFile, 1) + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const source = openProfileStateDatabase( + join(directory, 'source.db'), + options.profileId ?? profileId + ) + try { + if (!options.empty) { + importProfileStateJson(source.db, options.json ?? savedJson) + } + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + } finally { + source.db.close() + } + writeFileSync(dataFile, '{"settings":{"theme":"stale"}}') + writeFileSync(exportPath, '{"settings":{"theme":"migration"}}') + for (const suffix of ['', '-wal', '-shm', '-journal']) { + writeFileSync(`${databasePath}${suffix}`, `damaged ${suffix || 'primary'}`) + } + return { databasePath, dataFile, backupPath, exportPath, profileId, maintenance } +} + +function readRestored(databasePath: string): string { + const opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + return exportProfileStateJson(opened.db) + } finally { + opened.db.close() + } +} + +function expectOriginals(options: Awaited>): void { + expect(readFileSync(options.databasePath, 'utf8')).toBe('damaged primary') + expect(readFileSync(options.dataFile, 'utf8')).toContain('stale') + expect(existsSync(options.backupPath)).toBe(true) + expect(readdirSync(dirname(options.databasePath)).some((name) => name.endsWith('.tmp'))).toBe( + false + ) +} + +describe('profile state database backup recovery', () => { + it('rejects corruption in a large cloned staging file before changing recovery state', async () => { + const options = await fixture({ + json: JSON.stringify({ opaqueExtension: 'x'.repeat(8 * 1024 * 1024) }) + }) + const backup = new Database(options.backupPath) + try { + backup.exec("UPDATE profile_state_documents SET content_hash = printf('%064d', 0)") + } finally { + backup.close() + } + const originalBackup = readFileSync(options.backupPath) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow() + expectOriginals(options) + expect(readFileSync(options.backupPath).equals(originalBackup)).toBe(true) + expect( + readdirSync(dirname(options.databasePath)).some((name) => + name.startsWith('.orca-recovery-clone-') + ) + ).toBe(false) + }) + + it.each([true, false])( + 'restores SQLite authority with the old database present=%s', + async (hasDatabase) => { + const options = await fixture() + const backupBytes = readFileSync(options.backupPath) + if (!hasDatabase) { + for (const suffix of ['', '-wal', '-shm', '-journal']) { + rmSync(`${options.databasePath}${suffix}`) + } + } + const beforeRestore = vi.fn() + + const result = restoreProfileStateDatabaseBackup({ ...options, beforeRestore }) + + expect(result.revision).toBe(1) + expect(beforeRestore).toHaveBeenCalledOnce() + expect(readRestored(options.databasePath)).toBe(savedJson) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(options.backupPath)).toEqual(backupBytes) + expect(existsSync(options.exportPath)).toBe(false) + expect(readFileSync(join(result.quarantine.directory, basename(options.backupPath)))).toEqual( + backupBytes + ) + expect( + readFileSync(join(result.quarantine.directory, basename(options.dataFile)), 'utf8') + ).toContain('stale') + expect(existsSync(join(result.quarantine.directory, basename(options.exportPath)))).toBe(true) + if (hasDatabase) { + for (const suffix of ['', '-wal', '-shm', '-journal']) { + expect( + readFileSync(join(result.quarantine.directory, `profile-state.db${suffix}`), 'utf8') + ).toBe(`damaged ${suffix || 'primary'}`) + } + } + for (const suffix of ['-wal', '-shm', '-journal']) { + expect(existsSync(`${options.databasePath}${suffix}`)).toBe(false) + } + } + ) + + it.each(['foreign identity', 'empty profile'])( + 'rejects a %s backup before touching recovery state', + async (kind) => { + const options = await fixture( + kind === 'foreign identity' ? { profileId: 'other-profile' } : { empty: true } + ) + const beforeRestore = vi.fn() + expect(() => restoreProfileStateDatabaseBackup({ ...options, beforeRestore })).toThrow() + expect(beforeRestore).not.toHaveBeenCalled() + expectOriginals(options) + } + ) + + it.each(['corrupt hash', 'future schema', 'WAL mode'])( + 'rejects a backup with %s', + async (kind) => { + const options = await fixture() + const backup = new Database(options.backupPath) + try { + if (kind === 'corrupt hash') { + backup.exec("UPDATE profile_state_documents SET payload = '{}' WHERE domain = 'settings'") + } + if (kind === 'future schema') { + backup.pragma('user_version = 999') + } + if (kind === 'WAL mode') { + backup.pragma('journal_mode = WAL') + } + } finally { + backup.close() + } + const beforeRestore = vi.fn() + expect(() => restoreProfileStateDatabaseBackup({ ...options, beforeRestore })).toThrow() + expect(beforeRestore).not.toHaveBeenCalled() + expectOriginals(options) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'rejects a selected backup with a %s sidecar', + async (suffix) => { + const options = await fixture() + writeFileSync(`${options.backupPath}${suffix}`, 'external writer evidence') + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow('not self-contained') + expectOriginals(options) + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a reserved-name symlink to a valid snapshot', + async () => { + const options = await fixture() + const alias = profileStateDatabaseBackupPath( + options.databasePath, + createProfileStateDatabaseBackupId() + ) + symlinkSync(options.backupPath, alias) + expect(() => restoreProfileStateDatabaseBackup({ ...options, backupPath: alias })).toThrow( + 'regular file' + ) + expectOriginals(options) + } + ) + + it('refuses a backup path outside the retained profile inventory', async () => { + const options = await fixture() + expect(() => + restoreProfileStateDatabaseBackup({ ...options, backupPath: options.databasePath }) + ).toThrow('not retained') + expectOriginals(options) + }) + + it('keeps live state untouched when an older artifact cannot be archived', async () => { + const options = await fixture() + mkdirSync(profileStateJsonExportPath(options.dataFile, 2)) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow() + expectOriginals(options) + }) + + it('keeps live state untouched when the pre-restore cache invalidation fails', async () => { + const options = await fixture() + expect(() => + restoreProfileStateDatabaseBackup({ + ...options, + beforeRestore: () => { + throw new Error('injected pre-restore failure') + } + }) + ).toThrow('injected pre-restore failure') + expectOriginals(options) + }) + + it('preserves recoverable evidence when publication fails after removing a damaged family', async () => { + const options = await fixture() + const renameDurableSync = durableFileWrite.renameDurableSync + vi.spyOn(durableFileWrite, 'renameDurableSync').mockImplementation((source, target) => { + if (target === options.databasePath) { + throw new Error('injected snapshot publication failure') + } + return renameDurableSync(source, target) + }) + expect(() => restoreProfileStateDatabaseBackup(options)).toThrow( + 'injected snapshot publication failure' + ) + expect(existsSync(options.databasePath)).toBe(false) + expect(existsSync(options.backupPath)).toBe(true) + const archives = readdirSync(dirname(options.databasePath)).filter((name) => + name.startsWith('profile-state-corrupt-') + ) + expect(archives).toHaveLength(1) + expect( + readFileSync(join(dirname(options.databasePath), archives[0], 'profile-state.db-wal'), 'utf8') + ).toBe('damaged -wal') + vi.restoreAllMocks() + options.maintenance.release() + options.maintenance = acquireProfileStateMaintenance( + dirname(dirname(dirname(options.dataFile))) + ) + restoreProfileStateDatabaseBackup(options) + expect(readRestored(options.databasePath)).toBe(savedJson) + }) + + it('archives and removes SQLite backups and sidecars when explicitly rolling back to JSON', async () => { + const options = await fixture() + writeFileSync(`${options.backupPath}-journal`, 'backup recovery evidence') + const recovered = restoreProfileStateJsonExport(options) + expect(existsSync(options.databasePath)).toBe(false) + expect(existsSync(options.backupPath)).toBe(false) + expect(existsSync(`${options.backupPath}-journal`)).toBe(false) + expect( + readFileSync( + join(recovered.quarantine.directory, `${basename(options.backupPath)}-journal`), + 'utf8' + ) + ).toBe('backup recovery evidence') + expect(readFileSync(options.dataFile, 'utf8')).toContain('migration') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-recovery.ts b/src/main/persistence/profile-state/profile-state-database-recovery.ts new file mode 100644 index 00000000000..106e771978f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-recovery.ts @@ -0,0 +1,113 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { durableWriteTempPath, renameDurableSync } from '../../durable-file-write' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { validateProfileStateSnapshot } from './profile-state-documents' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' +import { + profileStateDatabaseBackups, + profileStateDatabaseBackupFiles +} from './profile-state-backup-path' +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { assertProfileStateMaintenance, type ProfileStateMaintenance } from './profile-state-access' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' + +export type ProfileStateDatabaseRecoveryOptions = { + maintenance: ProfileStateMaintenance + databasePath: string + dataFile: string + backupPath: string + profileId: string + quarantineRoot?: string + reason?: string + beforeRestore?: () => void +} + +export type ProfileStateDatabaseRecovery = { + revision: number + quarantine: ProfileStateDatabaseQuarantine + removedDatabaseFiles: readonly string[] +} + +/** Replace the database family only while startup and offline access are excluded. */ +export function restoreProfileStateDatabaseBackup( + options: ProfileStateDatabaseRecoveryOptions +): ProfileStateDatabaseRecovery { + assertProfileStateMaintenance(options.maintenance, options) + const backups = profileStateDatabaseBackups(options.databasePath) + if (!backups.some((backup) => backup.path === options.backupPath)) { + throw new Error('Selected profile state database backup is not retained by this profile') + } + if (!lstatSync(options.backupPath).isFile()) { + throw new Error('Profile state database backup must be a regular file') + } + if (['-wal', '-shm', '-journal'].some((suffix) => existsSync(`${options.backupPath}${suffix}`))) { + throw new Error('Profile state database backup has sidecars and is not self-contained') + } + + mkdirSync(dirname(options.databasePath), { recursive: true }) + const stagingPath = durableWriteTempPath(options.databasePath) + try { + copyProfileStateRecoveryFile(options.backupPath, stagingPath) + hardenSqliteDatabaseFiles(stagingPath) + const revision = validateRecoverySnapshot(stagingPath, options.profileId) + fsyncFileSync(stagingPath) + const recoveryFiles = [ + ...profileStateDatabaseBackupFiles(options.databasePath), + ...profileStateJsonExportPaths(options.dataFile), + ...(existsSync(options.dataFile) ? [options.dataFile] : []) + ] + const quarantine = quarantineProfileStateDatabase( + options.databasePath, + options.profileId, + options.quarantineRoot, + options.reason ?? 'profile-state-database-rollback', + recoveryFiles + ) + options.beforeRestore?.() + + // JSON exports are revisioned for the legacy authority. Remove them after + // archiving so a later SQLite revision can publish a fresh export at the + // same number without colliding with an older divergent payload. + const retainedJsonExports = profileStateJsonExportPaths(options.dataFile) + for (const exportPath of retainedJsonExports) { + rmSync(exportPath) + } + + const removedDatabaseFiles = profileStateDatabaseFiles(options.databasePath).filter(existsSync) + // Remove the primary first: interruption must fail closed on retained backups, never replay old WAL. + for (const path of removedDatabaseFiles) { + rmSync(path) + } + rmSync(options.dataFile, { force: true }) + bestEffortFsyncDirectorySync(dirname(options.databasePath)) + renameDurableSync(stagingPath, options.databasePath) + return { revision, quarantine, removedDatabaseFiles } + } finally { + for (const path of profileStateDatabaseFiles(stagingPath)) { + rmSync(path, { force: true }) + } + } +} + +function validateRecoverySnapshot(path: string, profileId: string): number { + const opened = openProfileStateDatabaseReadOnly(path, profileId) + try { + if (opened.db.pragma('journal_mode', { simple: true }) !== 'delete') { + throw new Error('Profile state database backup must use a self-contained journal mode') + } + const revision = validateProfileStateSnapshot(opened.db) + if (revision === 0) { + throw new Error('Profile state database backup contains no committed profile state') + } + return revision + } finally { + opened.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts new file mode 100644 index 00000000000..1d8762e5524 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-rollback-export.test.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { openProfileStateDatabase } from './profile-state-database' +import { + bootstrapProfileStateAuthority, + ProfileStateAuthorityBootstrapError +} from './profile-state-authority-bootstrap' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => value.toString().slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +it('can publish an updater JSON export at a reused revision after SQLite rollback', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-database-rollback-export-')) + const directory = join(root, 'profiles', 'rollback-export') + mkdirSync(directory, { recursive: true }) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const profileId = 'rollback-export' + const stores: Store[] = [] + try { + const originalAuthority = new ProfileStateSqliteAuthority(databasePath, profileId) + const original = new Store({ + dataFile, + profileStateAuthority: originalAuthority + }) + stores.push(original) + original.updateSettings({ theme: 'light' }) + await original.flushPendingOrThrowAsync() + await originalAuthority.drainBackups() + const backup = profileStateDatabaseBackups(databasePath)[0] + expect(backup).toBeDefined() + original.updateSettings({ theme: 'dark' }) + const formerRevision = original.writeLatestProfileStateJsonExport() + expect(formerRevision).toBeTypeOf('number') + if (!backup || formerRevision === undefined) { + throw new Error('Missing recovery fixture') + } + const exportPath = profileStateJsonExportPath(dataFile, formerRevision) + const previousExport = readFileSync(exportPath) + original.freezeWrites() + await original.flushAsync() + + const restored = restoreProfileStateDatabaseBackup({ + maintenance: acquireProfileStateMaintenance(root), + databasePath, + dataFile, + profileId, + backupPath: backup.path + }) + expect(existsSync(exportPath)).toBe(false) + expect(readFileSync(join(restored.quarantine.directory, basename(exportPath)))).toEqual( + previousExport + ) + const recovered = new Store({ + dataFile, + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(recovered) + recovered.updateSettings({ theme: 'system' }) + const newRevision = recovered.writeLatestProfileStateJsonExport() + + expect(newRevision).toBe(formerRevision) + expect(JSON.parse(readFileSync(exportPath, 'utf8')).settings.theme).toBe('system') + expect(readFileSync(exportPath)).not.toEqual(previousExport) + } finally { + for (const store of stores) { + store.freezeWrites() + await store.flushAsync() + } + rmSync(root, { recursive: true, force: true }) + } +}) + +it('leaves an explicit rollback path if compatibility publication fails before acceptance', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-store-profile-state-compat-failure-')) + const directory = join(root, 'profiles', 'profile-authority-test') + mkdirSync(directory, { recursive: true }) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'light' } }), 'utf8') + + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile-authority-test') + const store = new Store({ dataFile, profileStateAuthority: authority }) + try { + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + const revisionOne = store.writeLatestProfileStateJsonExport() + expect(revisionOne).toBe(1) + + const opened = openProfileStateDatabase(databasePath, 'profile-authority-test') + opened.db.exec( + `CREATE TRIGGER fail_compatibility_acceptance + BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + BEGIN SELECT RAISE(ABORT, 'injected compatibility failure'); END` + ) + opened.db.close() + + store.updateSettings({ theme: 'light' }) + expect(() => store.writeLatestProfileStateJsonCompatibilityExport()).toThrow( + 'injected compatibility failure' + ) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('light') + expect(() => + bootstrapProfileStateAuthority({ + dataFile, + databaseFile: databasePath, + profileId: 'profile-authority-test' + }) + ).toThrow(ProfileStateAuthorityBootstrapError) + + store.freezeWrites() + await store.flushAsync() + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(root), + databasePath, + dataFile, + exportPath: profileStateJsonExportPath(dataFile, revisionOne ?? 1), + profileId: 'profile-authority-test' + }) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).settings.theme).toBe('dark') + } finally { + store.freezeWrites() + await store.flushAsync() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/persistence/profile-state/profile-state-database-schema.ts b/src/main/persistence/profile-state/profile-state-database-schema.ts new file mode 100644 index 00000000000..4f6cd453825 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-schema.ts @@ -0,0 +1,41 @@ +// Schema 3 requires explicit automation storage metadata even when history is empty. + +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs-model' + +export const PROFILE_STATE_DATABASE_SCHEMA_VERSION = 3 +export const PROFILE_STATE_DOCUMENT_VERSION = 1 + +export const PROFILE_STATE_META_PROFILE_ID = 'profile_id' +export const PROFILE_STATE_META_REVISION = 'revision' +/** Records the legacy JSON bytes accepted by the SQLite authority bootstrap. */ +export const PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE = 'legacy_json_acceptance' + +export function createProfileStateTablesSql(): string { + return `CREATE TABLE IF NOT EXISTS profile_state_meta ( + key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, content_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_META_TABLE} ( + domain TEXT PRIMARY KEY NOT NULL, + presence TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${PROFILE_STATE_AUTOMATION_RUNS_TABLE} ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );` +} diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts new file mode 100644 index 00000000000..da0ebcf7ac5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -0,0 +1,324 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile +} from './profile-state-database' +import { exportProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import type Database from '../../sqlite/sync-database' +import * as durableFileWrite from '../../durable-file-write' +import * as fsPromises from 'node:fs/promises' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const directories: string[] = [] +const databases: Database.Database[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const db of databases.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-async-snapshot-')) + directories.push(directory) + const databasePath = profileStateDatabaseFile(directory) + const { db } = openProfileStateDatabase(databasePath, 'profile-a') + databases.push(db) + const originalJson = JSON.stringify({ settings: { theme: 'light' } }) + importProfileStateJson(db, originalJson) + return { directory, databasePath, db, targetPath: join(directory, 'snapshot.db'), originalJson } +} + +function readSnapshot(path: string): string { + const { db } = openProfileStateDatabaseReadOnly(path, 'profile-a') + try { + return exportProfileStateJson(db) + } finally { + db.close() + } +} + +function expectNoTemporaryFiles(directory: string): void { + expect(readdirSync(directory).filter((name) => name.includes('.tmp'))).toEqual([]) +} + +describe('asynchronous profile-state database snapshots', () => { + it('publishes a hardened self-contained snapshot including committed WAL pages', async () => { + const { directory, databasePath, db, targetPath, originalJson } = fixture() + expect(existsSync(`${databasePath}-wal`)).toBe(true) + const sourceFile = readFileSync(databasePath) + + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + + expect(readSnapshot(targetPath)).toBe(originalJson) + expect(existsSync(`${targetPath}-wal`)).toBe(false) + expect(existsSync(`${targetPath}-shm`)).toBe(false) + if (process.platform !== 'win32') { + expect(statSync(targetPath).mode & 0o777).toBe(0o600) + } + expect(exportProfileStateJson(db)).toBe(originalJson) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(readFileSync(databasePath)).toEqual(sourceFile) + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + expect(readSnapshot(targetPath)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('never removes an existing staging file when exclusive creation fails', async () => { + const { db, databasePath, targetPath, originalJson } = fixture() + await expect( + writeProfileStateDatabaseSnapshotAsync(db, targetPath, { temporaryPath: databasePath }) + ).rejects.toThrow() + expect(existsSync(databasePath)).toBe(true) + expect(exportProfileStateJson(db)).toBe(originalJson) + expect(existsSync(targetPath)).toBe(false) + }) + + it.each(['same connection', 'another connection'] as const)( + 'keeps a consistent complete revision while writes occur from %s', + async (connection) => { + const { directory, databasePath, db, targetPath } = fixture() + const writer = + connection === 'same connection' + ? db + : openProfileStateDatabase(databasePath, 'profile-a').db + if (writer !== db) { + databases.push(writer) + } + const padding = 'x'.repeat(256_000) + importProfileStateJson( + db, + JSON.stringify({ settings: { value: 1 }, ui: { value: 1 }, padding }) + ) + const nextJson = JSON.stringify({ settings: { value: 2 }, ui: { value: 2 }, padding }) + const nativeBackup = db.backup.bind(db) + let wroteDuringBackup = false + vi.spyOn(db, 'backup').mockImplementation((path) => + nativeBackup(path, { + rate: 1, + progress: ({ remainingPages }) => { + if (remainingPages > 0 && !wroteDuringBackup) { + wroteDuringBackup = true + importProfileStateJson(writer, nextJson) + } + } + }) + ) + + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + + expect(wroteDuringBackup).toBe(true) + expect(readSnapshot(targetPath)).toBe(nextJson) + expect(exportProfileStateJson(db)).toBe(nextJson) + expectNoTemporaryFiles(directory) + } + ) + + it('preserves the previous destination and removes staging after native backup fails', async () => { + const { directory, db, targetPath, originalJson } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + vi.spyOn(db, 'backup').mockImplementation(async (path) => { + writeFileSync(path, 'incomplete backup') + writeFileSync(`${path}-journal`, 'incomplete journal') + throw new Error('injected native backup failure') + }) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected native backup failure' + ) + + expect(readFileSync(targetPath)).toEqual(previous) + expect(exportProfileStateJson(db)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('validates staged content before replacing the previous destination', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + + await expect( + writeProfileStateDatabaseSnapshotAsync(db, targetPath, { + validateStagedSnapshot: () => { + throw new Error('staged validation failed') + } + }) + ).rejects.toThrow('staged validation failed') + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it('preserves the previous destination when publication fails', async () => { + const { directory, db, targetPath } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + vi.spyOn(durableFileWrite, 'renameDurable').mockRejectedValue( + new Error('injected rename failure') + ) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected rename failure' + ) + + expect(readFileSync(targetPath)).toEqual(previous) + expectNoTemporaryFiles(directory) + }) + + it('rejects active transactions without publishing uncommitted state', async () => { + const { directory, db, targetPath, originalJson } = fixture() + await writeProfileStateDatabaseSnapshotAsync(db, targetPath) + const previous = readFileSync(targetPath) + db.exec('BEGIN IMMEDIATE') + try { + db.exec("UPDATE profile_state_documents SET payload = '{}' WHERE domain = 'settings'") + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'idle database connection' + ) + } finally { + db.exec('ROLLBACK') + } + + expect(readFileSync(targetPath)).toEqual(previous) + expect(exportProfileStateJson(db)).toBe(originalJson) + expectNoTemporaryFiles(directory) + }) + + it('checks again if a transaction starts while the backup is staging', async () => { + const { directory, db, targetPath } = fixture() + const nativeBackup = db.backup.bind(db) + vi.spyOn(db, 'backup').mockImplementation((path) => { + db.exec('BEGIN IMMEDIATE') + return nativeBackup(path) + }) + try { + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'idle database connection' + ) + } finally { + db.exec('ROLLBACK') + } + expect(existsSync(targetPath)).toBe(false) + expectNoTemporaryFiles(directory) + }) + + it('leaves the previous destination intact when the staged file cannot be fsynced', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const nativeOpen = fsPromises.open + vi.spyOn(fsPromises, 'open').mockImplementation(async (...args) => { + const file = await nativeOpen(...args) + if (args[1] === 'r+') { + vi.spyOn(file, 'sync').mockRejectedValue(new Error('injected fsync failure')) + } + return file + }) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'injected fsync failure' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it('fails clearly when native backup is unsupported without replacing the destination', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const getBuiltinModule = process.getBuiltinModule.bind(process) + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? {} : getBuiltinModule(id) + ) + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'Asynchronous SQLite backup is unavailable' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) + + it.each(['', 'invalid\0path'])('rejects the invalid target %j', async (path) => { + const { db } = fixture() + await expect(writeProfileStateDatabaseSnapshotAsync(db, path)).rejects.toThrow( + 'snapshot path is invalid' + ) + }) + + it.each(['', '-wal', '-shm', '-journal'])( + 'refuses to replace the source database%s', + async (suffix) => { + const { databasePath, db, originalJson } = fixture() + await expect( + writeProfileStateDatabaseSnapshotAsync(db, `${databasePath}${suffix}`) + ).rejects.toThrow('cannot replace a source database') + expect(exportProfileStateJson(db)).toBe(originalJson) + } + ) + + it('rejects a source database reached through a directory alias', async () => { + const { directory, db, originalJson } = fixture() + const alias = join(directory, 'alias') + symlinkSync(directory, alias, 'junction') + await expect( + writeProfileStateDatabaseSnapshotAsync(db, join(alias, 'profile-state.db')) + ).rejects.toThrow('cannot replace a source database') + expect(exportProfileStateJson(db)).toBe(originalJson) + }) + + it.for(['', '-WAL', '-SHM', '-JOURNAL'])( + 'refuses source database%s case aliases on case-insensitive filesystems', + async (suffix, { skip }) => { + const { directory, db, originalJson } = fixture() + const alias = join(directory, 'PROFILE-STATE.DB') + if (!existsSync(alias)) { + skip() + return + } + await expect(writeProfileStateDatabaseSnapshotAsync(db, `${alias}${suffix}`)).rejects.toThrow( + 'cannot replace a source database' + ) + expect(exportProfileStateJson(db)).toBe(originalJson) + } + ) + + it.each(['-wal', '-shm', '-journal'])( + 'preserves a destination with an existing %s sidecar', + async (suffix) => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + writeFileSync(`${targetPath}${suffix}`, 'retained SQLite sidecar') + + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'destination has SQLite sidecars' + ) + + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expect(readFileSync(`${targetPath}${suffix}`, 'utf8')).toBe('retained SQLite sidecar') + expectNoTemporaryFiles(directory) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.ts new file mode 100644 index 00000000000..9cc89d58b59 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.ts @@ -0,0 +1,123 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { access, mkdir, open, realpath, rm, stat } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' +import Database from '../../sqlite/sync-database' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { durableWriteTempPath, renameDurable } from '../../durable-file-write' + +/** The caller owns the destination and keeps the source open until this backup settles. */ +export async function writeProfileStateDatabaseSnapshotAsync( + db: Database.Database, + targetPath: string, + options: { + temporaryPath?: string + validateStagedSnapshot?: (stagingPath: string) => Promise | void + } = {} +): Promise { + if (targetPath.length === 0 || targetPath.includes('\0')) { + throw new Error('Profile state snapshot path is invalid') + } + if (db.isTransaction) { + throw new Error('Profile state database snapshot requires an idle database connection') + } + await mkdir(dirname(targetPath), { recursive: true }) + await assertSnapshotTargetIsSeparate(db, targetPath) + await assertNoSnapshotSidecars(targetPath) + const temporaryPath = options.temporaryPath ?? durableWriteTempPath(targetPath) + let published = false + let created = false + try { + // Pre-create privately: the native backup otherwise creates a world-readable temporary file. + const temporary = await open(temporaryPath, 'wx', 0o600) + created = true + await temporary.close() + await db.backup(temporaryPath) + // The native copy preserves WAL mode; snapshots must not create sidecars when opened read-only. + const snapshot = new Database(temporaryPath, { fileMustExist: true }) + try { + if (snapshot.pragma('journal_mode = DELETE', { simple: true }) !== 'delete') { + throw new Error('Profile state snapshot could not become a self-contained database') + } + } finally { + snapshot.close() + } + hardenSqliteDatabaseFiles(temporaryPath) + const completed = await open(temporaryPath, 'r+') + try { + await completed.sync() + } finally { + await completed.close() + } + await options.validateStagedSnapshot?.(temporaryPath) + await assertNoSnapshotSidecars(targetPath) + await renameDurable(temporaryPath, targetPath) + published = true + } finally { + if (created && !published) { + await rm(temporaryPath, { force: true }) + } + if (created) { + await Promise.all( + ['-wal', '-shm', '-journal'].map((suffix) => + rm(`${temporaryPath}${suffix}`, { force: true }) + ) + ) + } + } +} + +async function assertSnapshotTargetIsSeparate( + db: Database.Database, + targetPath: string +): Promise { + const target = await realpath(targetPath).catch(async (error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return resolve(await realpath(dirname(targetPath)), basename(targetPath)) + }) + const databases = db.prepare('PRAGMA database_list').all() + for (const database of databases) { + if (typeof database.file !== 'string' || database.file.length === 0) { + continue + } + const source = await realpath(database.file) + if (profileStateDatabaseFiles(source).includes(target)) { + throw new Error('Profile state snapshot cannot replace a source database or its sidecars') + } + // realpath preserves case aliases on macOS; file identity also protects absent sidecar names. + const sourceInfo = await stat(source, { bigint: true }) + for (const candidate of new Set([target, target.replace(/-(?:wal|shm|journal)$/i, '')])) { + const targetInfo = await stat(candidate, { bigint: true }).catch((error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return undefined + }) + if (targetInfo?.dev === sourceInfo.dev && targetInfo.ino === sourceInfo.ino) { + throw new Error('Profile state snapshot cannot replace a source database or its sidecars') + } + } + } +} + +async function assertNoSnapshotSidecars(targetPath: string): Promise { + for (const suffix of ['-wal', '-shm', '-journal']) { + const exists = await access(`${targetPath}${suffix}`).then( + () => true, + (error: unknown) => { + if (!isMissingPath(error)) { + throw error + } + return false + } + ) + if (exists) { + throw new Error('Profile state snapshot destination has SQLite sidecars') + } + } +} + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/persistence/profile-state/profile-state-database-validation.ts b/src/main/persistence/profile-state/profile-state-database-validation.ts new file mode 100644 index 00000000000..3bd74421965 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database-validation.ts @@ -0,0 +1,146 @@ +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { readProfileStateRevision } from './profile-state-revision' +import { + PROFILE_STATE_META_PROFILE_ID, + PROFILE_STATE_DATABASE_SCHEMA_VERSION +} from './profile-state-database-schema' +import { + PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, + PROFILE_STATE_AUTOMATION_RUNS_TABLE +} from './profile-state-automation-runs' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' + +export function verifyProfileStateSchema( + db: Database.Database, + profileId: string, + schemaVersion = PROFILE_STATE_DATABASE_SCHEMA_VERSION +): void { + withProfileStateReadSnapshot(db, () => { + const tables = profileStateTableNames(db) + if ( + !tables.has('profile_state_meta') || + !tables.has('profile_state_documents') || + (schemaVersion >= 2 && + (!tables.has(PROFILE_STATE_AUTOMATION_RUNS_META_TABLE) || + !tables.has(PROFILE_STATE_AUTOMATION_RUNS_TABLE))) + ) { + throw new Error('Profile state database schema is incomplete') + } + + verifyProfileStateColumns(db, 'profile_state_meta', { + key: { type: 'TEXT', notNull: true, primaryKey: true }, + value: { type: 'TEXT', notNull: true, primaryKey: false } + }) + verifyProfileStateColumns(db, 'profile_state_documents', { + domain: { type: 'TEXT', notNull: true, primaryKey: true }, + payload: { type: 'TEXT', notNull: true, primaryKey: false }, + domain_version: { type: 'INTEGER', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false } + }) + if (schemaVersion >= 2) { + verifyProfileStateColumns(db, PROFILE_STATE_AUTOMATION_RUNS_META_TABLE, { + domain: { type: 'TEXT', notNull: true, primaryKey: true }, + presence: { type: 'TEXT', notNull: true, primaryKey: false }, + domain_version: { type: 'INTEGER', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false } + }) + verifyProfileStateColumns(db, PROFILE_STATE_AUTOMATION_RUNS_TABLE, { + run_id: { type: 'TEXT', notNull: true, primaryKey: true }, + ordinal: { type: 'INTEGER', notNull: true, primaryKey: false }, + payload: { type: 'TEXT', notNull: true, primaryKey: false }, + content_hash: { type: 'TEXT', notNull: true, primaryKey: false }, + revision: { type: 'INTEGER', notNull: true, primaryKey: false }, + updated_at: { type: 'INTEGER', notNull: true, primaryKey: false } + }) + } + + verifyProfileStateIdentity(db, profileId) + if (schemaVersion >= 3) { + readCurrentAutomationRunsState(db, readProfileStateRevision(db)) + } + }) +} + +export function verifyEmptyProfileStateSchema(db: Database.Database): void { + if (profileStateTableNames(db).size > 0) { + throw new Error('Profile state database has an unexpected version-0 schema') + } +} + +function verifyProfileStateColumns( + db: Database.Database, + table: string, + expected: Readonly> +): void { + const rows = db.pragma(`table_info(${table})`) + if (!Array.isArray(rows)) { + throw new Error(`Profile state table has no readable columns: ${table}`) + } + const columns = new Map() + for (const row of rows) { + if ( + !isRecord(row) || + typeof row.name !== 'string' || + typeof row.type !== 'string' || + typeof row.notnull !== 'number' || + typeof row.pk !== 'number' + ) { + throw new Error(`Profile state table has malformed column metadata: ${table}`) + } + columns.set(row.name, { + type: row.type.toUpperCase(), + notNull: row.notnull === 1, + primaryKey: row.pk === 1 + }) + } + for (const [name, definition] of Object.entries(expected)) { + const actual = columns.get(name) + if ( + actual === undefined || + actual.type !== definition.type || + actual.notNull !== definition.notNull || + actual.primaryKey !== definition.primaryKey + ) { + throw new Error(`Profile state table has an incompatible column: ${table}.${name}`) + } + } +} + +function profileStateTableNames(db: Database.Database): Set { + return new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => (isRecord(row) && typeof row.name === 'string' ? row.name : undefined)) + .filter((name): name is string => name !== undefined) + ) +} + +function verifyProfileStateIdentity(db: Database.Database, profileId: string): void { + const storedProfileIdRow = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_PROFILE_ID) + const storedProfileId = + isRecord(storedProfileIdRow) && typeof storedProfileIdRow.value === 'string' + ? storedProfileIdRow.value + : undefined + if (storedProfileId === undefined) { + throw new Error('Profile state database is missing its profile identity') + } + if (storedProfileId !== profileId) { + throw new ProfileStateDatabaseOpenError( + 'identity-mismatch', + 'Profile state database belongs to a different profile' + ) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts new file mode 100644 index 00000000000..c3cb63fb114 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -0,0 +1,339 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly, + profileStateDatabaseFile, + profileStatePragmaNumber, + PROFILE_STATE_BUSY_TIMEOUT_MS, + PROFILE_STATE_DATABASE_FILE_NAME +} from './profile-state-database' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' +import { importProfileStateJson } from './profile-state-documents' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-db-')) + temporaryDirectories.push(directory) + return directory +} + +describe('profile state database', () => { + it('creates an isolated per-profile schema with durable pragmas', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(dbPath).toBe(join(directory, PROFILE_STATE_DATABASE_FILE_NAME)) + expect(opened.readOnly).toBe(false) + expect(opened.profileId).toBe('profile-a') + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(profileStatePragmaNumber(opened.db, 'synchronous')).toBe(2) + expect(profileStatePragmaNumber(opened.db, 'busy_timeout')).toBe( + PROFILE_STATE_BUSY_TIMEOUT_MS + ) + expect(profileStatePragmaNumber(opened.db, 'foreign_keys')).toBe(1) + expect( + opened.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() + ).toEqual([ + { name: 'profile_state_automation_runs' }, + { name: 'profile_state_automation_runs_meta' }, + { name: 'profile_state_documents' }, + { name: 'profile_state_meta' } + ]) + expect( + opened.db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('profile_id') + ).toEqual({ value: 'profile-a' }) + } finally { + opened.db.close() + } + }) + + it('migrates the version-1 document schema by adding normalized run tables', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + INSERT INTO profile_state_meta (key, value) VALUES ('profile_id', 'profile-a'); + INSERT INTO profile_state_meta (key, value) VALUES ('revision', '1'); + INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES ('settings', '{"theme":"dark"}', 1, 1, 100, '0f4f87db4567232a7f1756aa1534ec1314777b39c3bf5209f87cf9739321cddc'); + `) + seeded.close() + + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + ) + expect( + opened.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() + ).toEqual([ + { name: 'profile_state_automation_runs' }, + { name: 'profile_state_automation_runs_meta' }, + { name: 'profile_state_documents' }, + { name: 'profile_state_meta' } + ]) + expect( + opened.db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('settings') + ).toEqual({ payload: '{"theme":"dark"}' }) + } finally { + opened.db.close() + } + }) + + it('validates normalized tables created during migration', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = 1; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY NOT NULL, + payload TEXT NOT NULL, + domain_version INTEGER NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + content_hash TEXT NOT NULL + ); + CREATE TABLE profile_state_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + payload BLOB NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO profile_state_meta (key, value) VALUES ('profile_id', 'profile-a'); + `) + seeded.close() + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + }) + + it('latches a future schema read-only without changing the database file', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION + 9}`) + seeded.db.close() + const before = statSync(dbPath) + const beforeBytes = readFileSync(dbPath) + + const opened = openProfileStateDatabase(dbPath, 'profile-a') + try { + expect(opened.readOnly).toBe(true) + expect(profileStatePragmaNumber(opened.db, 'user_version')).toBe( + PROFILE_STATE_DATABASE_SCHEMA_VERSION + 9 + ) + expect(() => opened.db.exec("INSERT INTO profile_state_meta VALUES ('x', 'y')")).toThrow() + } finally { + opened.db.close() + } + const after = statSync(dbPath) + expect(after.size).toBe(before.size) + expect(readFileSync(dbPath)).toEqual(beforeBytes) + }) + + it('opens the current schema read-only without changing its bytes', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.close() + const before = readFileSync(dbPath) + + const opened = openProfileStateDatabaseReadOnly(dbPath, 'profile-a') + try { + expect(opened.readOnly).toBe(true) + expect(() => opened.db.exec("INSERT INTO profile_state_meta VALUES ('x', 'y')")).toThrow() + } finally { + opened.db.close() + } + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects a database whose profile identity does not match', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + opened.db.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( + expect.objectContaining({ code: 'identity-mismatch' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects malformed database bytes without replacing them', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const bytes = Buffer.from('not a sqlite database') + writeFileSync(dbPath, bytes) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(bytes) + }) + + it('quarantines the database family without touching live recovery sources', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db.close() + writeFileSync(`${dbPath}-wal`, 'wal-preservation-sentinel') + + const sourceBytes = new Map( + [dbPath, `${dbPath}-wal`].map((path) => [path, readFileSync(path).toString('hex')]) + ) + const result = quarantineProfileStateDatabase( + dbPath, + 'profile-a', + join(directory, 'quarantine'), + 'test-corruption' + ) + + expect(result.copiedFiles).toHaveLength(2) + expect(JSON.parse(readFileSync(result.manifestPath, 'utf8'))).toMatchObject({ + schemaVersion: 1, + profileId: 'profile-a', + reason: 'test-corruption', + sourceFiles: expect.arrayContaining(['', '-wal']) + }) + expect(readFileSync(join(result.directory, 'profile-state.db')).toString('hex')).toBe( + sourceBytes.get(dbPath) + ) + expect(readFileSync(join(result.directory, 'profile-state.db-wal')).toString('hex')).toBe( + sourceBytes.get(`${dbPath}-wal`) + ) + for (const [path, bytes] of sourceBytes) { + expect(readFileSync(path).toString('hex')).toBe(bytes) + } + }) + + it('rejects an unexpected version-0 schema without mutating it', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec('CREATE TABLE unrelated (value TEXT)') + seeded.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects an incomplete current schema without mutating it', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = openProfileStateDatabase(dbPath, 'profile-a') + seeded.db.exec('DROP TABLE profile_state_documents') + seeded.db.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('rejects current-version tables with incompatible columns without mutating them', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const seeded = new Database(dbPath) + seeded.exec(` + PRAGMA user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION}; + CREATE TABLE profile_state_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE profile_state_documents ( + domain TEXT PRIMARY KEY, + payload BLOB NOT NULL, + revision INTEGER NOT NULL + ); + `) + seeded.close() + const before = readFileSync(dbPath) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + expect(readFileSync(dbPath)).toEqual(before) + }) + + it('does not create an empty database when the parent directory is absent', () => { + const directory = createDirectory() + const dbPath = join(directory, 'missing', PROFILE_STATE_DATABASE_FILE_NAME) + + expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( + expect.objectContaining({ code: 'unreadable' }) + ) + }) + + it('rejects an empty profile identity before opening SQLite', () => { + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + + expect(() => openProfileStateDatabase(dbPath, '')).toThrowError( + expect.objectContaining({ code: 'invalid-profile-id' }) + ) + }) + + it('restricts the database and WAL sidecars on POSIX', () => { + if (process.platform === 'win32') { + return + } + const directory = createDirectory() + const dbPath = profileStateDatabaseFile(directory) + const opened = openProfileStateDatabase(dbPath, 'profile-a') + opened.db + .prepare('INSERT INTO profile_state_documents VALUES (?, ?, ?, ?, ?, ?)') + .run('settings', '{}', 1, 1, Date.now(), 'hash') + try { + for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + } finally { + opened.db.close() + } + }) +}) + +describe('profile state database does not reuse orchestration state', () => { + it('uses a profile-local filename', () => { + const directory = createDirectory() + expect(profileStateDatabaseFile(directory)).not.toBe(join(directory, 'orchestration.db')) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts new file mode 100644 index 00000000000..d264d9f27d8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -0,0 +1,268 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import Database from '../../sqlite/sync-database' +import { migrateAutomationRunsStorage } from './profile-state-automation-runs-migration' +import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' +import { + createProfileStateTablesSql, + PROFILE_STATE_DATABASE_SCHEMA_VERSION, + PROFILE_STATE_META_PROFILE_ID +} from './profile-state-database-schema' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { + PROFILE_STATE_DATABASE_FILE_NAME, + profileStateDatabaseFile +} from '../../../shared/profile-state-storage-paths' +import { isRecord } from './profile-state-document-validation' +import { + ProfileStateDatabaseOpenError, + type ProfileStateDatabaseOpenErrorCode +} from './profile-state-database-errors' +import { + verifyEmptyProfileStateSchema, + verifyProfileStateSchema +} from './profile-state-database-validation' + +export const PROFILE_STATE_BUSY_TIMEOUT_MS = 5_000 + +/** + * Probe SQLite without importing the builtin at module evaluation time. + * + * The packaged orcad runtime still supports Node 18, where `node:sqlite` does + * not exist. Keeping this probe beside the opener gives every authority + * selector the same capability decision and keeps that runtime's module graph + * safe to load. + */ +export function isProfileStateSqliteAvailable(): boolean { + if (typeof process.getBuiltinModule !== 'function') { + return false + } + try { + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + return ( + isRecord(sqlite) && + typeof sqlite.DatabaseSync === 'function' && + typeof sqlite.backup === 'function' + ) + } catch { + return false + } +} + +export { ProfileStateDatabaseOpenError } +export type { ProfileStateDatabaseOpenErrorCode } + +export type OpenProfileStateDatabase = { + db: Database.Database + readOnly: boolean + profileId: string +} + +export { PROFILE_STATE_DATABASE_FILE_NAME, profileStateDatabaseFile } + +export function openWritableProfileStateDatabase( + databasePath: string, + profileId: string +): OpenProfileStateDatabase { + mkdirSync(dirname(databasePath), { recursive: true }) + const opened = openProfileStateDatabase(databasePath, profileId) + if (opened.readOnly) { + opened.db.close() + throw new ProfileStateDatabaseOpenError( + 'newer-schema', + 'This profile requires a newer version of Orca' + ) + } + return opened +} + +/** + * Open the database belonging to one profile. + * + * The schema version is read before WAL, busy-timeout, or DDL configuration so + * a newer build can leave the database byte-for-byte untouched and read it + * without accidentally writing through an unknown schema. + */ +export function openProfileStateDatabase( + dbPath: string, + profileId: string +): OpenProfileStateDatabase { + if (profileId.length === 0) { + throw new ProfileStateDatabaseOpenError('invalid-profile-id', 'Profile ID cannot be empty') + } + + let probe: Database.Database + try { + probe = new Database(dbPath) + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open profile state database: ${dbPath}`, + error + ) + } + + let transferred = false + try { + const storedVersion = profileStatePragmaNumber(probe, 'user_version') + verifyProfileStateIntegrity(probe) + if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + probe.close() + transferred = true + try { + return { + db: new Database(dbPath, { + readonly: true, + fileMustExist: true, + timeout: PROFILE_STATE_BUSY_TIMEOUT_MS + }), + readOnly: true, + profileId + } + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open future profile state database read-only: ${dbPath}`, + error + ) + } + } + + if (storedVersion > 0) { + // Validate the complete current shape before WAL setup. A structurally + // valid but incomplete database should fail without changing its header. + verifyProfileStateSchema(probe, profileId, storedVersion) + } else if (storedVersion === 0) { + verifyEmptyProfileStateSchema(probe) + } else { + throw new Error(`Unsupported profile state database schema: ${storedVersion}`) + } + + // The journal-mode pragma can update the SQLite header even when no state + // row is written, so all known-shape validation happens before this point. + migrateProfileStateSchema(probe, storedVersion, profileId) + configureProfileStatePragmas(probe) + hardenSqliteDatabaseFiles(dbPath) + transferred = true + return { db: probe, readOnly: false, profileId } + } catch (error) { + if (error instanceof ProfileStateDatabaseOpenError) { + throw error + } + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to initialize profile state database: ${dbPath}`, + error + ) + } finally { + if (!transferred) { + probe.close() + } + } +} + +/** + * Open an existing profile database without applying migrations or changing + * its journal mode. Callers use this for best-effort reads during GC, where a + * present database is authoritative and any failure must fail closed. + */ +export function openProfileStateDatabaseReadOnly( + dbPath: string, + profileId: string +): OpenProfileStateDatabase { + if (profileId.length === 0) { + throw new ProfileStateDatabaseOpenError('invalid-profile-id', 'Profile ID cannot be empty') + } + if (!existsSync(dbPath)) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Profile state database does not exist: ${dbPath}` + ) + } + + let db: Database.Database + try { + db = new Database(dbPath, { + readonly: true, + fileMustExist: true, + timeout: PROFILE_STATE_BUSY_TIMEOUT_MS + }) + } catch (error) { + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to open profile state database read-only: ${dbPath}`, + error + ) + } + + try { + const storedVersion = profileStatePragmaNumber(db, 'user_version') + verifyProfileStateIntegrity(db) + if (storedVersion > PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + throw new ProfileStateDatabaseOpenError( + 'newer-schema', + `Profile state database schema is newer than this runtime: ${storedVersion}` + ) + } + if (storedVersion !== PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + throw new Error(`Unsupported profile state database schema: ${storedVersion}`) + } + verifyProfileStateSchema(db, profileId) + return { db, readOnly: true, profileId } + } catch (error) { + db.close() + if (error instanceof ProfileStateDatabaseOpenError) { + throw error + } + throw new ProfileStateDatabaseOpenError( + 'unreadable', + `Unable to read profile state database: ${dbPath}`, + error + ) + } +} + +export function profileStatePragmaNumber(db: Database.Database, name: string): number { + return Number(db.pragma(name, { simple: true }) ?? 0) +} + +function verifyProfileStateIntegrity(db: Database.Database): void { + const result = db.pragma('quick_check', { simple: true }) + if (result !== 'ok') { + throw new Error(`Profile state database integrity check failed: ${String(result)}`) + } +} + +function configureProfileStatePragmas(db: Database.Database): void { + const journalMode = db.pragma('journal_mode = WAL', { simple: true }) + if (typeof journalMode !== 'string' || journalMode.toLowerCase() !== 'wal') { + throw new Error(`Profile state database does not support WAL (mode: ${String(journalMode)})`) + } + db.pragma(`busy_timeout = ${PROFILE_STATE_BUSY_TIMEOUT_MS}`) + db.pragma('foreign_keys = ON') + // Profile commits are user-visible state. Keep the same power-loss contract + // as the current temp-file + fsync writer rather than the orchestration DB's + // cache-oriented NORMAL setting. + db.pragma('synchronous = FULL') +} + +function migrateProfileStateSchema( + db: Database.Database, + storedVersion: number, + profileId: string +): void { + if (storedVersion >= PROFILE_STATE_DATABASE_SCHEMA_VERSION) { + return + } + + return withProfileStateWriteTransaction(db, () => { + db.exec(createProfileStateTablesSql()) + db.prepare('INSERT OR IGNORE INTO profile_state_meta (key, value) VALUES (?, ?)').run( + PROFILE_STATE_META_PROFILE_ID, + profileId + ) + migrateAutomationRunsStorage(db, storedVersion) + verifyProfileStateSchema(db, profileId) + db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION}`) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-document-reader.ts b/src/main/persistence/profile-state/profile-state-document-reader.ts new file mode 100644 index 00000000000..075e4f6dc06 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-reader.ts @@ -0,0 +1,99 @@ +import type Database from '../../sqlite/sync-database' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { + assertProfileStateDocumentRevision, + readProfileStateRevision +} from './profile-state-revision' +import { + ProfileStateDocumentCorruptionError, + validateProfileStateDocumentRow, + type ProfileStateDocument, + type ProfileStateParsedDocument, + type ProfileStateValidatedDocument +} from './profile-state-document-validation' + +export type ReadProfileStateDocumentsOptions = { + /** The profile revision already read by a surrounding snapshot. */ + profileRevision?: number +} + +/** Read the authoritative rows after checking their hash, shape, and JSON payload. */ +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation: 'parsed' } +): readonly ProfileStateParsedDocument[] +export function readProfileStateDocuments( + db: Database.Database, + options?: ReadProfileStateDocumentsOptions +): readonly ProfileStateDocument[] +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation: 'validated' } +): void +export function readProfileStateDocuments( + db: Database.Database, + options: ReadProfileStateDocumentsOptions & { representation?: 'parsed' | 'validated' } = {} +): + | readonly (ProfileStateDocument | ProfileStateParsedDocument | ProfileStateValidatedDocument)[] + | void { + const profileRevision = options.profileRevision ?? readProfileStateRevision(db) + const normalized = + options.representation === 'validated' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'validated') + : options.representation === 'parsed' + ? readProfileStateAutomationRunsDocument(db, profileRevision, 'parsed') + : readProfileStateAutomationRunsDocument(db, profileRevision) + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents ORDER BY rowid` + ) + .iterate() + const documents: ( + | ProfileStateDocument + | ProfileStateParsedDocument + | ProfileStateValidatedDocument + )[] = [] + for (const row of rows) { + const document = validateProfileStateDocumentRow(row, { + retainParsedValue: options.representation === 'parsed' + }) + assertProfileStateDocumentRevision(document.revision, profileRevision, document.domain) + if ( + normalized !== undefined && + document.domain === 'automationRuns' && + document.payload !== 'null' + ) { + throw new ProfileStateDocumentCorruptionError( + 'Normalized automationRuns placeholder is invalid', + document.domain + ) + } + if (options.representation === 'validated') { + continue + } + if (options.representation === 'parsed') { + const { payload: _payload, ...parsedDocument } = document + documents.push({ ...parsedDocument, value: document.value }) + } else { + documents.push(document) + } + } + if (options.representation === 'validated') { + return + } + if (normalized === undefined) { + return documents + } + const withoutAutomationRuns = documents.filter((document) => document.domain !== 'automationRuns') + if (normalized === null) { + return withoutAutomationRuns + } + const originalIndex = documents.findIndex((document) => document.domain === 'automationRuns') + withoutAutomationRuns.splice( + originalIndex === -1 ? withoutAutomationRuns.length : originalIndex, + 0, + normalized + ) + return withoutAutomationRuns +} diff --git a/src/main/persistence/profile-state/profile-state-document-validation.ts b/src/main/persistence/profile-state/profile-state-document-validation.ts new file mode 100644 index 00000000000..52638353921 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-document-validation.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' + +export type ProfileStateDocument = { + domain: string + payload: string + domainVersion: number + revision: number + updatedAt: number + contentHash: string +} + +export type ProfileStateValidatedDocument = Omit +export type ProfileStateParsedDocument = ProfileStateValidatedDocument & { value: unknown } + +export class ProfileStateDocumentCorruptionError extends Error { + readonly code = 'corrupt-document' as const + readonly domain: string | null + + constructor(message: string, domain: string | null = null) { + super(message) + this.name = 'ProfileStateDocumentCorruptionError' + this.domain = domain + } +} + +export class ProfileStateRevisionConflictError extends Error { + readonly code = 'profile-state-revision-conflict' as const + readonly expectedRevision: number + readonly actualRevision: number + + constructor(expectedRevision: number, actualRevision: number) { + super( + `Profile state revision changed while importing a document (expected ${expectedRevision}, found ${actualRevision})` + ) + this.name = 'ProfileStateRevisionConflictError' + this.expectedRevision = expectedRevision + this.actualRevision = actualRevision + } +} + +export function hashProfileStatePayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex') +} + +export function parseProfileStateRoot(rawJson: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(rawJson) + } catch { + throw new ProfileStateDocumentCorruptionError('Profile state JSON is invalid', null) + } + if (!isRecord(parsed)) { + throw new ProfileStateDocumentCorruptionError('Profile state JSON root must be an object', null) + } + return parsed +} + +export function validateProfileStateDocumentRow( + row: unknown, + options: { validateJson?: boolean; retainParsedValue?: boolean } = {} +): ProfileStateDocument & { value?: unknown } { + if ( + !isRecord(row) || + typeof row.domain !== 'string' || + typeof row.payload !== 'string' || + typeof row.domain_version !== 'number' || + typeof row.revision !== 'number' || + typeof row.updated_at !== 'number' || + typeof row.content_hash !== 'string' + ) { + throw new ProfileStateDocumentCorruptionError('Profile state document row has invalid fields') + } + if ( + !Number.isSafeInteger(row.domain_version) || + row.domain_version < 1 || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !Number.isSafeInteger(row.updated_at) || + row.updated_at < 0 || + !/^[a-f0-9]{64}$/.test(row.content_hash) + ) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document row metadata is invalid: ${row.domain}`, + row.domain + ) + } + if (hashProfileStatePayload(row.payload) !== row.content_hash) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document hash mismatch: ${row.domain}`, + row.domain + ) + } + let value: unknown + if (options.retainParsedValue || (options.validateJson ?? true)) { + try { + value = JSON.parse(row.payload) + } catch { + throw new ProfileStateDocumentCorruptionError( + `Profile state document payload is invalid JSON: ${row.domain}`, + row.domain + ) + } + } + return { + domain: row.domain, + payload: row.payload, + domainVersion: row.domain_version, + revision: row.revision, + updatedAt: row.updated_at, + contentHash: row.content_hash, + ...(options.retainParsedValue ? { value } : {}) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/persistence/profile-state/profile-state-documents.test.ts b/src/main/persistence/profile-state/profile-state-documents.test.ts new file mode 100644 index 00000000000..1afdae3d60c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.test.ts @@ -0,0 +1,370 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + exportProfileStateJson, + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance, + readProfileStateDocuments, + readProfileStateRevision, + readProfileStateSnapshot, + readProfileStateParsedSnapshot +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { readProfileStateDomains } from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openTestDatabase(): { + directory: string + db: ReturnType['db'] +} { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-documents-')) + temporaryDirectories.push(directory) + return { + directory, + db: openProfileStateDatabase(profileStateDatabaseFile(directory), 'profile-a').db + } +} + +describe('profile state document adapter', () => { + it('round-trips every top-level domain, including unknown keys, nulls, arrays, and sealed bytes', () => { + const { db } = openTestDatabase() + try { + const fixture = buildProfileStateCutoverFixture() + const raw = JSON.stringify(fixture) + expect(importProfileStateJson(db, raw, { now: () => 123 })).toBe(1) + const exported = exportProfileStateJson(db) + + expect(canonicalProfileStateJson(JSON.parse(exported))).toBe( + canonicalProfileStateJson(fixture) + ) + expect(JSON.parse(exported).settings.opencodeSessionCookie).toBe( + fixture.settings.opencodeSessionCookie + ) + expect(readProfileStateRevision(db)).toBe(1) + expect(readProfileStateDocuments(db)).toHaveLength(Object.keys(fixture).length) + expect(readProfileStateDocuments(db).find((row) => row.domain === 'settings')).toMatchObject({ + revision: 1, + updatedAt: 123 + }) + } finally { + db.close() + } + }) + + it('preserves missing domains versus explicit null values and array order', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + explicitNull: null, + ordered: ['first', 'second'], + unknown: { keep: true } + }) + ) + const exported = JSON.parse(exportProfileStateJson(db)) + expect(exported).toEqual({ + explicitNull: null, + ordered: ['first', 'second'], + unknown: { keep: true } + }) + expect(Object.hasOwn(exported, 'missing')).toBe(false) + } finally { + db.close() + } + }) + + it('advances revision and replaces the complete document set atomically', () => { + const { db } = openTestDatabase() + try { + expect(importProfileStateJson(db, JSON.stringify({ first: 1, old: 2 }))).toBe(1) + expect(importProfileStateJson(db, JSON.stringify({ second: 3 }), { now: () => 456 })).toBe(2) + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ second: 3 })) + expect(readProfileStateRevision(db)).toBe(2) + expect(readProfileStateDocuments(db)[0]).toMatchObject({ + domain: 'second', + revision: 2, + updatedAt: 456 + }) + } finally { + db.close() + } + }) + + it('rejects a stale complete-document replacement before deleting rows', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + expect(() => + importProfileStateJson(db, JSON.stringify({ replacement: true }), { + expectedRevision: 0 + }) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 0, + actualRevision: 1 + }) + ) + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('commits the legacy JSON acceptance marker with the imported revision', () => { + const { db } = openTestDatabase() + try { + const raw = JSON.stringify({ settings: { theme: 'dark' } }) + expect( + importProfileStateJson(db, raw, { + acceptedLegacyJsonHash: hashProfileStateJson(raw), + now: () => 123 + }) + ).toBe(1) + expect(readProfileStateJsonAcceptance(db)).toEqual({ + jsonHash: hashProfileStateJson(raw), + acceptedRevision: 1 + }) + } finally { + db.close() + } + }) + + it('rolls back every row and the revision when one insert fails', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: { value: 1 } }), { now: () => 10 }) + db.exec( + `CREATE TRIGGER fail_profile_state_insert + BEFORE INSERT ON profile_state_documents + WHEN NEW.domain = 'second' + BEGIN SELECT RAISE(ABORT, 'injected document failure'); END` + ) + + expect(() => importProfileStateJson(db, JSON.stringify({ first: 1, second: 2 }))).toThrow( + 'injected document failure' + ) + db.exec('DROP TRIGGER fail_profile_state_insert') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: { value: 1 } })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('does not roll back a transaction owned by the caller', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + db.exec('BEGIN IMMEDIATE') + + expect(() => importProfileStateJson(db, JSON.stringify({ replacement: true }))).toThrow( + 'requires an idle database connection' + ) + expect(db.isTransaction).toBe(true) + db.exec('ROLLBACK') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + } finally { + if (db.isTransaction) { + db.exec('ROLLBACK') + } + db.close() + } + }) + + it('rejects a tampered payload when its hash no longer matches', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ ui: { active: 'terminal' } })) + db.prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?').run( + JSON.stringify({ active: 'tasks' }), + 'ui' + ) + + expect(() => readProfileStateDocuments(db)).toThrowError( + expect.objectContaining({ domain: 'ui' }) + ) + expect(() => exportProfileStateJson(db)).toThrowError(/hash mismatch: ui/) + } finally { + db.close() + } + }) + + it('rejects invalid domain JSON even when its hash is correct', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run('{invalid', hashProfileStateJson('{invalid'), 'settings') + + expect(() => readProfileStateDocuments(db)).toThrow(/invalid JSON: settings/) + expect(() => readProfileStateSnapshot(db)).toThrow(/invalid JSON: settings/) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: settings/) + } finally { + db.close() + } + }) + + it.each(['[]', ' null '])( + 'rejects the noncanonical normalized history placeholder %s', + (payload) => { + const { db } = openTestDatabase() + try { + const original = { + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }], + ui: { sidebarWidth: 280 } + } + importProfileStateJson(db, JSON.stringify(original)) + expect( + db + .prepare('SELECT payload FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual({ payload: 'null' }) + expect(readProfileStateSnapshot(db).json).toBe(JSON.stringify(original)) + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'automationRuns') + expect(() => readProfileStateSnapshot(db)).toThrow(/placeholder is invalid/) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/placeholder is invalid/) + } finally { + db.close() + } + } + ) + + it('rejects a retained legacy document whose revision is ahead of the profile', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.prepare('UPDATE profile_state_documents SET revision = ? WHERE domain = ?').run( + 999, + 'automationRuns' + ) + + expect(() => readProfileStateDocuments(db)).toThrow( + /document revision 999 exceeds profile revision 1/ + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 1/ + ) + } finally { + db.close() + } + }) + + it.each(['null', 'absent'] as const)( + 'rejects normalized %s metadata whose revision is ahead of the profile', + (presence) => { + const { directory, db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: presence === 'null' ? 'null' : null, + expectedRevision: 1 + }) + expect(db.prepare('SELECT presence FROM profile_state_automation_runs_meta').get()).toEqual( + { + presence + } + ) + db.prepare('UPDATE profile_state_automation_runs_meta SET revision = ?').run(999) + + expect(() => readProfileStateSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 2/ + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow( + /document revision 999 exceeds profile revision 2/ + ) + expect( + readProfileStateDomains(profileStateDatabaseFile(directory), 'profile-a', [ + 'automationRuns' + ]) + ).toMatchObject({ + kind: 'unreadable' + }) + } finally { + db.close() + } + } + ) + + it.each(['missing', 'absent', 'null'] as const)( + 'rejects %s automation metadata with remaining normalized runs on every read path', + (presence) => { + const { directory, db } = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + automationRuns: [{ id: 'run-1', status: 'completed' }] + }) + if (presence === 'missing') { + db.exec('DELETE FROM profile_state_automation_runs_meta') + } else { + db.prepare( + 'UPDATE profile_state_automation_runs_meta SET presence = ?, content_hash = ?' + ).run(presence, presence === 'absent' ? '' : hashProfileStateJson('null')) + } + + const expectedError = + presence === 'missing' + ? 'Normalized automationRuns metadata is malformed' + : 'Normalized automationRuns rows exist for an empty domain' + expect(() => readProfileStateSnapshot(db)).toThrow(expectedError) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(expectedError) + const databasePath = profileStateDatabaseFile(directory) + const authority = new ProfileStateSqliteAuthority(databasePath, 'profile-a') + expect(() => authority.readSerializedState()).toThrow( + presence === 'missing' ? /Unable to read profile state database/ : expectedError + ) + expect( + readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + ).toMatchObject({ + kind: 'unreadable' + }) + } finally { + db.close() + } + } + ) + + it('rejects malformed input without changing an existing revision', () => { + const { db } = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ keep: true })) + expect(() => importProfileStateJson(db, '{invalid')).toThrow('Profile state JSON is invalid') + expect(exportProfileStateJson(db)).toBe(JSON.stringify({ keep: true })) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-documents.ts b/src/main/persistence/profile-state/profile-state-documents.ts new file mode 100644 index 00000000000..f20b0d0ccdc --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-documents.ts @@ -0,0 +1,232 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, + PROFILE_STATE_DOCUMENT_VERSION, + PROFILE_STATE_META_REVISION +} from './profile-state-database-schema' +import { + hashProfileStatePayload, + parseProfileStateRoot, + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError, + type ProfileStateDocument +} from './profile-state-document-validation' +import { + clearProfileStateAutomationRuns, + rebuildProfileStateAutomationRunsProjection +} from './profile-state-automation-runs' + +import { readProfileStateDocuments } from './profile-state-document-reader' + +import { readProfileStateRevision } from './profile-state-revision' +import { readProfileStateJsonAcceptance } from './profile-state-json-acceptance' + +export { + acceptProfileStateJsonCompatibility, + readProfileStateJsonAcceptance, + stageProfileStateJsonCompatibility, + type ProfileStateJsonAcceptance +} from './profile-state-json-acceptance' + +export { readProfileStateRevision } from './profile-state-revision' +export { readProfileStateDocuments } from './profile-state-document-reader' +export type { ReadProfileStateDocumentsOptions } from './profile-state-document-reader' +export { ProfileStateDocumentCorruptionError, ProfileStateRevisionConflictError } +export type { ProfileStateDocument } from './profile-state-document-validation' + +export type ImportProfileStateOptions = { + now?: () => number + /** Hash of the exact legacy JSON bytes accepted by this import. */ + acceptedLegacyJsonHash?: string + /** Revision observed by the caller before constructing this replacement. */ + expectedRevision?: number +} + +export type ProfileStateSnapshot = { + revision: number + documents: readonly ProfileStateDocument[] + json: string +} + +export type ProfileStateParsedSnapshot = { + revision: number + state: Record +} + +/** Import a complete JSON document set atomically into one profile database. */ +export function importProfileStateJson( + db: Database.Database, + rawJson: string, + options: ImportProfileStateOptions = {} +): number { + const parsed = parseProfileStateRoot(rawJson) + const entries = Object.entries(parsed).map(([domain, value]) => { + const payload = JSON.stringify(value) + if (payload === undefined) { + throw new ProfileStateDocumentCorruptionError( + `Profile state domain cannot be serialized: ${domain}`, + domain + ) + } + return { domain, payload } + }) + + const now = options.now ?? Date.now + if ( + options.acceptedLegacyJsonHash !== undefined && + !/^[a-f0-9]{64}$/.test(options.acceptedLegacyJsonHash) + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance hash is invalid') + } + if ( + options.expectedRevision !== undefined && + (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 0) + ) { + throw new ProfileStateDocumentCorruptionError('Expected profile state revision is invalid') + } + const updatedAt = now() + if (!Number.isSafeInteger(updatedAt) || updatedAt < 0) { + throw new ProfileStateDocumentCorruptionError('Profile state update timestamp is invalid') + } + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (options.expectedRevision !== undefined && actualRevision !== options.expectedRevision) { + throw new ProfileStateRevisionConflictError(options.expectedRevision, actualRevision) + } + readCurrentAutomationRunsState(db, actualRevision) + const revision = actualRevision + 1 + clearProfileStateAutomationRuns(db) + db.exec('DELETE FROM profile_state_documents') + const automationRuns = entries.find((entry) => entry.domain === 'automationRuns') + const normalizedRuns = + automationRuns !== undefined && + rebuildProfileStateAutomationRunsProjection( + db, + automationRuns.payload, + PROFILE_STATE_DOCUMENT_VERSION, + updatedAt, + revision + ) + const insert = db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?)` + ) + for (const entry of entries) { + const payload = normalizedRuns && entry.domain === 'automationRuns' ? 'null' : entry.payload + insert.run( + entry.domain, + payload, + PROFILE_STATE_DOCUMENT_VERSION, + revision, + updatedAt, + hashProfileStatePayload(payload) + ) + } + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_REVISION, String(revision)) + if (options.acceptedLegacyJsonHash !== undefined) { + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run( + PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, + JSON.stringify({ jsonHash: options.acceptedLegacyJsonHash, acceptedRevision: revision }) + ) + } + return revision + }) +} + +export function hashProfileStateJson(rawJson: string): string { + return hashProfileStatePayload(rawJson) +} + +/** Validate that retained legacy JSON is the exact export accepted by this database. */ +export function profileStateJsonMatchesAcceptance(db: Database.Database, rawJson: string): boolean { + return readAcceptedProfileStateSnapshot(db, rawJson) !== undefined +} + +/** Validate the retained JSON and return the same database snapshot used for acceptance. */ +export function readAcceptedProfileStateSnapshot( + db: Database.Database, + rawJson: string +): ProfileStateSnapshot | undefined { + return readAcceptedSnapshot(db, rawJson, () => readProfileStateSnapshot(db)) +} + +export function readAcceptedProfileStateParsedSnapshot( + db: Database.Database, + rawJson: string +): ProfileStateParsedSnapshot | undefined { + return readAcceptedSnapshot(db, rawJson, () => readProfileStateParsedSnapshot(db)) +} + +function readAcceptedSnapshot( + db: Database.Database, + rawJson: string, + read: () => T +): T | undefined { + return withProfileStateReadSnapshot(db, () => { + const marker = readProfileStateJsonAcceptance(db) + const snapshot = read() + const jsonHash = hashProfileStateJson(rawJson) + return marker !== undefined && + (marker.jsonHash === jsonHash || marker.pending?.jsonHash === jsonHash) && + snapshot.revision >= (marker.pending?.acceptedRevision ?? marker.acceptedRevision) + ? snapshot + : undefined + }) +} + +/** Transfer independently validated values without constructing another whole-profile string. */ +export function readProfileStateParsedSnapshot(db: Database.Database): ProfileStateParsedSnapshot { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + const documents = readProfileStateDocuments(db, { + profileRevision: revision, + representation: 'parsed' + }) + return { + revision, + state: Object.fromEntries(documents.map((document) => [document.domain, document.value])) + } + }) +} + +/** Export the row set as JSON accepted by the current loader. */ +export function exportProfileStateJson(db: Database.Database): string { + return readProfileStateSnapshot(db).json +} + +/** Read revision, rows, and their JSON projection under one SQLite snapshot. */ +export function readProfileStateSnapshot(db: Database.Database): ProfileStateSnapshot { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + const documents = readProfileStateDocuments(db, { profileRevision: revision }) + return { + revision, + documents, + // Every payload was already hash- and JSON-validated above. Reusing the + // validated fragments avoids parsing and stringifying the full profile a + // second time before Store parses it at its domain boundary. + json: `{${documents + .map((document) => `${JSON.stringify(document.domain)}:${document.payload}`) + .join(',')}}` + } + }) +} + +/** Validate the complete snapshot without retaining state that recovery callers discard. */ +export function validateProfileStateSnapshot(db: Database.Database): number { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + readProfileStateDocuments(db, { profileRevision: revision, representation: 'validated' }) + return revision + }) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-equality.test.ts b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts new file mode 100644 index 00000000000..df9dfe3efb9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-equality.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const databases: { db: ReturnType['db']; directory: string }[] = [] + +function fixture(value: unknown = { future: { content: '雪 🐋', nullable: null } }) { + const directory = mkdtempSync(join(tmpdir(), 'orca-domain-equality-')) + const { db } = openProfileStateDatabase(join(directory, 'state.db'), 'profile') + importProfileStateJson(db, JSON.stringify({ settings: {}, extension: value })) + databases.push({ db, directory }) + return { db, payload: JSON.stringify(value) } +} + +afterEach(() => { + for (const { db, directory } of databases.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('validated domain replacement equality', () => { + it.each([{ nested: { content: '雪 🐋', nullable: null } }, null])( + 'preserves equal payload, revision and timestamp for %j', + (value) => { + const { db, payload } = fixture(value) + const before = readProfileStateSnapshot(db) + const rows = db.prepare('SELECT * FROM profile_state_documents').all() + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'extension', + payload, + now: () => { + throw new Error('An unchanged replacement must not request a timestamp') + } + } + ] + }) + ).toEqual({ changed: false, revision: 1, changedDomains: [] }) + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(db.prepare('SELECT * FROM profile_state_documents').all()).toEqual(rows) + + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload: null }] + }) + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ settings: {} }) + } + ) + + it.each([ + ["content_hash = 'invalid'", /metadata is invalid/], + [`content_hash = '${'a'.repeat(64)}'`, /hash mismatch/], + ['domain_version = 0', /metadata is invalid/], + ['updated_at = -1', /metadata is invalid/], + ['revision = 0', /metadata is invalid/], + ['revision = 2', /exceeds profile revision/] + ] as const)('rejects equal payload with corrupt %s', (assignment, error) => { + const { db, payload } = fixture() + db.exec(`UPDATE profile_state_documents SET ${assignment} WHERE domain = 'extension'`) + const before = db.prepare('SELECT * FROM profile_state_documents').all() + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: '{"changed":true}' }, + { domain: 'extension', payload } + ] + }) + ).toThrow(error) + expect(readProfileStateRevision(db)).toBe(1) + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT * FROM profile_state_documents').all()).toEqual(before) + }) + + it.each(['{', '{"valid":true}', null])( + 'rejects malformed stored JSON with a matching hash on replacement %j', + (payload) => { + const { db } = fixture() + db.prepare( + "UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = 'extension'" + ).run('{', hashProfileStateJson('{')) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload }] + }) + ).toThrow(/invalid JSON: extension/) + expect(readProfileStateRevision(db)).toBe(1) + expect(db.isTransaction).toBe(false) + expect( + db.prepare("SELECT payload FROM profile_state_documents WHERE domain = 'extension'").get() + ).toEqual({ payload: '{' }) + } + ) + + it('ignores caller-supplied prepared history when writing another domain', () => { + const { db } = fixture() + const replacement = { + domain: 'settings', + payload: '{"changed":true}', + automationRuns: { incoming: { presence: 'absent', contentHash: '' }, domainVersion: 1 } + } + + writeProfileStateDomains(db, { expectedRevision: 1, replacements: [replacement] }) + + expect(JSON.parse(readProfileStateSnapshot(db).json)).toEqual({ + settings: { changed: true }, + extension: { future: { content: '雪 🐋', nullable: null } } + }) + }) + + it('fences a stale writer even when its payload remains equal', () => { + const { db, payload } = fixture() + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'settings', payload: '{"changed":true}' }] + }) + const before = readProfileStateSnapshot(db) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [{ domain: 'extension', payload }] + }) + ).toThrow(expect.objectContaining({ code: 'profile-state-revision-conflict' })) + expect(readProfileStateSnapshot(db)).toEqual(before) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.test.ts b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts new file mode 100644 index 00000000000..c6c9d89c6db --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { importProfileStateJson } from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' +import { writeProfileStateDomains } from './profile-state-domain-writes' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createDatabase(): { directory: string; databasePath: string } { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-reader-')) + temporaryDirectories.push(directory) + return { directory, databasePath: profileStateDatabaseFile(directory) } +} + +describe('profile state domain reader', () => { + it('does not parse unrelated domains', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ settings: { theme: 'dark' }, unrelated: { keep: true } }) + ) + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'unrelated') + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['settings']) + expect(result).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['settings', { theme: 'dark' }]]) + }) + }) + + it('fails closed when a selected domain is corrupt', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson(opened.db, JSON.stringify({ settings: { theme: 'dark' } })) + opened.db + .prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?') + .run('{invalid', 'settings') + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['settings']) + expect(result.kind).toBe('unreadable') + }) + + it('reads the normalized automation projection when selected', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + expect(result).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', [{ id: 'run-1', status: 'pending' }]]]) + }) + }) + + it('preserves missing versus explicit null automation runs', () => { + const missing = createDatabase() + const missingOpened = openProfileStateDatabase(missing.databasePath, 'profile-a') + importProfileStateJson(missingOpened.db, JSON.stringify({ settings: { theme: 'dark' } })) + missingOpened.db.close() + const missingResult = readProfileStateDomains(missing.databasePath, 'profile-a', [ + 'automationRuns' + ]) + expect(missingResult.kind).toBe('values') + expect( + missingResult.kind === 'values' ? missingResult.values.has('automationRuns') : true + ).toBe(false) + + const explicitNull = createDatabase() + const nullOpened = openProfileStateDatabase(explicitNull.databasePath, 'profile-a') + importProfileStateJson( + nullOpened.db, + JSON.stringify({ settings: { theme: 'dark' }, automationRuns: null }) + ) + nullOpened.db.close() + const nullResult = readProfileStateDomains(explicitNull.databasePath, 'profile-a', [ + 'automationRuns' + ]) + expect(nullResult).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', null]]) + }) + }) + + it('does not resurrect a stale legacy automation row after normalized deletion', () => { + const { databasePath } = createDatabase() + const opened = openProfileStateDatabase(databasePath, 'profile-a') + importProfileStateJson( + opened.db, + JSON.stringify({ automationRuns: [{ id: 'run-1', status: 'pending' }] }) + ) + writeProfileStateDomains(opened.db, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: null }] + }) + opened.db.close() + + const result = readProfileStateDomains(databasePath, 'profile-a', ['automationRuns']) + expect(result.kind).toBe('values') + expect(result.kind === 'values' ? result.values.has('automationRuns') : true).toBe(false) + }) +}) + +it('reuses independently parsed values for selected domains and history rows', () => { + const { databasePath } = createDatabase() + const { db } = openProfileStateDatabase(databasePath, 'profile-a') + const settings = '{"theme":"dark"}' + const row = '{"id":"run-a","output":"retained"}' + importProfileStateJson(db, `{"settings":${settings},"automationRuns":[${row}]}`) + const parse = vi.spyOn(JSON, 'parse') + try { + expect( + readProfileStateDomainsWithRevisionFromDatabase(db, ['settings', 'automationRuns']) + ).toEqual({ + kind: 'values', + revision: 1, + values: new Map([ + ['settings', { theme: 'dark' }], + ['automationRuns', [{ id: 'run-a', output: 'retained' }]] + ]) + }) + expect(parse.mock.calls.filter(([input]) => input === settings)).toHaveLength(1) + expect(parse.mock.calls.filter(([input]) => input === row)).toHaveLength(1) + expect(parse.mock.calls.some(([input]) => input === `[${row}]`)).toBe(false) + } finally { + parse.mockRestore() + db.close() + } +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-reader.ts b/src/main/persistence/profile-state/profile-state-domain-reader.ts new file mode 100644 index 00000000000..2bd3a642e8e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-reader.ts @@ -0,0 +1,115 @@ +import { withProfileStateReadSnapshot } from './profile-state-read-snapshot' +import { + assertProfileStateDocumentRevision, + readProfileStateRevision +} from './profile-state-revision' +import { readProfileStateAutomationRunsDocument } from './profile-state-automation-runs' +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { + validateProfileStateDocumentRow, + type ProfileStateParsedDocument +} from './profile-state-document-validation' + +export type ProfileStateDomainReadResult = + | { kind: 'value'; value: unknown } + | { kind: 'missing' } + | { kind: 'unreadable'; error: unknown } + +export type ProfileStateDomainsReadResult = + | { kind: 'values'; revision: number; values: ReadonlyMap } + | { kind: 'unreadable'; error: unknown } + +/** + * Read one domain from an existing profile database without opening a write + * handle, applying migrations, or creating the database. Any malformed selected + * row makes the whole read unusable so callers that prune state can fail closed. + */ +export function readProfileStateDomain( + databasePath: string, + profileId: string, + domain: string +): ProfileStateDomainReadResult { + const result = readProfileStateDomains(databasePath, profileId, [domain]) + if (result.kind === 'unreadable') { + return result + } + if (!result.values.has(domain)) { + return { kind: 'missing' } + } + return { kind: 'value', value: result.values.get(domain) } +} + +/** Read several domains and the fencing revision under one SQLite snapshot. */ +export function readProfileStateDomains( + databasePath: string, + profileId: string, + domains: readonly string[] +): ProfileStateDomainsReadResult { + let opened: ReturnType | undefined + try { + opened = openProfileStateDatabaseReadOnly(databasePath, profileId) + return readProfileStateDomainsWithRevisionFromDatabase(opened.db, domains) + } catch (error) { + return { kind: 'unreadable', error } + } finally { + opened?.db.close() + } +} + +/** Read several domains from an already-open database, keeping the caller's handle alive. */ +export function readProfileStateDomainsWithRevisionFromDatabase( + db: Parameters[0], + domains: readonly string[] +): ProfileStateDomainsReadResult { + const wanted = new Set(domains) + const values = new Map() + try { + return withProfileStateReadSnapshot(db, () => { + const revision = readProfileStateRevision(db) + if (wanted.size === 0) { + return { kind: 'values', revision, values } + } + + const normalized = wanted.has('automationRuns') + ? readProfileStateAutomationRunsDocument(db, revision, 'parsed') + : undefined + const legacyDomains = [...wanted].filter( + (domain) => domain !== 'automationRuns' || normalized === undefined + ) + for (const document of readSelectedDocuments(db, legacyDomains)) { + assertProfileStateDocumentRevision(document.revision, revision, document.domain) + values.set(document.domain, document.value) + } + if (normalized !== undefined && normalized !== null) { + values.set(normalized.domain, normalized.value) + } + return { kind: 'values', revision, values } + }) + } catch (error) { + return { kind: 'unreadable', error } + } +} + +function readSelectedDocuments( + db: Parameters[0], + domains: readonly string[] +): readonly ProfileStateParsedDocument[] { + if (domains.length === 0) { + return [] + } + const placeholders = domains.map(() => '?').join(',') + const rows = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents + WHERE domain IN (${placeholders}) + ORDER BY rowid` + ) + .iterate(...domains) + return Array.from(rows, (row) => { + const { payload: _payload, ...document } = validateProfileStateDocumentRow(row, { + retainParsedValue: true + }) + return { ...document, value: document.value } + }) +} diff --git a/src/main/persistence/profile-state/profile-state-domain-write-validation.ts b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts new file mode 100644 index 00000000000..bac86a1610a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-write-validation.ts @@ -0,0 +1,78 @@ +import { PROFILE_STATE_DOCUMENT_VERSION } from './profile-state-database-schema' +import { hashProfileStateJson } from './profile-state-documents' +import type { AutomationRunsWritePreparation } from './profile-state-automation-runs' +import type { + ProfileStateDomainMutation, + ProfileStateDomainTransaction +} from './profile-state-domain-writes' + +export type PreparedProfileStateMutation = ProfileStateDomainMutation & { + domainVersion: number + payloadHash: string | null + // Keep the checked history value only for this write so normalization does not parse it again. + automationRunsValue?: unknown + automationRuns?: AutomationRunsWritePreparation +} + +export function validateProfileStateDomainTransaction( + transaction: ProfileStateDomainTransaction +): void { + if (!Number.isSafeInteger(transaction.expectedRevision) || transaction.expectedRevision < 0) { + throw new Error('Profile state expected revision is invalid') + } + if ( + !Array.isArray(transaction.replacements) || + (transaction.replacements.length === 0 && transaction.automationRunsAfter === undefined) + ) { + throw new Error('Profile state domain transaction requires at least one replacement') + } + const domains = new Set() + for (const replacement of transaction.replacements) { + validateProfileStateDomainMutation(replacement) + if (domains.has(replacement.domain)) { + throw new Error(`Profile state domain transaction repeats domain: ${replacement.domain}`) + } + domains.add(replacement.domain) + } + if (transaction.automationRunsAfter !== undefined && domains.has('automationRuns')) { + throw new Error('Profile state automationRuns delta repeats domain: automationRuns') + } +} + +export function prepareProfileStateDomainMutation( + replacement: ProfileStateDomainMutation +): PreparedProfileStateMutation { + const payloadHash = + replacement.payload === null ? null : hashProfileStateJson(replacement.payload) + let parsed: unknown + if (replacement.payload !== null) { + try { + parsed = JSON.parse(replacement.payload) + } catch { + throw new Error(`Profile state domain payload is invalid JSON: ${replacement.domain}`) + } + } + return { + domain: replacement.domain, + payload: replacement.payload, + now: replacement.now, + domainVersion: replacement.domainVersion ?? PROFILE_STATE_DOCUMENT_VERSION, + payloadHash, + automationRunsValue: replacement.domain === 'automationRuns' ? parsed : undefined + } +} + +function validateProfileStateDomainMutation(replacement: ProfileStateDomainMutation): void { + if (typeof replacement.domain !== 'string' || replacement.domain.length === 0) { + throw new Error('Profile state domain name cannot be empty') + } + if (replacement.payload !== null && typeof replacement.payload !== 'string') { + throw new Error(`Profile state domain payload is invalid: ${replacement.domain}`) + } + if ( + replacement.domainVersion !== undefined && + (!Number.isSafeInteger(replacement.domainVersion) || replacement.domainVersion < 1) + ) { + throw new Error('Profile state domain version is invalid') + } +} diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.test.ts b/src/main/persistence/profile-state/profile-state-domain-writes.test.ts new file mode 100644 index 00000000000..8c54ee2360f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.test.ts @@ -0,0 +1,585 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + exportProfileStateJson, + importProfileStateJson, + readProfileStateDocuments, + readProfileStateRevision +} from './profile-state-documents' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' +import { + ProfileStateRevisionConflictError, + writeProfileStateDomain, + writeProfileStateDomains +} from './profile-state-domain-writes' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openTestDatabase(): ReturnType['db'] { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-write-')) + temporaryDirectories.push(directory) + return openProfileStateDatabase(profileStateDatabaseFile(directory), 'profile-a').db +} + +describe('profile state dirty-domain writes', () => { + it('retains logical history order when existing runs change position', () => { + const db = openTestDatabase() + const runs = [{ id: 'first' }, { id: 'second' }, { id: 'third' }] as const + const reordered = [runs[2], runs[0], runs[1]] + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: runs })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify(reordered), + expectedRevision: 1 + }) + + expect( + db.prepare('SELECT run_id FROM profile_state_automation_runs ORDER BY rowid').all() + ).toEqual(runs.map((run) => ({ run_id: run.id }))) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual(reordered) + + db.prepare('UPDATE profile_state_automation_runs SET ordinal = 0 WHERE run_id = ?').run( + 'first' + ) + expect(() => exportProfileStateJson(db)).toThrow( + 'Normalized automationRuns ordering is corrupt' + ) + } finally { + db.close() + } + }) + + it('updates automationRuns without rewriting unrelated domains', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }), + { now: () => 100 } + ) + const settingsBefore = readProfileStateDocuments(db).find( + (document) => document.domain === 'settings' + ) + + const result = writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1, + now: () => 200 + }) + + expect(result).toEqual({ changed: true, revision: 2 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'completed' }] + }) + expect(readProfileStateDocuments(db)).toEqual([ + expect.objectContaining({ domain: 'settings', revision: 1, updatedAt: 100 }), + expect.objectContaining({ domain: 'automationRuns', revision: 2, updatedAt: 200 }) + ]) + const settingsAfter = readProfileStateDocuments(db).find( + (document) => document.domain === 'settings' + ) + expect(settingsAfter).toMatchObject({ + payload: settingsBefore?.payload, + contentHash: settingsBefore?.contentHash, + updatedAt: settingsBefore?.updatedAt + }) + } finally { + db.close() + } + }) + + it('commits changed automation runs as rows without rewriting the legacy document blob', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [ + { ...fixtureRun, id: 'run-1', status: 'pending' as const }, + { ...fixtureRun, id: 'run-2', status: 'completed' as const } + ] + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: initialRuns + }), + { now: () => 100 } + ) + const legacyDocument = db + .prepare('SELECT payload, content_hash FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + + const result = writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: [{ ...initialRuns[0], status: 'dispatched' as const }, initialRuns[1]] + }) + + expect(result).toEqual({ + changed: true, + revision: 2, + changedDomains: ['automationRuns'] + }) + expect( + db.prepare('SELECT COUNT(*) AS count FROM profile_state_automation_runs').get() + ).toEqual({ count: 2 }) + expect( + db + .prepare('SELECT payload, content_hash FROM profile_state_documents WHERE domain = ?') + .get('automationRuns') + ).toEqual(legacyDocument) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ ...initialRuns[0], status: 'dispatched' }, initialRuns[1]] + }) + } finally { + db.close() + } + }) + + it('rolls back a direct automation-run delta and revision when normalized metadata rejects it', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [{ ...fixtureRun, id: 'run-1', status: 'pending' as const }] + importProfileStateJson(db, JSON.stringify({ automationRuns: initialRuns }), { + now: () => 100 + }) + const normalizedRuns = [{ ...initialRuns[0], status: 'dispatched' as const }] + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: normalizedRuns + }) + ).toEqual({ changed: true, revision: 2, changedDomains: ['automationRuns'] }) + db.exec( + `CREATE TRIGGER fail_profile_state_automation_run_delta + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected automation delta failure'); END` + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: [{ ...normalizedRuns[0], status: 'completed' as const }] + }) + ).toThrow('injected automation delta failure') + db.exec('DROP TRIGGER fail_profile_state_automation_run_delta') + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual(normalizedRuns) + } finally { + db.close() + } + }) + + it('fails closed when a normalized automation-run row is corrupt', () => { + const db = openTestDatabase() + try { + const fixtureRun = buildProfileStateCutoverFixture().automationRuns[0] + if (!fixtureRun) { + throw new Error('Expected automation run fixture') + } + const runs = [{ ...fixtureRun, id: 'run-1', status: 'pending' as const }] + importProfileStateJson(db, JSON.stringify({ automationRuns: runs })) + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: runs + }) + db.prepare('UPDATE profile_state_automation_runs SET payload = ? WHERE run_id = ?').run( + JSON.stringify({ ...runs[0], status: 'tampered' }), + 'run-1' + ) + + expect(() => exportProfileStateJson(db)).toThrow('Normalized automationRuns row is corrupt') + } finally { + db.close() + } + }) + + it('retains unchanged run row revisions while updating the aggregate projection', () => { + const db = openTestDatabase() + try { + const fixtureRuns = buildProfileStateCutoverFixture().automationRuns + const first = fixtureRuns[0] + if (!first) { + throw new Error('Expected automation run fixture') + } + const initialRuns = [ + { ...first, id: 'run-1', status: 'pending' as const }, + { ...first, id: 'run-2', status: 'dispatched' as const } + ] + importProfileStateJson(db, JSON.stringify({ automationRuns: initialRuns })) + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [], + automationRunsAfter: [{ ...initialRuns[0], status: 'completed' as const }, initialRuns[1]] + }) + writeProfileStateDomains(db, { + expectedRevision: 2, + replacements: [], + automationRunsAfter: [ + { ...initialRuns[0], status: 'dispatch_failed' as const }, + initialRuns[1] + ] + }) + + expect( + db + .prepare('SELECT revision FROM profile_state_automation_runs WHERE run_id = ?') + .get('run-2') + ).toEqual({ revision: 1 }) + expect(readProfileStateRevision(db)).toBe(3) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual([ + { ...initialRuns[0], status: 'dispatch_failed' }, + initialRuns[1] + ]) + } finally { + db.close() + } + }) + + it('commits several changed domains at one shared revision', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }], + ui: { activeView: 'terminal' } + }), + { now: () => 100 } + ) + + expect( + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { + domain: 'settings', + payload: JSON.stringify({ theme: 'light' }), + now: () => 200 + }, + { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + now: () => 201 + } + ] + }) + ).toEqual({ + changed: true, + revision: 2, + changedDomains: ['settings', 'automationRuns'] + }) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'light' }, + automationRuns: [{ id: 'run-1', status: 'completed' }], + ui: { activeView: 'terminal' } + }) + expect(readProfileStateDocuments(db)).toEqual([ + expect.objectContaining({ domain: 'settings', revision: 2, updatedAt: 200 }), + expect.objectContaining({ domain: 'automationRuns', revision: 2, updatedAt: 201 }), + expect.objectContaining({ domain: 'ui', revision: 1, updatedAt: 100 }) + ]) + } finally { + db.close() + } + }) + + it('rolls back every domain when a later mutation fails', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }), + { now: () => 100 } + ) + db.exec( + `CREATE TRIGGER fail_second_profile_state_domain_update + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected domain failure'); END` + ) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'light' }) }, + { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]) + } + ] + }) + ).toThrow('injected domain failure') + db.exec('DROP TRIGGER fail_second_profile_state_domain_update') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'dark' }, + automationRuns: [{ id: 'run-1', status: 'pending' }] + }) + } finally { + db.close() + } + }) + + it('fences a stale multi-domain transaction before changing either row', () => { + const db = openTestDatabase() + try { + importProfileStateJson( + db, + JSON.stringify({ settings: { theme: 'dark' }, ui: { activeView: 'terminal' } }) + ) + writeProfileStateDomain(db, { + domain: 'settings', + payload: JSON.stringify({ theme: 'light' }), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'blue' }) }, + { domain: 'ui', payload: JSON.stringify({ activeView: 'browser' }) } + ] + }) + ).toThrowError(ProfileStateRevisionConflictError) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + settings: { theme: 'light' }, + ui: { activeView: 'terminal' } + }) + } finally { + db.close() + } + }) + + it('rejects duplicate domains before opening a transaction', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) + expect(() => + writeProfileStateDomains(db, { + expectedRevision: 1, + replacements: [ + { domain: 'settings', payload: JSON.stringify({ theme: 'light' }) }, + { domain: 'settings', payload: JSON.stringify({ theme: 'blue' }) } + ] + }) + ).toThrow('repeats domain: settings') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ settings: { theme: 'dark' } }) + } finally { + db.close() + } + }) + + it('fences a stale Store and leaves the database unchanged', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + expect(readProfileStateRevision(db)).toBe(2) + expect(JSON.parse(exportProfileStateJson(db)).automationRuns).toEqual([ + { id: 'run-1', status: 'completed' } + ]) + } finally { + db.close() + } + }) + + it('fences two independently opened database writers with the shared revision', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-domain-writer-fence-')) + temporaryDirectories.push(directory) + const databasePath = profileStateDatabaseFile(directory) + const first = openProfileStateDatabase(databasePath, 'profile-a').db + const second = openProfileStateDatabase(databasePath, 'profile-a').db + try { + importProfileStateJson(first, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect(readProfileStateRevision(second)).toBe(1) + writeProfileStateDomain(first, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + + expect(() => + writeProfileStateDomain(second, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrowError(ProfileStateRevisionConflictError) + expect(readProfileStateRevision(second)).toBe(2) + } finally { + first.close() + second.close() + } + }) + + it('does not advance revision for an identical replacement or absent delete', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1' }]), + expectedRevision: 1 + }) + ).toEqual({ changed: false, revision: 1 }) + expect( + writeProfileStateDomain(db, { + domain: 'missingDomain', + payload: null, + expectedRevision: 1 + }) + ).toEqual({ changed: false, revision: 1 }) + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('distinguishes explicit JSON null from deleting a domain', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: 'null', + expectedRevision: 1 + }) + ).toEqual({ changed: true, revision: 2 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ automationRuns: null }) + + expect( + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: null, + expectedRevision: 2 + }) + ).toEqual({ changed: true, revision: 3 }) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({}) + } finally { + db.close() + } + }) + + it('rolls back the row and revision when SQLite rejects the replacement', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.exec( + `CREATE TRIGGER fail_profile_state_domain_update + BEFORE UPDATE ON profile_state_automation_runs_meta + WHEN NEW.domain = 'automationRuns' + BEGIN SELECT RAISE(ABORT, 'injected domain failure'); END` + ) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'failed' }]), + expectedRevision: 1 + }) + ).toThrow('injected domain failure') + db.exec('DROP TRIGGER fail_profile_state_domain_update') + expect(readProfileStateRevision(db)).toBe(1) + expect(JSON.parse(exportProfileStateJson(db))).toEqual({ + automationRuns: [{ id: 'run-1' }] + }) + } finally { + db.close() + } + }) + + it('rejects malformed payloads before opening a transaction', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: '{invalid', + expectedRevision: 1 + }) + ).toThrow('Profile state domain payload is invalid JSON: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) + + it('fails closed when the target row hash is already corrupt', () => { + const db = openTestDatabase() + try { + importProfileStateJson(db, JSON.stringify({ automationRuns: [{ id: 'run-1' }] })) + db.prepare('UPDATE profile_state_documents SET payload = ? WHERE domain = ?').run( + JSON.stringify([{ id: 'tampered' }]), + 'automationRuns' + ) + + expect(() => + writeProfileStateDomain(db, { + domain: 'automationRuns', + payload: JSON.stringify([{ id: 'run-1', status: 'completed' }]), + expectedRevision: 1 + }) + ).toThrow('Profile state document hash mismatch: automationRuns') + expect(readProfileStateRevision(db)).toBe(1) + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-domain-writes.ts b/src/main/persistence/profile-state/profile-state-domain-writes.ts new file mode 100644 index 00000000000..792487ef3b1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-domain-writes.ts @@ -0,0 +1,231 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import type Database from '../../sqlite/sync-database' +import { readCurrentAutomationRunsState } from './profile-state-automation-runs-storage' +import { + PROFILE_STATE_DOCUMENT_VERSION, + PROFILE_STATE_META_REVISION +} from './profile-state-database-schema' +import { + ProfileStateRevisionConflictError, + readProfileStateRevision +} from './profile-state-documents' +import { + applyProfileStateAutomationRuns, + clearProfileStateAutomationRuns, + prepareProfileStateAutomationRunsDelta, + prepareProfileStateAutomationRunsReplacement +} from './profile-state-automation-runs' +import { isRecord, validateProfileStateDocumentRow } from './profile-state-document-validation' +import { assertProfileStateDocumentRevision } from './profile-state-revision' +import { + prepareProfileStateDomainMutation, + validateProfileStateDomainTransaction, + type PreparedProfileStateMutation +} from './profile-state-domain-write-validation' + +export { ProfileStateRevisionConflictError } from './profile-state-documents' + +/** A storage-form replacement for one top-level profile-state domain. */ +export type ProfileStateDomainReplacement = { + /** Non-empty top-level domain name, for example `automationRuns`. */ + domain: string + /** Canonical JSON payload, or null to remove the domain row. */ + payload: string | null + /** Revision observed by the caller before it built this replacement. */ + expectedRevision: number + domainVersion?: number + now?: () => number +} + +/** One row mutation inside a transaction that may update several domains. */ +export type ProfileStateDomainMutation = Omit + +/** + * A set of domain mutations guarded by one profile revision. + * + * A profile revision is shared by every row, so checking it once at the start + * of the transaction gives callers an atomic cross-domain compare-and-swap. + */ +export type ProfileStateDomainTransaction = { + expectedRevision: number + replacements: readonly ProfileStateDomainMutation[] + /** Changed run projection supplied by Store for selective row updates. */ + automationRunsAfter?: readonly unknown[] +} + +export type ProfileStateDomainWriteResult = { + changed: boolean + revision: number +} + +export type ProfileStateDomainTransactionResult = ProfileStateDomainWriteResult & { + changedDomains: readonly string[] +} + +/** + * Replace one domain without serializing or rewriting the other domains. + * + * The caller supplies the revision it read with the domain. SQLite's + * `BEGIN IMMEDIATE` plus the exact revision check fences stale Store instances + * before the row and profile revision are changed. A null payload deletes the + * row; the JSON literal `null` remains an explicit domain value. + */ +export function writeProfileStateDomain( + db: Database.Database, + replacement: ProfileStateDomainReplacement +): ProfileStateDomainWriteResult { + const result = writeProfileStateDomains(db, { + expectedRevision: replacement.expectedRevision, + replacements: [ + { + domain: replacement.domain, + payload: replacement.payload, + domainVersion: replacement.domainVersion, + now: replacement.now + } + ] + }) + return { changed: result.changed, revision: result.revision } +} + +/** + * Replace one or more domains in one SQLite transaction. + * + * Every changed row receives the same next profile revision. If any row + * validation, payload write, or commit step fails, SQLite rolls back all row + * changes and the profile revision remains unchanged. + */ +export function writeProfileStateDomains( + db: Database.Database, + transaction: ProfileStateDomainTransaction +): ProfileStateDomainTransactionResult { + validateProfileStateDomainTransaction(transaction) + + const prepared = transaction.replacements.map(prepareProfileStateDomainMutation) + + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (actualRevision !== transaction.expectedRevision) { + throw new ProfileStateRevisionConflictError(transaction.expectedRevision, actualRevision) + } + const currentAutomationRuns = readCurrentAutomationRunsState(db, actualRevision) + + const updates: PreparedProfileStateMutation[] = [] + for (const mutation of prepared) { + const existing = db + .prepare( + `SELECT domain, payload, domain_version, revision, updated_at, content_hash + FROM profile_state_documents WHERE domain = ?` + ) + .get(mutation.domain) + const existingRow = + existing === undefined + ? undefined + : validateProfileStateDocumentRow(existing, { + // An identical incoming payload has already passed JSON validation. + validateJson: !(isRecord(existing) && existing.payload === mutation.payload) + }) + if (existingRow) { + assertProfileStateDocumentRevision(existingRow.revision, actualRevision, mutation.domain) + } + if (mutation.domain === 'automationRuns') { + // Canonical history already has this hash; unchanged rows need no new projection. + if ( + currentAutomationRuns.presence === 'array' && + mutation.payload?.startsWith('[') && + currentAutomationRuns.contentHash === mutation.payloadHash + ) { + continue + } + const normalized = prepareProfileStateAutomationRunsReplacement( + db, + mutation, + actualRevision + ) + if (normalized !== undefined) { + if (normalized.changed) { + updates.push({ ...mutation, automationRuns: normalized }) + } + continue + } + } + const unchanged = + (mutation.payload === null && existingRow === undefined) || + (mutation.payload !== null && existingRow?.payload === mutation.payload) + if (!unchanged) { + updates.push(mutation) + } + } + if (transaction.automationRunsAfter !== undefined) { + const delta = prepareProfileStateAutomationRunsDelta( + db, + transaction.automationRunsAfter, + PROFILE_STATE_DOCUMENT_VERSION, + Date.now, + actualRevision + ) + if (delta === undefined) { + throw new Error('Normalized automationRuns delta is unsupported') + } + if (delta.changed) { + updates.push({ + domain: 'automationRuns', + payload: null, + domainVersion: PROFILE_STATE_DOCUMENT_VERSION, + payloadHash: null, + automationRuns: delta + }) + } + } + + if (updates.length === 0) { + return { changed: false, revision: actualRevision, changedDomains: [] } + } + + const nextRevision = actualRevision + 1 + for (const mutation of updates) { + if (mutation.automationRuns) { + applyProfileStateAutomationRuns(db, mutation.automationRuns, nextRevision) + continue + } + if (mutation.domain === 'automationRuns') { + clearProfileStateAutomationRuns(db) + } + if (mutation.payload === null) { + db.prepare('DELETE FROM profile_state_documents WHERE domain = ?').run(mutation.domain) + } else { + const updatedAt = (mutation.now ?? Date.now)() + if (!Number.isSafeInteger(updatedAt) || updatedAt < 0) { + throw new Error(`Profile state domain update timestamp is invalid: ${mutation.domain}`) + } + db.prepare( + `INSERT INTO profile_state_documents + (domain, payload, domain_version, revision, updated_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(domain) DO UPDATE SET + payload = excluded.payload, + domain_version = excluded.domain_version, + revision = excluded.revision, + updated_at = excluded.updated_at, + content_hash = excluded.content_hash` + ).run( + mutation.domain, + mutation.payload, + mutation.domainVersion, + nextRevision, + updatedAt, + mutation.payloadHash + ) + } + } + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_REVISION, String(nextRevision)) + return { + changed: true, + revision: nextRevision, + changedDomains: updates.map(({ domain }) => domain) + } + }) +} diff --git a/src/main/persistence/profile-state/profile-state-export-path.ts b/src/main/persistence/profile-state/profile-state-export-path.ts new file mode 100644 index 00000000000..34332e86fa0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-export-path.ts @@ -0,0 +1,36 @@ +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' + +/** Return the immutable JSON artifact created when a profile first enters SQLite authority. */ +export function profileStateJsonExportPath(dataFile: string, revision: number): string { + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error('Profile state export revision must be a positive safe integer') + } + return `${dataFile}.sqlite-export.${revision}.json` +} + +/** List retained rollback exports newest-first without opening SQLite. */ +export function profileStateJsonExportPaths(dataFile: string): readonly string[] { + const directory = dirname(dataFile) + const prefix = `${basename(dataFile)}.sqlite-export.` + if (!existsSync(directory)) { + return [] + } + return readdirSync(directory) + .flatMap((name) => { + const match = new RegExp(`^${escapeRegExp(prefix)}(\\d+)\\.json$`).exec(name) + if (match === null) { + return [] + } + const revision = Number(match[1]) + return Number.isSafeInteger(revision) && revision > 0 + ? [{ path: join(directory, name), revision }] + : [] + }) + .sort((left, right) => right.revision - left.revision) + .map(({ path }) => path) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts new file mode 100644 index 00000000000..ffdfccc7904 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-fragment-validation.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Store } from '../loading-store/store' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(keepJson = false) { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-fragments-')) + directories.push(directory) + const paths = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'fragment-validation' + } + const source = JSON.stringify({ + settings: { theme: 'dark' }, + futureDomain: null, + automationRuns: [{ id: 'a' }, { id: 'b' }] + }) + if (keepJson) { + writeFileSync(paths.dataFile, source) + } + const { db } = openProfileStateDatabase(paths.databaseFile, paths.profileId) + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + return { paths, db } +} + +describe('independent profile state JSON fragments', () => { + it.each([false, true])( + 'rejects a domain that injects a valid sibling into startup JSON (retained JSON: %s)', + (keepJson) => { + const { paths, db } = fixture(keepJson) + const payload = 'null,"settings":{"theme":"light"}' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'futureDomain') + expect(() => readProfileStateParsedSnapshot(db)).toThrow(/invalid JSON: futureDomain/) + expect(() => validateProfileStateSnapshot(db)).toThrow(/invalid JSON: futureDomain/) + db.close() + + expect(() => { + const result = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }) + result.store.freezeWrites() + }).toThrow() + } + ) + + it('rejects a spliced domain through direct authority and Store reads', () => { + const { paths, db } = fixture() + const payload = 'null,"settings":{"theme":"light"}' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(payload, hashProfileStateJson(payload), 'futureDomain') + db.close() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + try { + expect(() => { + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + store.freezeWrites() + }).toThrow(/invalid JSON: futureDomain/) + expect(() => authority.readSerializedState()).toThrow(/invalid JSON: futureDomain/) + } finally { + authority.close() + } + }) + + it.each(['snapshot', 'parsed', 'validated', 'authority', 'store'] as const)( + 'rejects history rows that form a valid array only when spliced together (%s)', + (boundary) => { + const { paths, db } = fixture() + const payloads = ['{"id":"a","text":"', 'b"},{"id":"b"}'] + const aggregate = `[${payloads.join(',')}]` + expect(JSON.parse(aggregate)).toEqual([{ id: 'a', text: ',b' }, { id: 'b' }]) + for (const [ordinal, payload] of payloads.entries()) { + expect(() => JSON.parse(payload)).toThrow() + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = ?' + ).run(payload, hashProfileStateJson(payload), ordinal) + } + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(aggregate) + ) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + try { + expect(() => { + if (boundary === 'snapshot') { + readProfileStateSnapshot(db) + } else if (boundary === 'parsed') { + readProfileStateParsedSnapshot(db) + } else if (boundary === 'validated') { + validateProfileStateSnapshot(db) + } else if (boundary === 'authority') { + authority.readSerializedState() + } else { + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + store.freezeWrites() + } + }).toThrow('Normalized automationRuns row is invalid JSON') + } finally { + authority.close() + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-json-acceptance.ts b/src/main/persistence/profile-state/profile-state-json-acceptance.ts new file mode 100644 index 00000000000..015a35b2899 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-json-acceptance.ts @@ -0,0 +1,153 @@ +import { withProfileStateWriteTransaction } from './profile-state-write-transaction' +import type Database from '../../sqlite/sync-database' +import { PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE } from './profile-state-database-schema' +import { + hashProfileStatePayload, + isRecord, + parseProfileStateRoot, + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { readProfileStateRevision } from './profile-state-revision' + +type ProfileStateJsonAcceptanceVersion = { + jsonHash: string + acceptedRevision: number +} + +export type ProfileStateJsonAcceptance = ProfileStateJsonAcceptanceVersion & { + pending?: ProfileStateJsonAcceptanceVersion +} + +/** Read the source acceptance marker, if this database has one. */ +export function readProfileStateJsonAcceptance( + db: Database.Database +): ProfileStateJsonAcceptance | undefined { + const row = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE) + if (row === undefined) { + return undefined + } + if (!isRecord(row) || typeof row.value !== 'string') { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + let parsed: unknown + try { + parsed = JSON.parse(row.value) + } catch { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + if (!isJsonAcceptanceVersion(parsed)) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + let pending: ProfileStateJsonAcceptanceVersion | undefined + if ('pending' in parsed) { + if ( + !isJsonAcceptanceVersion(parsed.pending) || + parsed.pending.acceptedRevision < parsed.acceptedRevision + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + pending = parsed.pending + } + return { + jsonHash: parsed.jsonHash, + acceptedRevision: parsed.acceptedRevision, + ...(pending === undefined ? {} : { pending }) + } +} + +function isJsonAcceptanceVersion(value: unknown): value is ProfileStateJsonAcceptanceVersion { + return ( + isRecord(value) && + typeof value.jsonHash === 'string' && + /^[a-f0-9]{64}$/.test(value.jsonHash) && + typeof value.acceptedRevision === 'number' && + Number.isSafeInteger(value.acceptedRevision) && + value.acceptedRevision >= 1 + ) +} + +/** Accept either side of the upcoming JSON replacement before publishing it. */ +export function stageProfileStateJsonCompatibility( + db: Database.Database, + rawJson: string, + expectedRevision: number, + retainedJson?: string +): void { + const retainedHash = + retainedJson === undefined ? undefined : hashProfileStatePayload(retainedJson) + updateProfileStateJsonAcceptance(db, rawJson, expectedRevision, (previous, next) => { + if (previous === undefined) { + return next + } + const retained = + retainedHash === undefined || retainedHash === previous.jsonHash + ? previous + : previous.pending?.jsonHash === retainedHash + ? previous.pending + : undefined + if (retained === undefined) { + throw new ProfileStateDocumentCorruptionError('Compatibility JSON changed before export') + } + return { + jsonHash: retained.jsonHash, + acceptedRevision: retained.acceptedRevision, + pending: next + } + }) +} + +/** Promote the staged JSON after publication; failures leave both versions accepted. */ +export function acceptProfileStateJsonCompatibility( + db: Database.Database, + rawJson: string, + expectedRevision: number +): void { + updateProfileStateJsonAcceptance(db, rawJson, expectedRevision, (previous, next) => { + const staged = previous?.pending ?? previous + if (staged?.jsonHash !== next.jsonHash || staged.acceptedRevision !== next.acceptedRevision) { + throw new ProfileStateDocumentCorruptionError('Compatibility JSON export was not staged') + } + return next + }) +} + +function updateProfileStateJsonAcceptance( + db: Database.Database, + rawJson: string, + expectedRevision: number, + update: ( + previous: ProfileStateJsonAcceptance | undefined, + next: ProfileStateJsonAcceptanceVersion + ) => ProfileStateJsonAcceptance +): void { + parseProfileStateRoot(rawJson) + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) { + throw new ProfileStateDocumentCorruptionError( + 'Compatibility JSON acceptance revision is invalid' + ) + } + return withProfileStateWriteTransaction(db, () => { + const actualRevision = readProfileStateRevision(db) + if (actualRevision !== expectedRevision) { + throw new ProfileStateRevisionConflictError(expectedRevision, actualRevision) + } + const previous = readProfileStateJsonAcceptance(db) + if ( + previous && + (previous.pending?.acceptedRevision ?? previous.acceptedRevision) > actualRevision + ) { + throw new ProfileStateDocumentCorruptionError('Legacy JSON acceptance marker is invalid') + } + const marker = update(previous, { + jsonHash: hashProfileStatePayload(rawJson), + acceptedRevision: expectedRevision + }) + db.prepare( + `INSERT INTO profile_state_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(PROFILE_STATE_META_LEGACY_JSON_ACCEPTANCE, JSON.stringify(marker)) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts new file mode 100644 index 00000000000..1d1502009a8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-json-compatibility-recovery.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as durableFiles from '../../durable-file-write' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const fixtures: { directory: string; authority: ProfileStateSqliteAuthority }[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const { authority, directory } of fixtures.splice(0)) { + authority.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-compatibility-recovery-')) + const paths = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'compatibility-recovery' + } + const retainedJson = '{"settings":{"theme":"light"}}' + writeFileSync(paths.dataFile, retainedJson) + const withDatabase = ( + run: (db: ReturnType['db']) => T + ): T => { + const opened = openProfileStateDatabase(paths.databaseFile, paths.profileId) + try { + return run(opened.db) + } finally { + opened.db.close() + } + } + withDatabase((db) => + importProfileStateJson(db, retainedJson, { + acceptedLegacyJsonHash: hashProfileStateJson(retainedJson) + }) + ) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authority.readSerializedState() + authority.writeSerializedState(Buffer.from('{"settings":{"theme":"dark"}}')) + fixtures.push({ directory, authority }) + return { + paths, + authority, + retainedJson, + withDatabase, + acceptance: () => withDatabase(readProfileStateJsonAcceptance), + publish: async (mode: 'sync' | 'async') => + mode === 'sync' + ? authority.writeJsonCompatibilityExport(paths.dataFile) + : authority.writeJsonCompatibilityExportAsync(paths.dataFile), + reopen: () => { + const result = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }) + try { + expect(result.backend).toBe('sqlite') + return result.store.getSettings().theme + } finally { + result.store.freezeWrites() + } + } + } +} + +describe.each(['sync', 'async'] as const)('%s compatibility export recovery', (mode) => { + it.each(['staging', 'publication', 'promotion'] as const)( + 'reopens SQLite and permits a later export after failed %s', + async (phase) => { + const state = fixture() + if (phase === 'publication') { + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + if (args[1] === state.paths.dataFile) { + throw new Error('injected publication failure') + } + write(...args) + }) + } else { + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce( + new Error('injected publication failure') + ) + } + } else { + state.withDatabase((db) => + db.exec( + `CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + ${phase === 'promotion' ? "AND json_type(NEW.value, '$.pending') IS NULL" : ''} + BEGIN SELECT RAISE(ABORT, 'injected acceptance failure'); END` + ) + ) + } + + await expect(state.publish(mode)).rejects.toThrow('injected') + + const published = readFileSync(state.paths.dataFile, 'utf8') + expect(JSON.parse(published).settings.theme).toBe(phase === 'promotion' ? 'dark' : 'light') + expect(state.reopen()).toBe('dark') + if (phase !== 'staging') { + expect(state.acceptance()?.pending).toEqual({ + jsonHash: hashProfileStateJson('{"settings":{"theme":"dark"}}'), + acceptedRevision: 2 + }) + } + vi.restoreAllMocks() + state.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) + state.authority.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + + await state.publish(mode) + + expect(state.reopen()).toBe('system') + expect(state.acceptance()).toEqual({ + jsonHash: hashProfileStateJson(readFileSync(state.paths.dataFile, 'utf8')), + acceptedRevision: 3 + }) + } + ) + + it('keeps the published JSON accepted when a concurrent commit prevents promotion', async () => { + const state = fixture() + const compete = () => { + const other = new ProfileStateSqliteAuthority(state.paths.databaseFile, state.paths.profileId) + try { + other.readSerializedState() + other.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + } finally { + other.close() + } + } + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + write(...args) + if (args[1] === state.paths.dataFile) { + compete() + } + }) + } else { + const write = durableFiles.writeFileDurable + vi.spyOn(durableFiles, 'writeFileDurable').mockImplementationOnce(async (...args) => { + await write(...args) + compete() + }) + } + + await expect(state.publish(mode)).rejects.toMatchObject({ + code: 'profile-state-revision-conflict' + }) + + expect(JSON.parse(readFileSync(state.paths.dataFile, 'utf8')).settings.theme).toBe('dark') + expect(state.reopen()).toBe('system') + expect(state.acceptance()?.pending?.acceptedRevision).toBe(2) + }) + + it('refuses an unrelated edit even while a previous export remains staged', async () => { + const state = fixture() + if (mode === 'sync') { + const write = durableFiles.writeFileDurableSync + vi.spyOn(durableFiles, 'writeFileDurableSync').mockImplementation((...args) => { + if (args[1] === state.paths.dataFile) { + throw new Error('injected publication failure') + } + write(...args) + }) + } else { + vi.spyOn(durableFiles, 'writeFileDurable').mockRejectedValueOnce( + new Error('injected publication failure') + ) + } + await expect(state.publish(mode)).rejects.toThrow('injected') + expect(state.acceptance()?.pending).toEqual({ + jsonHash: hashProfileStateJson('{"settings":{"theme":"dark"}}'), + acceptedRevision: 2 + }) + const unrelatedJson = '{"settings":{"theme":"system"},"unrelatedEdit":true}' + writeFileSync(state.paths.dataFile, unrelatedJson) + + expect(state.reopen).toThrow('without a matching acceptance marker') + await expect(state.publish(mode)).rejects.toThrow('Compatibility JSON changed before export') + expect(readFileSync(state.paths.dataFile, 'utf8')).toBe(unrelatedJson) + }) +}) + +it.each([ + null, + [], + { jsonHash: 'invalid', acceptedRevision: 2 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 0 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 1.5 }, + { jsonHash: 'a'.repeat(64), acceptedRevision: 3 } +])('refuses malformed or impossible pending acceptance %#', (pending) => { + const state = fixture() + state.withDatabase((db) => + db.prepare('UPDATE profile_state_meta SET value = ? WHERE key = ?').run( + JSON.stringify({ + jsonHash: hashProfileStateJson(state.retainedJson), + acceptedRevision: 1, + pending + }), + 'legacy_json_acceptance' + ) + ) + expect(state.reopen).toThrow() + expect(() => state.authority.writeJsonCompatibilityExport(state.paths.dataFile)).toThrow() + expect(readFileSync(state.paths.dataFile, 'utf8')).toBe(state.retainedJson) +}) diff --git a/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts new file mode 100644 index 00000000000..a1b8216c49f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-large-recovery-crash.test.ts @@ -0,0 +1,149 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from './profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { buildRecoveryCrashProcess, killRecoveryAt } from './profile-state-recovery-crash-process' + +const suite = mkdtempSync(join(tmpdir(), 'orca-large-recovery-crash-')) +const roots: string[] = [] +const profileId = 'large-recovery' +const oldJson = JSON.stringify({ opaque: 'x'.repeat(8 * 1024 * 1024), revision: 'old' }) +const selectedJson = JSON.stringify({ opaque: 'y'.repeat(8 * 1024 * 1024), revision: 'selected' }) +let bundle: string +beforeAll(() => { + bundle = buildRecoveryCrashProcess(suite) +}) +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(suite, { recursive: true, force: true })) + +async function fixture() { + const root = mkdtempSync(join(suite, 'profile-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + const databasePath = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const db = openProfileStateDatabase(databasePath, profileId).db + try { + importProfileStateJson(db, oldJson) + importProfileStateJson(db, oldJson) + importProfileStateJson(db, selectedJson) + await writeProfileStateDatabaseSnapshotAsync(db, backupPath) + } finally { + db.close() + } + const options = { + root, + directory, + profileId, + databasePath, + dataFile, + backupPath, + exportPath: profileStateJsonExportPath(dataFile, 3), + markerPath: join(root, 'http1-compatibility.json'), + kind: 'sqlite' as const + } + await killRecoveryAt(bundle, options, 'seed', oldJson) + expect(existsSync(`${databasePath}-wal`)).toBe(true) + const originalFamily = new Map( + ['', '-wal', '-shm'].map((suffix) => [suffix, readFileSync(databasePath + suffix)]) + ) + return { ...options, originalFamily, backupBytes: readFileSync(backupPath) } +} + +function snapshot(path: string) { + const db = openProfileStateDatabaseReadOnly(path, profileId).db + try { + const { json, revision } = readProfileStateSnapshot(db) + return { json, revision } + } finally { + db.close() + } +} + +const boundaries = [ + ...(process.platform === 'darwin' + ? [ + 'clone:1:before', + 'clone:1:after', + 'clone:2:before', + 'clone:2:after', + 'clone:3:after', + 'clone:4:after' + ] + : []), + 'primary', + 'sqlite-publish:before', + 'sqlite-publish:after' +] + +describe('large independent recovery copies under process death', () => { + it.each(boundaries)( + 'preserves exact backup and retry state after %s', + async (boundary) => { + const profile = await fixture() + const stage = boundary === 'primary' ? `removed:${profile.databasePath}` : boundary + await killRecoveryAt(bundle, profile, stage) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) + if (boundary.startsWith('clone:')) { + for (const [suffix, bytes] of profile.originalFamily) { + expect(readFileSync(profile.databasePath + suffix).equals(bytes)).toBe(true) + } + } else { + const directory = readdirSync(profile.directory).find((name) => + name.startsWith('profile-state-corrupt-') + ) + if (directory === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + for (const [suffix, bytes] of profile.originalFamily) { + expect( + readFileSync(join(profile.directory, directory, `profile-state.db${suffix}`)).equals( + bytes + ) + ).toBe(true) + } + expect( + readFileSync(join(profile.directory, directory, basename(profile.backupPath))).equals( + profile.backupBytes + ) + ).toBe(true) + if (boundary === 'sqlite-publish:after') { + expect(snapshot(profile.databasePath)).toEqual({ json: selectedJson, revision: 3 }) + } else { + expect(existsSync(profile.databasePath)).toBe(false) + } + } + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + restoreProfileStateDatabaseBackup({ ...profile, maintenance }) + } finally { + maintenance.release() + } + expect(snapshot(profile.databasePath)).toEqual({ json: selectedJson, revision: 3 }) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) + }, + 30_000 + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts b/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts new file mode 100644 index 00000000000..ba222b61fd7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-legacy-backup-path.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs' + +export const PROFILE_STATE_LEGACY_BACKUP_COUNT = 5 + +export function profileStateLegacyBackupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { + if (existsSync(profileStateLegacyBackupPath(dataFile, index))) { + return true + } + } + return false +} diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts new file mode 100644 index 00000000000..56c2cd2b072 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.test.ts @@ -0,0 +1,216 @@ +import { build } from 'esbuild' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import type { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let bundleRoot: string +let workerOptions: { workerPath: string; backupWorkerPath: string } +const roots: string[] = [] +const stores: Store[] = [] + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-live-writer-bundle-')) + workerOptions = { + workerPath: join(bundleRoot, 'profile-state-writer-worker-entry.js'), + backupWorkerPath: join(bundleRoot, 'profile-state-backup-worker-entry.js') + } + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundleRoot, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +function options() { + const root = mkdtempSync(join(tmpdir(), 'orca-live-profile-')) + roots.push(root) + return { + dataFile: join(root, 'orca-data.json'), + databaseFile: join(root, 'profile-state.db'), + profileId: 'live-profile-test', + authorityMode: 'sqlite-candidate' as const + } +} + +async function open(input: ReturnType) { + const result = await createLiveProfileStateStore(input, workerOptions) + stores.push(result.store) + return result +} + +function readState(input: ReturnType) { + const reader = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + try { + return JSON.parse(reader.readSerializedState() ?? '{}') + } finally { + reader.close() + } +} + +describe('live profile authority admission', () => { + it.each([false, true])( + 'hands unbound aliases to admitted startup only (worker refused=%s)', + async (refused) => { + const input = options() + const source = buildProfileStateCutoverFixture(join(input.dataFile, '..')) + const session = source.workspaceSession + for (const tab of Object.values(session.tabsByWorktree).flat()) { + tab.ptyId = null + } + session.terminalLayoutsByTabId['tab-local'] = { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: {} + } + writeFileSync(input.dataFile, JSON.stringify(source)) + const register = vi.spyOn(agentHookServer, 'registerPaneKeyAlias') + if (refused) { + await expect( + createLiveProfileStateStore(input, { + workerPath: join(input.dataFile, '..', 'missing-worker.js') + }) + ).rejects.toThrow() + expect(register).not.toHaveBeenCalled() + return + } + const { store } = await open(input) + const leafId = store.getWorkspaceSession().terminalLayoutsByTabId['tab-local'].activeLeafId + const userDataPath = join(input.dataFile, '..') + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + join(userDataPath, 'agent-hooks', 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab-local:1': { + paneKey: 'tab-local:1', + tabId: 'tab-local', + worktreeId: 'repo-local::/fixture/local', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }) + ) + try { + await agentHookServer.start({ env: 'production', userDataPath }) + expect(agentHookServer.getStatusSnapshot()).toContainEqual( + expect.objectContaining({ + paneKey: `tab-local:${leafId}`, + prompt: 'legacy cached' + }) + ) + } finally { + agentHookServer.stop() + } + } + ) + + it('migrates once, loads admitted state and reopens worker-acknowledged writes', async () => { + const input = options() + writeFileSync( + input.dataFile, + JSON.stringify(buildProfileStateCutoverFixture(join(input.dataFile, '..'))) + ) + const { store, migrated, backend } = await open(input) + expect({ migrated, backend }).toEqual({ migrated: true, backend: 'sqlite' }) + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + const reopened = await open(input) + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().theme).toBe('dark') + expect(readState(input).automationRuns).toHaveLength(1) + }) + + it('never adopts a competing revision between bootstrap and worker readiness', async () => { + const input = options() + const original = ProfileStateSqliteAuthority.prototype.retireForWorker + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'retireForWorker').mockImplementation( + function (this: ProfileStateSqliteAuthority) { + const handoff = original.call(this) + const peer = new ProfileStateSqliteAuthority(input.databaseFile, input.profileId) + try { + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'peer', payload: '{"retained":true}' }]) + } finally { + peer.close() + } + return handoff + } + ) + await expect(open(input)).rejects.toThrow('Profile state revision changed') + expect(readState(input).peer).toEqual({ retained: true }) + }) + + it('refuses startup when the worker is unavailable without selecting JSON', async () => { + const input = options() + await expect( + createLiveProfileStateStore(input, { workerPath: join(bundleRoot, 'missing.js') }) + ).rejects.toThrow('Profile state writer') + expect(() => readFileSync(input.dataFile)).toThrow() + const reopened = await open(input) + expect(reopened.backend).toBe('sqlite') + }) + + it('orders exports with full checkpoints and closes backup and writer handles on final flush', async () => { + const input = options() + const { store } = await open(input) + store.getWorkspaceSession().activeTabId = 'getter-export' + store.updateSettings({ theme: 'dark' }) + const revision = await store.writeLatestProfileStateJsonExportAsync() + expect(revision).toBeTypeOf('number') + if (revision === undefined) { + throw new Error('Expected a persisted profile revision') + } + expect( + JSON.parse(readFileSync(profileStateJsonExportPath(input.dataFile, revision), 'utf8')) + .workspaceSession.activeTabId + ).toBe('getter-export') + store.updateSettings({ theme: 'light' }) + await store.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + expect(JSON.parse(readFileSync(input.dataFile, 'utf8')).settings.theme).toBe('light') + expect(profileStateJsonExportPaths(input.dataFile).length).toBeGreaterThan(0) + expect(readState(input).settings.theme).toBe('light') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + const reopened = await open(input) + expect(reopened.store.getSettings().theme).toBe('light') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-live-store-factory.ts b/src/main/persistence/profile-state/profile-state-live-store-factory.ts new file mode 100644 index 00000000000..82bf99bf063 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-live-store-factory.ts @@ -0,0 +1,58 @@ +import { Store } from '../loading-store/store' +import { + prepareProfileStateStore, + type ProfileStateStoreFactoryOptions, + type ProfileStateStoreFactoryResult +} from './profile-state-store-factory' +import { ProfileStateWorkerAuthority } from './profile-state-worker-authority' + +/** Publish live state only after its exact bootstrap revision has a worker owner. */ +export async function createLiveProfileStateStore( + options: ProfileStateStoreFactoryOptions, + workerOptions: { + workerPath?: string + backupWorkerPath?: string + onFailure?: (error: Error) => void + } = {} +): Promise { + const { initialState: initial, ...prepared } = prepareProfileStateStore(options) + if (!initial) { + return { + ...prepared, + store: new Store({ dataFile: options.dataFile, storageAuthority: options.storageAuthority }) + } + } + + // Consume before retirement: the bootstrap snapshot carries the original revision fence. + const parsed = initial.takeParsedState?.() + const serializedState = initial.serializedState + const authority = new ProfileStateWorkerAuthority( + initial.authority.retireForWorker(), + workerOptions + ) + try { + await authority.ready + const initialAuthorityState = initial.takeParsedState + ? { authority, takeParsedState: () => parsed } + : { authority, serializedState } + return { + ...prepared, + store: new Store({ + dataFile: options.dataFile, + storageAuthority: options.storageAuthority, + profileStateAuthority: authority, + initialAuthorityState: { + ...initialAuthorityState, + unboundPaneAliases: initial.unboundPaneAliases + } + }) + } + } catch (error) { + try { + await authority.close() + } catch (closeError) { + console.error('[persistence] Failed to close a refused profile writer:', closeError) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-migration.ts b/src/main/persistence/profile-state/profile-state-migration.ts new file mode 100644 index 00000000000..35b4a892364 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-migration.ts @@ -0,0 +1,86 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { publishProfileStateDatabase } from './profile-state-database-publication' +import { openProfileStateDatabase } from './profile-state-database' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { assertProfileStateCanInitialize } from './profile-state-recovery-required' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { + classifyProfileStateStorage, + profileStateDatabaseFiles +} from './profile-state-storage-classification' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' + +type ProfileStateMigrationOptions = { + dataFile: string + databaseFile: string + profileId: string + expectedLegacyJson: string | undefined + /** Storage-form state; inactive profiles retain sealed secrets without decrypting them. */ + serializedState: string +} + +/** Publish an imported database only after its complete source has committed durably. */ +export function migrateProfileStateToSqlite(options: ProfileStateMigrationOptions): { + authority: ProfileStateSqliteAuthority + initialState: ProfileStateAuthorityInitialState +} { + assertMigrationSourceUnchanged(options) + mkdirSync(dirname(options.databaseFile), { recursive: true }) + const temporaryDatabaseFile = `${options.databaseFile}.migration.${process.pid}.${randomUUID()}.tmp` + let published = false + try { + const opened = openProfileStateDatabase(temporaryDatabaseFile, options.profileId) + try { + importProfileStateJson( + opened.db, + options.serializedState, + options.expectedLegacyJson === undefined + ? {} + : { acceptedLegacyJsonHash: hashProfileStateJson(options.expectedLegacyJson) } + ) + } finally { + opened.db.close() + } + + // Closing checkpoints the temporary database before its canonical path becomes visible. + assertMigrationSourceUnchanged(options) + if (!publishProfileStateDatabase(temporaryDatabaseFile, options.databaseFile)) { + throw new Error('Profile state storage changed while importing legacy JSON') + } + published = true + const authority = new ProfileStateSqliteAuthority(options.databaseFile, options.profileId) + try { + writeVersionedProfileStateExport(options.dataFile, (path) => authority.writeJsonExport(path)) + const initialState = authority.readInitialState() + return { authority, initialState } + } catch (error) { + authority.close() + throw error + } + } finally { + if (!published) { + for (const path of profileStateDatabaseFiles(temporaryDatabaseFile)) { + rmSync(path, { force: true }) + } + } + } +} + +function assertMigrationSourceUnchanged(options: ProfileStateMigrationOptions): void { + assertProfileStateCanInitialize(options) + const expectedClassification = options.expectedLegacyJson === undefined ? 'neither' : 'json-only' + if ( + classifyProfileStateStorage(options.dataFile, options.databaseFile) !== expectedClassification + ) { + throw new Error('Profile state storage changed while importing legacy JSON') + } + const currentJson = existsSync(options.dataFile) + ? readFileSync(options.dataFile, 'utf8') + : undefined + if (currentJson !== options.expectedLegacyJson) { + throw new Error('Profile state JSON changed while importing legacy JSON') + } +} diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.test.ts b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts new file mode 100644 index 00000000000..45f81ccdb9f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.test.ts @@ -0,0 +1,135 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + readAgentHookSettingsFromProfileState, + updateAgentHookSettingsFromProfileState +} from './profile-state-offline-settings' +import * as exportPaths from './profile-state-export-path' +import { ProfileStateRecoveryRequiredError } from './profile-state-recovery-required' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +const directories: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createLocation() { + const directory = mkdtempSync(join(tmpdir(), 'orca-offline-settings-')) + directories.push(directory) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'orca-state.db'), + profileId: 'profile-offline' + } +} + +describe.each(['json', 'sqlite'] as const)('offline settings %s updates', (backend) => { + it('filters malformed hook settings for callers while preserving their stored values', () => { + const location = createLocation() + const original = { + settings: { + agentStatusHooksEnabled: true, + agentCmdOverrides: { codex: 42, claude: 'claude --model opus', gemini: null }, + disabledTuiAgents: ['codex', false, 'future-agent', 'codex'], + futureSetting: { preserved: true } + }, + futureDomain: { preserved: ['雪', null] } + } + const authority = new ProfileStateSqliteAuthority(location.databaseFile, location.profileId) + try { + if (backend === 'sqlite') { + authority.writeSerializedState(Buffer.from(JSON.stringify(original))) + authority.close() + } else { + writeFileSync(location.dataFile, JSON.stringify(original)) + } + + expect(updateAgentHookSettingsFromProfileState(location, false)).toEqual({ + settingsPath: backend === 'sqlite' ? location.databaseFile : location.dataFile, + settings: { + agentCmdOverrides: { claude: 'claude --model opus' }, + disabledTuiAgents: ['codex', 'future-agent', 'codex'] + } + }) + const persisted = + backend === 'sqlite' + ? authority.readSerializedState() + : readFileSync(location.dataFile, 'utf8') + expect(JSON.parse(persisted ?? 'null')).toMatchObject({ + ...original, + settings: { ...original.settings, agentStatusHooksEnabled: false } + }) + } finally { + authority.close() + } + }) +}) + +describe.each(['read', 'update'] as const)('offline settings %s recovery', (operation) => { + function run(location: ReturnType) { + return operation === 'read' + ? readAgentHookSettingsFromProfileState(location) + : updateAgentHookSettingsFromProfileState(location, false) + } + + it.each([true, false])('rejects retained exports with JSON present=%s', (hasJson) => { + const location = createLocation() + const source = JSON.stringify({ settings: { agentStatusHooksEnabled: true } }) + if (hasJson) { + writeFileSync(location.dataFile, source) + } + const exportFile = exportPaths.profileStateJsonExportPath(location.dataFile, 1) + writeFileSync(exportFile, source) + // Recovery detection must also work in the Node 18 fallback without SQLite. + vi.spyOn(process, 'getBuiltinModule').mockReturnValue(undefined) + + expect(() => run(location)).toThrowError(ProfileStateRecoveryRequiredError) + expect(existsSync(location.databaseFile)).toBe(false) + expect(existsSync(location.dataFile)).toBe(hasJson) + expect(readFileSync(exportFile, 'utf8')).toBe(source) + if (hasJson) { + expect(readFileSync(location.dataFile, 'utf8')).toBe(source) + } + }) + + it.each([0, 1, 2, 3, 4])('refuses defaults when only legacy backup %s remains', (slot) => { + const location = createLocation() + const backup = `${location.dataFile}.bak.${slot}` + const source = '{"settings":{"agentStatusHooksEnabled":false}}' + writeFileSync(backup, source) + + expect(() => run(location)).toThrow('restore a selected backup') + expect(existsSync(location.dataFile)).toBe(false) + expect(existsSync(location.databaseFile)).toBe(false) + expect(readFileSync(backup, 'utf8')).toBe(source) + }) + + it('fails closed when retained exports cannot be enumerated', () => { + const location = createLocation() + vi.spyOn(exportPaths, 'profileStateJsonExportPaths').mockImplementation(() => { + throw new Error('permission denied') + }) + + expect(() => run(location)).toThrowError(ProfileStateRecoveryRequiredError) + expect(existsSync(location.dataFile)).toBe(false) + expect(existsSync(location.databaseFile)).toBe(false) + }) + + it.each(['-wal', '-shm', '-journal'])('rejects stale JSON when only %s remains', (suffix) => { + const location = createLocation() + const source = JSON.stringify({ settings: { agentStatusHooksEnabled: true } }) + writeFileSync(location.dataFile, source) + const sidecar = `${location.databaseFile}${suffix}` + writeFileSync(sidecar, 'orphaned recovery evidence') + + expect(() => run(location)).toThrow() + expect(readFileSync(location.dataFile, 'utf8')).toBe(source) + expect(existsSync(location.databaseFile)).toBe(false) + expect(readFileSync(sidecar, 'utf8')).toBe('orphaned recovery evidence') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-offline-settings.ts b/src/main/persistence/profile-state/profile-state-offline-settings.ts new file mode 100644 index 00000000000..f6eecefd522 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-offline-settings.ts @@ -0,0 +1,198 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname } from 'node:path' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { profileStateJsonMatchesAcceptance } from './profile-state-documents' +import { isRecord, parseProfileStateRoot } from './profile-state-document-validation' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + readProfileStateDomains, + readProfileStateDomainsWithRevisionFromDatabase +} from './profile-state-domain-reader' +import { writeProfileStateDomain } from './profile-state-domain-writes' +import { assertProfileStateCanInitialize } from './profile-state-recovery-required' +import { classifyProfileStateStorage } from './profile-state-storage-classification' + +export type ProfileStateOfflineLocation = { + dataFile: string + databaseFile: string + profileId: string +} + +export type AgentHookSettings = Pick< + GlobalSettings, + 'agentStatusHooksEnabled' | 'disabledTuiAgents' +> + +export type AgentHookSettingsUpdate = { + settings: Pick + settingsPath: string +} + +/** Read the settings domain without creating SQLite for a JSON-only profile. */ +export function readAgentHookSettingsFromProfileState( + location: ProfileStateOfflineLocation +): AgentHookSettings { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + assertProfileStateCanInitialize(location) + return readAgentHookSettingsFromJson(location.dataFile) + } + + assertSqliteCapability() + assertAcceptedLegacyJson(location, classification) + const result = readProfileStateDomains(location.databaseFile, location.profileId, ['settings']) + if (result.kind === 'unreadable') { + throw result.error + } + return readAgentHookSettingsFromSettingsValue(result.values.get('settings')) +} + +/** + * Update only the settings row in an existing SQLite authority. + * + * The snapshot revision is checked again by BEGIN IMMEDIATE, so a runtime + * writer between read and update produces a conflict instead of clobbering it. + */ +export function updateAgentHookSettingsInProfileState( + location: ProfileStateOfflineLocation, + enabled: boolean +): AgentHookSettingsUpdate { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + throw new Error('SQLite profile state is not established for this profile') + } + + assertSqliteCapability() + assertAcceptedLegacyJson(location, classification) + const opened = openProfileStateDatabase(location.databaseFile, location.profileId) + try { + const domains = readProfileStateDomainsWithRevisionFromDatabase(opened.db, ['settings']) + if (domains.kind === 'unreadable') { + throw domains.error + } + const persistedSettings = domains.values.get('settings') + const settings = { + ...getDefaultPersistedState(homedir()).settings, + ...(isRecord(persistedSettings) ? persistedSettings : {}), + agentStatusHooksEnabled: enabled + } + writeProfileStateDomain(opened.db, { + domain: 'settings', + payload: JSON.stringify(settings), + expectedRevision: domains.revision + }) + return { + settingsPath: location.databaseFile, + settings: projectAgentHookSettings(settings) + } + } finally { + opened.db.close() + } +} + +/** Update the active profile through its classified backend without exposing that choice to CLI callers. */ +export function updateAgentHookSettingsFromProfileState( + location: ProfileStateOfflineLocation, + enabled: boolean +): AgentHookSettingsUpdate { + const classification = classifyProfileStateStorage(location.dataFile, location.databaseFile) + if (classification === 'sqlite-only' || classification === 'both') { + return updateAgentHookSettingsInProfileState(location, enabled) + } + + assertProfileStateCanInitialize(location) + const state = existsSync(location.dataFile) + ? parseProfileStateRoot(readFileSync(location.dataFile, 'utf8')) + : structuredClone(getDefaultPersistedState(homedir())) + const persistedSettings = isRecord(state.settings) ? state.settings : {} + const settings = { + ...getDefaultPersistedState(homedir()).settings, + ...persistedSettings, + agentStatusHooksEnabled: enabled + } + state.settings = settings + writeJsonProfileState(location.dataFile, state) + return { + settingsPath: location.dataFile, + settings: projectAgentHookSettings(settings) + } +} + +function projectAgentHookSettings( + settings: AgentHookSettingsUpdate['settings'] +): AgentHookSettingsUpdate['settings'] { + return { + agentCmdOverrides: isRecord(settings.agentCmdOverrides) + ? Object.fromEntries( + Object.entries(settings.agentCmdOverrides).filter( + ([, value]) => typeof value === 'string' + ) + ) + : {}, + disabledTuiAgents: Array.isArray(settings.disabledTuiAgents) + ? settings.disabledTuiAgents.filter((value) => typeof value === 'string') + : [] + } +} + +function assertSqliteCapability(): void { + if (!isProfileStateSqliteAvailable()) { + throw new Error('SQLite profile state is present but this runtime cannot validate it') + } +} + +function assertAcceptedLegacyJson( + location: ProfileStateOfflineLocation, + classification: 'sqlite-only' | 'both' +): void { + if (classification === 'sqlite-only') { + if (!existsSync(location.databaseFile)) { + throw new Error('SQLite profile state has an orphaned database sidecar') + } + return + } + + const rawJson = readFileSync(location.dataFile, 'utf8') + const opened = openProfileStateDatabaseReadOnly(location.databaseFile, location.profileId) + try { + if (!profileStateJsonMatchesAcceptance(opened.db, rawJson)) { + throw new Error( + 'Profile state has both JSON and SQLite storage without a matching acceptance marker' + ) + } + } finally { + opened.db.close() + } +} + +function readAgentHookSettingsFromJson(dataFile: string): AgentHookSettings { + const settings = existsSync(dataFile) + ? parseProfileStateRoot(readFileSync(dataFile, 'utf8')).settings + : getDefaultPersistedState(homedir()).settings + return readAgentHookSettingsFromSettingsValue(settings) +} + +function readAgentHookSettingsFromSettingsValue(value: unknown): AgentHookSettings { + const settings = isRecord(value) ? value : {} + return { + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, + disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents) + } +} + +function writeJsonProfileState(dataFile: string, state: Record): void { + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileDurableSync( + durableWriteTempPath(dataFile), + dataFile, + `${JSON.stringify(state, null, 2)}\n` + ) +} diff --git a/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts new file mode 100644 index 00000000000..428622f8c6d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-parsed-snapshot.test.ts @@ -0,0 +1,158 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateDocuments, + readProfileStateParsedSnapshot, + readProfileStateSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-parsed-profile-')) + directories.push(directory) + return openProfileStateDatabase(join(directory, 'profile-state.db'), 'parsed-profile').db +} + +describe('checked profile state values', () => { + it.each([ + [ + 'row hash', + "UPDATE profile_state_automation_runs SET payload = '{}' WHERE ordinal = 0", + 'row is corrupt' + ], + [ + 'ordering', + 'UPDATE profile_state_automation_runs SET ordinal = 3 WHERE ordinal = 0', + 'ordering is corrupt' + ], + [ + 'revision', + 'UPDATE profile_state_automation_runs SET revision = 99 WHERE ordinal = 0', + 'metadata is inconsistent' + ], + [ + 'timestamp', + 'UPDATE profile_state_automation_runs SET updated_at = updated_at + 1 WHERE ordinal = 0', + 'metadata is inconsistent' + ] + ])('rejects corrupt normalized %s in both representations', (_, sql, message) => { + const db = fixture() + try { + importProfileStateJson(db, '{"automationRuns":[{"id":"a"},{"id":"b"}]}') + db.exec(sql) + expect(() => readProfileStateParsedSnapshot(db)).toThrow(message) + expect(() => readProfileStateSnapshot(db)).toThrow(message) + expect(() => validateProfileStateSnapshot(db)).toThrow(message) + } finally { + db.close() + } + }) + + it('rejects a normalized identity mismatch even when the payload hash is valid', () => { + const db = fixture() + try { + importProfileStateJson(db, '{"automationRuns":[{"id":"a"}]}') + const payload = '{"id":"other"}' + db.prepare('UPDATE profile_state_automation_runs SET payload = ?, content_hash = ?').run( + payload, + hashProfileStateJson(payload) + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow('identity is corrupt') + expect(() => readProfileStateSnapshot(db)).toThrow('identity is corrupt') + expect(() => validateProfileStateSnapshot(db)).toThrow('identity is corrupt') + } finally { + db.close() + } + }) + + it.each([ + undefined, + null, + [], + [ + { id: 'second', unknown: '雪 🐋\ud800' }, + { id: 'first', unknown: null } + ], + { futureHistoryFormat: true }, + [{ id: 'duplicate' }, { id: 'duplicate' }] + ])('matches serialized semantics for history %j', (automationRuns) => { + const db = fixture() + try { + const source = JSON.stringify( + Object.fromEntries([ + ['z', { sealed: 'safeStorage:unchanged' }], + ['__proto__', { own: true }], + ['10', 'ten'], + ['2', 'two'], + ['constructor', 'own constructor'], + ['automationRuns', automationRuns], + ['a', null] + ]) + ) + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + const serialized = readProfileStateSnapshot(db) + const expected: unknown = JSON.parse(serialized.json) + const parsed = readProfileStateParsedSnapshot(db) + expect(parsed).toStrictEqual({ revision: serialized.revision, state: expected }) + expect(Object.keys(parsed.state)).toEqual(Object.keys(JSON.parse(source))) + expect(Object.hasOwn(parsed.state, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(parsed.state)).toBe(Object.prototype) + expect(readAcceptedProfileStateParsedSnapshot(db, source)).toStrictEqual(parsed) + expect(readAcceptedProfileStateParsedSnapshot(db, '{}')).toBeUndefined() + expect( + readProfileStateDocuments(db).every((document) => !Object.hasOwn(document, 'value')) + ).toBe(true) + expect(readProfileStateSnapshot(db).json).toBe(serialized.json) + } finally { + db.close() + } + }) + + it('validates original bytes while reusing noncanonical JSON values', () => { + const db = fixture() + try { + importProfileStateJson(db, '{"future":null,"automationRuns":[{"id":"a"},{"id":"b"}]}') + const future = + ' {"negativeZero":-0,"large":1e400,"duplicate":1,"duplicate":2,"text":"雪\\ud800"} ' + db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ).run(future, hashProfileStateJson(future), 'future') + const payloads = [' {"id":"a","number":-0,"large":1e400} ', '{"id":"b","text":"雪\\ud800"}'] + for (const [ordinal, payload] of payloads.entries()) { + db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = ?' + ).run(payload, hashProfileStateJson(payload), ordinal) + } + const aggregate = `[${payloads.join(',')}]` + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(aggregate) + ) + const serialized = readProfileStateSnapshot(db) + expect(serialized.json).toBe(`{"future":${future},"automationRuns":${aggregate}}`) + expect(readProfileStateParsedSnapshot(db)).toStrictEqual({ + revision: serialized.revision, + state: JSON.parse(serialized.json) + }) + db.prepare('UPDATE profile_state_automation_runs_meta SET content_hash = ?').run( + hashProfileStateJson(JSON.stringify(JSON.parse(aggregate))) + ) + expect(() => readProfileStateParsedSnapshot(db)).toThrow('aggregate hash mismatch') + expect(() => readProfileStateSnapshot(db)).toThrow('aggregate hash mismatch') + } finally { + db.close() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts new file mode 100644 index 00000000000..0f6307e47d3 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-concurrency.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { verifyProfileStateSchema } from './profile-state-database-validation' +import { + importProfileStateJson, + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' +import { readProfileStateDomainsWithRevisionFromDatabase } from './profile-state-domain-reader' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import * as revisions from './profile-state-revision' + +const directories: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-read-concurrency-')) + directories.push(directory) + const path = join(directory, 'profile-state.db') + const { db } = openProfileStateDatabase(path, 'profile-a') + importProfileStateJson(db, '{"automationRuns":[{"id":"run-1","status":"pending"}]}') + return { path, db } +} + +describe('profile state reads during concurrent commits', () => { + it.each([readProfileStateSnapshot, readProfileStateParsedSnapshot, validateProfileStateSnapshot])( + 'keeps revision and documents together when a writer commits during %s', + (read) => { + const { path, db: writer } = fixture() + const { db: reader } = openProfileStateDatabaseReadOnly(path, 'profile-a') + const readRevision = revisions.readProfileStateRevision + let committed = false + vi.spyOn(revisions, 'readProfileStateRevision').mockImplementation((db) => { + const revision = readRevision(db) + if (db === reader && !committed) { + committed = true + writeProfileStateDomains(writer, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[]' }] + }) + } + return revision + }) + try { + const snapshot = read(reader) + expect(committed).toBe(true) + if (typeof snapshot === 'number') { + expect(snapshot).toBe(1) + } else { + expect(snapshot).toMatchObject({ revision: 1 }) + expect(snapshot).toMatchObject( + read === readProfileStateSnapshot + ? { json: '{"automationRuns":[{"id":"run-1","status":"pending"}]}' } + : { state: { automationRuns: [{ id: 'run-1', status: 'pending' }] } } + ) + } + expect(reader.isTransaction).toBe(false) + const next = read(reader) + expect(typeof next === 'number' ? next : next.revision).toBe(2) + } finally { + reader.close() + writer.close() + } + } + ) + + it.each([ + ['read-only', openProfileStateDatabaseReadOnly], + ['writable', openProfileStateDatabase] + ] as const)( + 'opens a healthy %s database when automation state changes between validation reads', + (_, open) => { + const { path, db: writer } = fixture() + const readRevision = revisions.readProfileStateRevision + let committed = false + vi.spyOn(revisions, 'readProfileStateRevision').mockImplementation((reader) => { + const revision = readRevision(reader) + if (reader !== writer && !committed) { + committed = true + writeProfileStateDomains(writer, { + expectedRevision: 1, + replacements: [{ domain: 'automationRuns', payload: '[]' }] + }) + } + return revision + }) + try { + const opened = open(path, 'profile-a') + try { + expect(committed).toBe(true) + expect(opened.db.isTransaction).toBe(false) + expect(readProfileStateParsedSnapshot(opened.db)).toEqual({ + revision: 2, + state: { automationRuns: [] } + }) + expect(readProfileStateSnapshot(opened.db)).toMatchObject({ + revision: 2, + json: '{"automationRuns":[]}' + }) + } finally { + opened.db.close() + } + } finally { + writer.close() + } + } + ) + + it('keeps caller-owned writes uncommitted through schema, full and selected reads', () => { + const { db } = fixture() + try { + db.exec('BEGIN IMMEDIATE') + db.prepare('INSERT INTO profile_state_meta (key, value) VALUES (?, ?)').run('probe', 'value') + verifyProfileStateSchema(db, 'profile-a') + expect(readProfileStateSnapshot(db).revision).toBe(1) + expect(readProfileStateParsedSnapshot(db).revision).toBe(1) + expect(readProfileStateDomainsWithRevisionFromDatabase(db, ['automationRuns'])).toEqual({ + kind: 'values', + revision: 1, + values: new Map([['automationRuns', [{ id: 'run-1', status: 'pending' }]]]) + }) + expect(db.isTransaction).toBe(true) + db.exec('ROLLBACK') + expect( + db.prepare('SELECT value FROM profile_state_meta WHERE key = ?').get('probe') + ).toBeUndefined() + } finally { + db.close() + } + }) + + it.each(['owned', 'caller'] as const)( + 'preserves corrupt state and releases only %s read transactions', + (ownership) => { + const { db } = fixture() + try { + if (ownership === 'caller') { + db.exec('BEGIN IMMEDIATE') + } + db.exec('DELETE FROM profile_state_automation_runs_meta') + expect(() => verifyProfileStateSchema(db, 'profile-a')).toThrow('metadata is malformed') + expect(() => readProfileStateSnapshot(db)).toThrow('metadata is malformed') + expect(() => readProfileStateParsedSnapshot(db)).toThrow('metadata is malformed') + expect(readProfileStateDomainsWithRevisionFromDatabase(db, ['automationRuns']).kind).toBe( + 'unreadable' + ) + expect(db.isTransaction).toBe(ownership === 'caller') + expect(db.prepare('SELECT domain FROM profile_state_automation_runs_meta').all()).toEqual( + [] + ) + if (ownership === 'caller') { + db.exec('ROLLBACK') + expect(readProfileStateSnapshot(db).revision).toBe(1) + expect(readProfileStateParsedSnapshot(db).revision).toBe(1) + } + } finally { + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-read-snapshot.ts b/src/main/persistence/profile-state/profile-state-read-snapshot.ts new file mode 100644 index 00000000000..415c1721071 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-read-snapshot.ts @@ -0,0 +1,37 @@ +import type Database from '../../sqlite/sync-database' + +export class ProfileStateReadRollbackError extends Error { + readonly code = 'profile-state-read-rollback-failed' as const + + constructor( + cause: unknown, + readonly rollbackError: unknown + ) { + super('Profile state read failed without releasing its transaction', { cause }) + this.name = 'ProfileStateReadRollbackError' + } +} + +/** Reuse a caller's transaction without committing or rolling it back. */ +export function withProfileStateReadSnapshot(db: Database.Database, read: () => T): T { + const ownsTransaction = !db.isTransaction + if (ownsTransaction) { + db.exec('BEGIN') + } + try { + const result = read() + if (ownsTransaction) { + db.exec('COMMIT') + } + return result + } catch (error) { + if (ownsTransaction && db.isTransaction) { + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + throw new ProfileStateReadRollbackError(error, rollbackError) + } + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts new file mode 100644 index 00000000000..82e4ccf708e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-batch.test.ts @@ -0,0 +1,227 @@ +import { + closeSync, + copyFileSync, + existsSync, + ftruncateSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, + writeSync +} from 'node:fs' +import type * as FileSystem from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as processes from '../../../shared/child-process/run-process' +import { copyProfileStateRecoveryFiles } from './profile-state-recovery-copy' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' + +const publication = vi.hoisted(() => ({ unsupportedTarget: '' })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: (...args: Parameters) => { + if (args[1] === publication.unsupportedTarget) { + throw Object.assign(new Error('hardlinks unavailable'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + publication.unsupportedTarget = '' + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(names = ['primary.db', 'backup.db'], large = true) { + const directory = mkdtempSync(join(tmpdir(), 'orca-recovery-batch-')) + directories.push(directory) + const files = names.map((name, index) => { + const sourceDirectory = join(directory, `source-${index}`) + mkdirSync(sourceDirectory) + const source = join(sourceDirectory, name) + const descriptor = openSync(source, 'wx', 0o600) + try { + ftruncateSync(descriptor, large ? 8 * 1024 * 1024 + 1 : 128) + writeSync(descriptor, Buffer.from(`retained-${index}`)) + } finally { + closeSync(descriptor) + } + return { source, target: join(directory, `restored-${index}.db`) } + }) + return { directory, files } +} + +function expectIndependent(files: ReturnType['files']): void { + for (const { source, target } of files) { + const original = readFileSync(source) + expect(readFileSync(target).equals(original)).toBe(true) + expect(statSync(source, { bigint: true }).ino).not.toBe(statSync(target, { bigint: true }).ino) + writeFileSync(source, 'changed source') + expect(readFileSync(target).equals(original)).toBe(true) + } +} + +function expectNoTemporary(directory: string): void { + expect(readdirSync(directory).some((name) => name.startsWith('.orca-recovery-clone-'))).toBe( + false + ) +} + +describe('batched profile recovery copies', () => { + it.each(['manifest.json', 'MANIFEST.JSON'])( + 'preserves a recovery artifact named %s without overwriting it with a manifest', + (name) => { + const { directory, files } = fixture(['primary.db', name], false) + const artifact = files[1].source + const original = readFileSync(artifact) + const quarantine = () => + quarantineProfileStateDatabase(files[0].source, 'profile', directory, 'test', [artifact]) + if (existsSync(join(dirname(artifact), 'manifest.json'))) { + expect(quarantine).toThrow('conflicts with the quarantine manifest') + expect( + readdirSync(directory).some((entry) => entry.startsWith('profile-state-corrupt-')) + ).toBe(false) + } else { + const result = quarantine() + expect(readFileSync(join(result.directory, name)).equals(original)).toBe(true) + } + expect(readFileSync(artifact).equals(original)).toBe(true) + expect(existsSync(files[0].source)).toBe(true) + } + ) + + it.each([false, true])('preserves every independent file with large copies=%s', (large) => { + const { directory, files } = fixture(undefined, large) + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('does nothing for an empty batch', () => { + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles([]) + expect(run).not.toHaveBeenCalled() + }) + + describe.skipIf(process.platform !== 'darwin')('Darwin batch boundaries', () => { + it('shares one process with absolute arguments and the per-file timeout budget', () => { + const { directory, files } = fixture() + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledOnce() + const spec = run.mock.calls[0]?.[0] + expect(spec?.args?.slice(1).every(isAbsolute)).toBe(true) + expect(spec?.args).toHaveLength(4) + expect(spec?.timeoutMs).toBe(60_000) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('bounds a shared process when many large artifacts are retained', () => { + const { directory, files } = fixture( + Array.from({ length: 17 }, (_, index) => `backup-${index}.db`) + ) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledTimes(2) + expect(run.mock.calls.every(([spec]) => (spec.args?.length ?? 0) <= 18)).toBe(true) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it.each([ + ['same.db', 'same.db'], + ['CASE.db', 'case.db'], + ['é.db', 'e\u0301.db'], + ['suffix.db.', 'suffix.db'], + ['- leading space.db', 'plain.db'] + ])('isolates ambiguous source names %s and %s', (...names) => { + const { directory, files } = fixture(names) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFiles(files) + expect(run).toHaveBeenCalledTimes(2) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it.each(['timeout', 'signal', 'spawn'] as const)( + 'publishes no cloned files after a batch %s failure', + (failure) => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation(() => { + if (failure === 'spawn') { + throw new Error('copy process could not start') + } + return { + code: null, + signal: 'SIGTERM', + timedOut: failure === 'timeout', + stdout: '', + stderr: '' + } + }) + expect(() => copyProfileStateRecoveryFiles(files)).toThrow() + for (const { source, target } of files) { + expect(existsSync(source)).toBe(true) + expect(existsSync(target)).toBe(false) + } + expectNoTemporary(directory) + } + ) + + it('discards partial process output before falling back for every source', () => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporaryDirectory = spec.args?.at(-1) + if (!temporaryDirectory) { + throw new Error('Missing clone directory') + } + expect(statSync(temporaryDirectory).mode & 0o777).toBe(0o700) + writeFileSync(join(temporaryDirectory, basename(files[0].source)), 'incomplete') + return { code: 1, signal: null, timedOut: false, stdout: '', stderr: 'clone unavailable' } + }) + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + + it('preserves a destination created during the batch process', () => { + const { directory, files } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporaryDirectory = spec.args?.at(-1) + if (!temporaryDirectory) { + throw new Error('Missing clone directory') + } + for (const { source } of files) { + copyFileSync(source, join(temporaryDirectory, basename(source))) + } + writeFileSync(files[1].target, 'concurrent destination') + return { code: 0, signal: null, timedOut: false, stdout: '', stderr: '' } + }) + expect(() => copyProfileStateRecoveryFiles(files)).toThrow() + expect(readFileSync(files[1].target, 'utf8')).toBe('concurrent destination') + expect(readFileSync(files[0].target).equals(readFileSync(files[0].source))).toBe(true) + expectNoTemporary(directory) + }) + + it('falls back independently when one destination cannot publish hardlinks', () => { + const { directory, files } = fixture() + publication.unsupportedTarget = files[1].target + copyProfileStateRecoveryFiles(files) + expectIndependent(files) + expectNoTemporary(directory) + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts new file mode 100644 index 00000000000..0715f9f3561 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' +import { + ProfileStateRecoveryCommandError, + type ProfileStateRecoverySelector, + type ProfileStateExportsResult, + type ProfileStateRollbackResult +} from '../../../shared/profile-state-recovery-command' +import { getActiveProfileStateLocation } from './profile-state-active-location' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import type { ProfileStateMaintenance } from './profile-state-access' +import { readProfileStateDomain } from './profile-state-domain-reader' +import { isRecord } from './profile-state-document-validation' +import { profileHasPendingProjectMove } from '../../orca-profiles/profile-project-move-record' +import { + invalidateHttp1CompatibilityMarker, + writeHttp1CompatibilityMarker +} from '../../startup/http1-compatibility-marker' + +export function getProfileStateExports(userDataPath: string): ProfileStateExportsResult { + const location = getActiveProfileStateLocation(userDataPath) + if (location === undefined) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'No active profile is available for recovery.' + ) + } + return { + profileId: location.profileId, + dataFile: location.dataFile, + databaseFile: location.databaseFile, + exportPaths: profileStateJsonExportPaths(location.dataFile), + backups: profileStateDatabaseBackups(location.databaseFile) + } +} + +export function rollbackProfileState( + userDataPath: string, + selector: ProfileStateRecoverySelector, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + const result = getProfileStateExports(userDataPath) + if (profileHasPendingProjectMove(result.profileId, userDataPath)) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'This profile has a pending project move. Resolve the move with both profiles preserved before restoring a single profile.' + ) + } + if (selector.kind === 'sqlite') { + return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance) + } + const revision = selector.kind === 'json' ? selector.revision : null + const exportPath = + revision === null ? result.dataFile : profileStateJsonExportPath(result.dataFile, revision) + if (revision !== null && !result.exportPaths.includes(exportPath)) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + `Profile-state export revision ${revision} is unavailable. Use profile state exports to inspect retained revisions.` + ) + } + const recovered = restoreProfileStateJsonExport({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + exportPath, + profileId: result.profileId, + ...(revision === null ? { reason: 'profile-state-adopt-current-json' } : {}), + beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) + }) + syncHttp1CompatibilityMarkerAfterRollback(userDataPath, result.dataFile, result.profileId) + return { + ...result, + storage: 'json', + restoredPath: result.dataFile, + revision, + quarantineDirectory: recovered.quarantine.directory, + removedDatabaseFiles: recovered.removedDatabaseFiles + } +} + +function restoreDatabaseBackup( + userDataPath: string, + result: ProfileStateExportsResult, + backupId: string, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + const backup = result.backups.find((entry) => entry.id === backupId) + if (!backup) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Profile-state backup is unavailable. Use profile state exports to inspect retained backups.' + ) + } + const recovered = restoreProfileStateDatabaseBackup({ + maintenance, + databasePath: result.databaseFile, + dataFile: result.dataFile, + backupPath: backup.path, + profileId: result.profileId, + beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) + }) + const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings') + if (settings.kind !== 'unreadable') { + const enabled = + settings.kind === 'value' && + isRecord(settings.value) && + settings.value.electronHttp1CompatibilityMode === true + writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId) + } + return { + ...result, + storage: 'sqlite', + restoredPath: result.databaseFile, + backupId: backup.id, + revision: recovered.revision, + quarantineDirectory: recovered.quarantine.directory, + removedDatabaseFiles: recovered.removedDatabaseFiles + } +} + +function syncHttp1CompatibilityMarkerAfterRollback( + userDataPath: string, + dataFile: string, + profileId: string +): void { + let enabled = false + try { + const parsed: unknown = JSON.parse(readFileSync(dataFile, 'utf8')) + if (isRecord(parsed) && isRecord(parsed.settings)) { + enabled = parsed.settings.electronHttp1CompatibilityMode === true + } + } catch { + // Leave the invalidated marker absent so startup reads the restored JSON itself. + return + } + writeHttp1CompatibilityMarker(userDataPath, enabled, profileId) +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts new file mode 100644 index 00000000000..d7bc8796be2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.test.ts @@ -0,0 +1,193 @@ +import { + closeSync, + copyFileSync, + existsSync, + ftruncateSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, + writeSync +} from 'node:fs' +import type * as FileSystem from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as processes from '../../../shared/child-process/run-process' +import { copyProfileStateRecoveryFile } from './profile-state-recovery-copy' + +const cloneLink = vi.hoisted(() => ({ unsupported: false })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + linkSync: (...args: Parameters) => { + if (cloneLink.unsupported) { + throw Object.assign(new Error('hardlinks unavailable'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + } + } +}) + +const directories: string[] = [] +afterEach(() => { + cloneLink.unsupported = false + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(large = true) { + const directory = mkdtempSync(join(tmpdir(), 'orca-recovery-copy-')) + directories.push(directory) + const source = join(directory, '- source with spaces') + const target = join(directory, 'target') + const descriptor = openSync(source, 'wx', 0o600) + try { + ftruncateSync(descriptor, large ? 8 * 1024 * 1024 + 1 : 100) + writeSync(descriptor, Buffer.from('retained source')) + } finally { + closeSync(descriptor) + } + return { directory, source, target } +} + +function expectNoTemporary(directory: string): void { + expect(readdirSync(directory).some((name) => name.startsWith('.orca-recovery-clone-'))).toBe( + false + ) +} + +describe('independent profile recovery copies', () => { + it.each([false, true])('preserves independent bytes with a large file=%s', (large) => { + const { directory, source, target } = fixture(large) + const before = readFileSync(source) + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(before)).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe(statSync(source, { bigint: true }).ino) + writeFileSync(source, 'changed source') + expect(readFileSync(target).equals(before)).toBe(true) + writeFileSync(target, 'changed target') + expect(readFileSync(source, 'utf8')).toBe('changed source') + expectNoTemporary(directory) + }) + + it.each([false, true])('never replaces an existing destination with a large file=%s', (large) => { + const { directory, source, target } = fixture(large) + writeFileSync(target, 'do not replace') + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(target, 'utf8')).toBe('do not replace') + expectNoTemporary(directory) + }) + + it('does not start a copy process for small files', () => { + const { source, target } = fixture(false) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(source, target) + expect(run).not.toHaveBeenCalled() + }) + + describe.skipIf(process.platform !== 'darwin')('Darwin clone failure boundaries', () => { + it('resolves both process arguments while preserving relative path behavior', () => { + const { directory, source, target } = fixture() + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(relative(process.cwd(), source), relative(process.cwd(), target)) + const args = run.mock.calls[0]?.[0].args + expect(args?.[0]).toBe('-c') + expect(isAbsolute(args?.[1] ?? '')).toBe(true) + expect(isAbsolute(args?.[2] ?? '')).toBe(true) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expectNoTemporary(directory) + }) + + it('keeps ordinary-copy semantics for symlinks to large recovery artifacts', () => { + const { directory, source, target } = fixture() + const alias = join(directory, 'alias') + symlinkSync(source, alias) + const run = vi.spyOn(processes, 'runProcessSync') + copyProfileStateRecoveryFile(alias, target) + expect(run).not.toHaveBeenCalled() + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + + it('cleans a failed partial clone and falls back to an independent ordinary copy', () => { + const { directory, source, target } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporary = spec.args?.[2] + if (typeof temporary !== 'string') { + throw new Error('Missing clone destination') + } + expect(statSync(dirname(temporary)).mode & 0o777).toBe(0o700) + writeFileSync(temporary, 'incomplete') + return { code: 1, signal: null, timedOut: false, stdout: '', stderr: 'clone unavailable' } + }) + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + + it.each(['timeout', 'signal', 'spawn'] as const)( + 'preserves originals and removes temporary copies after %s failure', + (failure) => { + const { directory, source, target } = fixture() + const before = readFileSync(source) + vi.spyOn(processes, 'runProcessSync').mockImplementation(() => { + if (failure === 'spawn') { + throw new Error('copy process could not start') + } + return { + code: null, + signal: 'SIGTERM', + timedOut: failure === 'timeout', + stdout: '', + stderr: '' + } + }) + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(source).equals(before)).toBe(true) + expect(existsSync(target)).toBe(false) + expectNoTemporary(directory) + } + ) + + it('preserves a destination created while the clone process runs', () => { + const { directory, source, target } = fixture() + vi.spyOn(processes, 'runProcessSync').mockImplementation((spec) => { + const temporary = spec.args?.[2] + if (typeof temporary !== 'string') { + throw new Error('Missing clone destination') + } + copyFileSync(source, temporary) + writeFileSync(target, 'concurrent destination') + return { code: 0, signal: null, timedOut: false, stdout: '', stderr: '' } + }) + expect(() => copyProfileStateRecoveryFile(source, target)).toThrow() + expect(readFileSync(target, 'utf8')).toBe('concurrent destination') + expectNoTemporary(directory) + }) + + it('falls back when the destination filesystem does not support hardlinks', () => { + const { directory, source, target } = fixture() + cloneLink.unsupported = true + copyProfileStateRecoveryFile(source, target) + expect(readFileSync(target).equals(readFileSync(source))).toBe(true) + expect(statSync(target, { bigint: true }).ino).not.toBe( + statSync(source, { bigint: true }).ino + ) + expectNoTemporary(directory) + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-copy.ts b/src/main/persistence/profile-state/profile-state-recovery-copy.ts new file mode 100644 index 00000000000..cb7a1f9315b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-copy.ts @@ -0,0 +1,81 @@ +import { constants, copyFileSync, linkSync, lstatSync, mkdtempSync, rmSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { runProcessSync } from '../../../shared/child-process/run-process' + +// Keep process startup overhead off small recovery copies. +const MINIMUM_CLONE_BYTES = 8 * 1024 * 1024 +const MAXIMUM_CLONE_FILES = 16 + +type RecoveryCopy = { source: string; target: string } + +/** Copy quiescent recovery artifacts independently; never copy an active WAL database this way. */ +export function copyProfileStateRecoveryFile(source: string, target: string): void { + copyProfileStateRecoveryFiles([{ source, target }]) +} + +/** Targets are staging files; callers validate, fsync and publish. */ +export function copyProfileStateRecoveryFiles(files: readonly RecoveryCopy[]): void { + const clones: RecoveryCopy[] = [] + const names = new Set() + for (const file of files) { + const info = process.platform === 'darwin' ? lstatSync(file.source) : undefined + const name = basename(file.source) + if (!info?.isFile() || info.size < MINIMUM_CLONE_BYTES) { + copyFileSync(file.source, file.target, constants.COPYFILE_EXCL) + } else if ( + files.length > 1 && + (clones.length === MAXIMUM_CLONE_FILES || + !/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i.test(name) || + names.has(name.toLowerCase())) + ) { + // cp derives temporary basenames; ambiguous names need their own private directory. + copyProfileStateRecoveryFile(file.source, file.target) + } else { + clones.push(file) + names.add(name.toLowerCase()) + } + } + if (clones.length === 0) { + return + } + const directory = mkdtempSync(join(dirname(clones[0].target), '.orca-recovery-clone-')) + const temporary = (source: string) => + join(directory, clones.length === 1 ? 'copy' : basename(source)) + let cloned = false + try { + // Node's clone flag is unsupported on Darwin; cp -c falls back to ordinary copying. + const result = runProcessSync({ + program: '/bin/cp', + args: [ + '-c', + ...clones.map(({ source }) => resolve(source)), + resolve(clones.length === 1 ? temporary(clones[0].source) : directory) + ], + timeoutMs: 30_000 * clones.length, + maxOutputBytes: 16_384 + }) + if (result.timedOut || result.signal !== null) { + throw new Error('Profile recovery file copy was interrupted') + } + if (result.code === 0) { + for (const { source, target } of clones) { + try { + linkSync(temporary(source), target) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + throw error + } + copyFileSync(source, target, constants.COPYFILE_EXCL) + } + } + cloned = true + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + if (!cloned) { + for (const { source, target } of clones) { + copyFileSync(source, target, constants.COPYFILE_EXCL) + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts new file mode 100644 index 00000000000..2554324968d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts @@ -0,0 +1,378 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../../../shared/secret-store' +import { profileStateStorage } from '../../orca-profiles/profile-project-state-file' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateSnapshot +} from './profile-state-documents' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath, + profileStateDatabaseBackups +} from './profile-state-backup-path' +import { writeProfileStateDatabaseSnapshotAsync } from './profile-state-database-snapshot' +import { createProfileStateStore } from './profile-state-store-factory' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { + buildRecoveryCrashProcess, + killRecoveryAt, + type RecoveryCrashOptions +} from './profile-state-recovery-crash-process' + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const suiteRoot = mkdtempSync(join(tmpdir(), 'orca-recovery-crash-boundaries-')) +const fixtureRoots: string[] = [] +let bundle: string +const profileId = 'crash-recovery' +const selectedState = { + settings: { + theme: 'dark', + httpProxyUrl: Buffer.from('sealed-selected').toString('base64'), + electronHttp1CompatibilityMode: true + }, + ui: { extension: { origin: 'selected', value: null } }, + extension: { nested: [null, '\ud800', 'selected'], ['__proto__']: { inert: true } }, + opaque: null, + repos: [], + automationRuns: [], + automations: [] +} +const oldState = { + ...selectedState, + settings: { + ...selectedState.settings, + theme: 'light', + httpProxyUrl: Buffer.from('sealed-old').toString('base64') + }, + ui: { extension: { origin: 'old', value: null } }, + extension: { nested: [null, '\ud800', 'old'], ['__proto__']: { inert: true } } +} +const selectedJson = JSON.stringify(selectedState) +const oldJson = JSON.stringify(oldState) + +beforeAll(() => { + bundle = buildRecoveryCrashProcess(suiteRoot) +}) +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => false, + encryptString: () => { + throw new Error('Keychain unavailable') + }, + decryptString: () => { + throw new Error('Keychain unavailable') + }, + describeProtectionGap: () => null + }) +}) +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(suiteRoot, { recursive: true, force: true })) + +type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map } + +async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const root = mkdtempSync(join(suiteRoot, 'profile-')) + fixtureRoots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databasePath = join(directory, 'profile-state.db') + const exportPath = profileStateJsonExportPath(dataFile, 3) + const backupPath = profileStateDatabaseBackupPath( + databasePath, + createProfileStateDatabaseBackupId() + ) + const options = { + root, + profileId, + dataFile, + databasePath, + exportPath, + backupPath, + markerPath: join(root, 'http1-compatibility.json'), + kind + } + const source = openProfileStateDatabase(databasePath, profileId) + try { + importProfileStateJson(source.db, oldJson) + importProfileStateJson(source.db, oldJson) + importProfileStateJson( + source.db, + selectedJson, + accepted ? { acceptedLegacyJsonHash: hashProfileStateJson(selectedJson) } : {} + ) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupPath) + } finally { + source.db.close() + } + await killRecoveryAt(bundle, options, 'seed', oldJson) + expect(existsSync(`${databasePath}-wal`)).toBe(true) + expect(existsSync(`${databasePath}-shm`)).toBe(true) + // An empty abandoned rollback journal is harmless, but must be removed before publication. + writeFileSync(`${databasePath}-journal`, '') + if (accepted) { + writeFileSync(dataFile, selectedJson) + } + writeFileSync(exportPath, selectedJson) + writeFileSync( + profileStateJsonExportPath(dataFile, 1), + JSON.stringify({ ...oldState, exportRevision: 1 }) + ) + writeFileSync( + profileStateJsonExportPath(dataFile, 2), + JSON.stringify({ ...oldState, exportRevision: 2 }) + ) + writeFileSync(options.markerPath, JSON.stringify({ schemeVersion: 2, enabled: false, profileId })) + const originalFamily = new Map( + ['', '-wal', '-shm', '-journal'].map((suffix) => [ + suffix, + readFileSync(`${databasePath}${suffix}`) + ]) + ) + return { ...options, backupBytes: readFileSync(backupPath), originalFamily } +} + +function readSqlite(path: string): unknown { + const opened = openProfileStateDatabaseReadOnly(path, profileId) + try { + return JSON.parse(readProfileStateSnapshot(opened.db).json) + } finally { + opened.db.close() + } +} + +function assertQuarantine(profile: Fixture): void { + const quarantine = readdirSync(dirname(profile.databasePath)).find((name) => + name.startsWith('profile-state-corrupt-') + ) + if (quarantine === undefined) { + throw new Error('Recovery did not preserve a quarantine') + } + const directory = join(dirname(profile.databasePath), quarantine) + // Check exact family bytes before opening the copied WAL snapshot. + for (const [suffix, bytes] of profile.originalFamily) { + expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes) + } + expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson) + expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes) + expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState) +} + +function assertRestart(profile: Fixture, expected: 'old' | 'selected' | 'refused'): void { + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + const open = () => + createProfileStateStore({ + dataFile: profile.dataFile, + databaseFile: profile.databasePath, + profileId, + authorityMode: 'sqlite-established' + }) + if (expected === 'refused') { + expect(open).toThrow() + expect(() => profileStateStorage(profileId, profile.root)).toThrow() + return + } + const expectedState = expected === 'old' ? oldState : selectedState + const storage = profileStateStorage(profileId, profile.root) + const raw = + storage === 'sqlite' + ? readSqlite(profile.databasePath) + : JSON.parse(readFileSync(profile.dataFile, 'utf8')) + // Full raw-state equality is checked before Store normalization can hide a lost domain. + expect(raw).toEqual(expectedState) + const reopened = open() + try { + expect(reopened.backend).toBe(storage) + const projected: unknown = JSON.parse(reopened.store.prepareProfileStateExport().json) + expect(projected).toMatchObject(expectedState) + } finally { + reopened.store.freezeWrites() + } + } finally { + admission.release() + } +} + +function retry(profile: Fixture): void { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + if (profile.kind === 'json') { + expect(profileStateJsonExportPaths(profile.dataFile)).toContain(profile.exportPath) + restoreProfileStateJsonExport({ ...profile, maintenance }) + } else { + expect( + profileStateDatabaseBackups(profile.databasePath).some( + (backup) => backup.path === profile.backupPath + ) + ).toBe(true) + restoreProfileStateDatabaseBackup({ ...profile, maintenance }) + } + } finally { + maintenance.release() + } + assertRestart(profile, 'selected') +} + +const JSON_BOUNDARIES = [ + 'marker-invalidated', + 'json-publish:before', + 'json-publish:after', + 'primary', + 'wal', + 'shm', + 'journal', + 'other-export', + 'first-export', + 'backup', + 'selected-export', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' +] as const + +function stage(profile: Fixture, name: string): string { + const paths: Record = { + primary: profile.databasePath, + wal: `${profile.databasePath}-wal`, + shm: `${profile.databasePath}-shm`, + journal: `${profile.databasePath}-journal`, + 'other-export': profileStateJsonExportPath(profile.dataFile, 2), + 'first-export': profileStateJsonExportPath(profile.dataFile, 1), + 'marker-invalidated': profile.markerPath, + backup: profile.backupPath, + 'selected-export': profile.exportPath, + json: profile.dataFile + } + const target = paths[name] + return target === undefined ? name : `removed:${target}` +} + +describe.each([false, true])('JSON recovery process death, accepted prior JSON=%s', (accepted) => { + it.each(JSON_BOUNDARIES)( + 'preserves a complete authority or exact retry at %s', + async (boundary) => { + const profile = await fixture('json', accepted) + await killRecoveryAt(bundle, profile, stage(profile, boundary)) + assertQuarantine(profile) + const finished = [ + 'selected-export', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ].includes(boundary) + const expected = finished + ? 'selected' + : ['marker-invalidated', 'json-publish:before'].includes(boundary) || + (boundary === 'json-publish:after' && accepted) + ? 'old' + : 'refused' + assertRestart(profile, expected) + if (finished) { + expect(readFileSync(profile.dataFile, 'utf8')).toBe(selectedJson) + expect(profileStateJsonExportPaths(profile.dataFile)).toEqual([]) + expect(profileStateDatabaseBackups(profile.databasePath)).toEqual([]) + } else { + expect(readFileSync(profile.exportPath, 'utf8')).toBe(selectedJson) + retry(profile) + } + } + ) +}) + +describe('SQLite recovery process death', () => { + it.each([ + 'marker-invalidated', + 'selected-export', + 'other-export', + 'first-export', + 'primary', + 'wal', + 'shm', + 'journal', + 'json', + 'sqlite-publish:before', + 'sqlite-publish:after', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ])('preserves full state and its immutable retry backup at %s', async (boundary) => { + const profile = await fixture('sqlite', true) + await killRecoveryAt(bundle, profile, stage(profile, boundary)) + assertQuarantine(profile) + expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + const expected = [ + 'marker-invalidated', + 'selected-export', + 'other-export', + 'first-export' + ].includes(boundary) + ? 'old' + : [ + 'sqlite-publish:after', + 'restore-returned', + 'marker-publish:before', + 'marker-publish:after', + 'marker-refreshed' + ].includes(boundary) + ? 'selected' + : 'refused' + assertRestart(profile, expected) + retry(profile) + expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + }) + + it.skipIf(process.platform === 'win32')( + 'allows clean JSON startup after final artifact cleanup is directory-synced', + async () => { + const profile = await fixture('json', true) + await killRecoveryAt(bundle, profile, 'cleanup-directory-synced') + assertQuarantine(profile) + assertRestart(profile, 'selected') + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts new file mode 100644 index 00000000000..d51ae465dae --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-process.ts @@ -0,0 +1,178 @@ +import { once } from 'node:events' +import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { buildSync } from 'esbuild' +import { spawnProcess } from '../../../shared/child-process/run-process' + +export type RecoveryCrashOptions = { + root: string + profileId: string + dataFile: string + databasePath: string + exportPath: string + backupPath: string + markerPath: string + kind: 'json' | 'sqlite' +} + +/** Build only the recovery graph into an isolated test directory, never shared out/. */ +export function buildRecoveryCrashProcess(directory: string): string { + const bundle = join(directory, 'recovery-crash-api.cjs') + buildSync({ + stdin: { + contents: ` + export { acquireProfileStateMaintenance } from './src/main/persistence/profile-state/profile-state-access' + export { restoreProfileStateJsonExport } from './src/main/persistence/profile-state/profile-state-recovery' + export { restoreProfileStateDatabaseBackup } from './src/main/persistence/profile-state/profile-state-database-recovery' + export { openProfileStateDatabase } from './src/main/persistence/profile-state/profile-state-database' + export { importProfileStateJson } from './src/main/persistence/profile-state/profile-state-documents' + export { invalidateHttp1CompatibilityMarker, writeHttp1CompatibilityMarker } from './src/main/startup/http1-compatibility-marker' + `, + loader: 'ts', + resolveDir: process.cwd() + }, + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external' + }) + return bundle +} + +const CHILD_SOURCE = ` +const fs = require('node:fs') +const [bundle, raw, stage, payloadPath] = process.argv.slice(2) +const options = JSON.parse(raw) +const payload = fs.readFileSync(payloadPath, 'utf8') +const api = require(bundle) +const barrier = label => { + if (label !== stage) return + fs.writeSync(1, label + '\\n') + fs.readSync(0, Buffer.alloc(1), 0, 1) + throw new Error('Crash barrier unexpectedly resumed') +} +if (stage === 'seed') { + const source = api.openProfileStateDatabase(options.databasePath, options.profileId) + api.importProfileStateJson(source.db, payload, { expectedRevision: 3 }) + barrier('seed') +} +const rename = fs.renameSync +fs.renameSync = (from, to) => { + if (to === options.dataFile) barrier('json-publish:before') + if (to === options.databasePath) barrier('sqlite-publish:before') + if (to === options.markerPath) barrier('marker-publish:before') + rename(from, to) + if (to === options.dataFile) barrier('json-publish:after') + if (to === options.databasePath) barrier('sqlite-publish:after') + if (to === options.markerPath) barrier('marker-publish:after') +} +const rm = fs.rmSync +fs.rmSync = (target, ...rest) => { + rm(target, ...rest) + barrier('removed:' + target) +} +let clone = 0 +const link = fs.linkSync +fs.linkSync = (from, to) => { + const isClone = from.includes('.orca-recovery-clone-') + if (isClone) barrier('clone:' + (++clone) + ':before') + link(from, to) + if (isClone) barrier('clone:' + clone + ':after') +} +const fsync = fs.fsyncSync +fs.fsyncSync = descriptor => { + fsync(descriptor) + if (fs.fstatSync(descriptor).isDirectory() && !fs.existsSync(options.exportPath)) { + barrier('cleanup-directory-synced') + } +} +const maintenance = api.acquireProfileStateMaintenance(options.root) +const recovered = (options.kind === 'json' + ? api.restoreProfileStateJsonExport + : api.restoreProfileStateDatabaseBackup)({ + ...options, maintenance, + beforeRestore: () => api.invalidateHttp1CompatibilityMarker(options.root) + }) +barrier('restore-returned') +api.writeHttp1CompatibilityMarker(options.root, true, options.profileId) +barrier('marker-refreshed') +maintenance.release() +throw new Error('Requested crash boundary was not reached: ' + stage) +` + +/** A pipe barrier proves the syscall completed before the parent sends SIGKILL. */ +export async function killRecoveryAt( + bundle: string, + options: RecoveryCrashOptions, + stage: string, + payload = '' +): Promise { + const childScript = join(dirname(bundle), 'recovery-crash-child.cjs') + const payloadPath = join(dirname(bundle), 'recovery-crash-payload.json') + writeFileSync(childScript, CHILD_SOURCE, 'utf8') + writeFileSync(payloadPath, payload, 'utf8') + const childEnv = { + ORCA_BACKGROUND_LAUNCH: '1', + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), + ...(process.env.TMP ? { TMP: process.env.TMP } : {}) + } + const recoveryOptions: RecoveryCrashOptions = { + root: options.root, + profileId: options.profileId, + dataFile: options.dataFile, + databasePath: options.databasePath, + exportPath: options.exportPath, + backupPath: options.backupPath, + markerPath: options.markerPath, + kind: options.kind + } + const child = spawnProcess({ + program: process.execPath, + args: [childScript, bundle, JSON.stringify(recoveryOptions), stage, payloadPath], + env: childEnv + }) + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + try { + await new Promise((resolve, reject) => { + let stdout = '' + const timer = setTimeout( + () => finish(new Error(`Crash boundary timed out: ${stage}; ${stderr}`)), + 10_000 + ) + const onData = (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes(`${stage}\n`)) { + finish() + } + } + const onExit = () => finish(new Error(`Recovery exited before ${stage}: ${stderr}`)) + const onError = (error: Error) => finish(error) + const finish = (error?: Error) => { + clearTimeout(timer) + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + if (error) { + reject(error) + } else { + resolve() + } + } + child.stdout.on('data', onData) + child.once('exit', onExit) + child.once('error', onError) + }) + } finally { + if (child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close') + child.kill('SIGKILL') + await closed + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery-required.ts b/src/main/persistence/profile-state/profile-state-recovery-required.ts new file mode 100644 index 00000000000..9c376528562 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -0,0 +1,81 @@ +import { existsSync } from 'node:fs' +import { hasStateBackup } from './profile-state-legacy-backup-path' +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { profileStateDatabaseBackups } from './profile-state-backup-path' + +type ProfileStateRecoveryLocation = { + dataFile: string + databaseFile: string + profileId: string +} + +export class ProfileStateAuthorityBootstrapError extends Error { + readonly code = 'ambiguous-profile-state' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateAuthorityBootstrapError' + } +} + +/** Never establish a new authority over evidence that the primary was lost. */ +export function assertProfileStateCanInitialize(options: ProfileStateRecoveryLocation): void { + assertNoRetainedProfileStateExports(options) + if (!existsSync(options.dataFile) && hasStateBackup(options.dataFile)) { + throw new ProfileStateAuthorityBootstrapError( + `Legacy profile JSON is missing while its .bak.0–.bak.4 backups remain. Stop Orca and restore a selected backup to ${options.dataFile} before reopening.` + ) + } +} + +/** Startup can surface this error with the exact artifacts an explicit rollback may use. */ +export class ProfileStateRecoveryRequiredError extends Error { + readonly code = 'profile-state-recovery-required' as const + readonly dataFile: string + readonly databaseFile: string + readonly exportPaths: readonly string[] + readonly backupPaths: readonly string[] + + constructor(options: ProfileStateRecoveryLocation, cause: unknown) { + super( + `SQLite profile state could not be read; choose a retained backup or JSON export to recover the profile`, + { cause } + ) + this.name = 'ProfileStateRecoveryRequiredError' + this.dataFile = options.dataFile + this.databaseFile = options.databaseFile + // Recovery guidance must survive a permissions failure while enumerating the directory. + // The startup error still names the canonical paths and remains typed for fail-closed handling. + try { + this.exportPaths = profileStateJsonExportPaths(options.dataFile) + } catch { + this.exportPaths = [] + } + try { + this.backupPaths = profileStateDatabaseBackups(options.databaseFile).map(({ path }) => path) + } catch { + this.backupPaths = [] + } + } +} + +/** A retained migration export proves that absent SQLite is not a fresh profile. */ +export function assertNoRetainedProfileStateExports(options: ProfileStateRecoveryLocation): void { + let hasRetainedExport: boolean + try { + hasRetainedExport = + profileStateJsonExportPaths(options.dataFile).length > 0 || + profileStateDatabaseBackups(options.databaseFile).length > 0 + } catch { + throw new ProfileStateRecoveryRequiredError( + options, + new Error('Could not enumerate retained profile state exports') + ) + } + if (hasRetainedExport) { + throw new ProfileStateRecoveryRequiredError( + options, + new Error('SQLite profile state is missing while retained migration exports exist') + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-recovery.ts b/src/main/persistence/profile-state/profile-state-recovery.ts new file mode 100644 index 00000000000..2b3a061ed5a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-recovery.ts @@ -0,0 +1,85 @@ +import { profileStateDatabaseFiles } from './profile-state-storage-classification' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { parseProfileStateRoot } from './profile-state-document-validation' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { bestEffortFsyncDirectorySync } from '../../../shared/secure-file' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' +import { profileStateJsonExportPaths } from './profile-state-export-path' +import { profileStateDatabaseBackupFiles } from './profile-state-backup-path' +import { assertProfileStateMaintenance, type ProfileStateMaintenance } from './profile-state-access' + +export type ProfileStateJsonRecoveryOptions = { + maintenance: ProfileStateMaintenance + databasePath: string + dataFile: string + exportPath: string + profileId: string + quarantineRoot?: string + reason?: string + beforeRestore?: () => void +} + +export type ProfileStateJsonRecovery = { + quarantine: ProfileStateDatabaseQuarantine + removedDatabaseFiles: readonly string[] +} + +/** Restore an explicitly selected JSON export after preserving a failed SQLite authority. */ +export function restoreProfileStateJsonExport( + options: ProfileStateJsonRecoveryOptions +): ProfileStateJsonRecovery { + assertProfileStateMaintenance(options.maintenance, options) + const rawJson = readRecoveryExport(options.exportPath) + const retainedExports = profileStateJsonExportPaths(options.dataFile) + const retainedBackups = profileStateDatabaseBackupFiles(options.databasePath) + const recoveryFiles = [options.exportPath, ...retainedExports, ...retainedBackups] + if (existsSync(options.dataFile)) { + recoveryFiles.push(options.dataFile) + } + const quarantine = quarantineProfileStateDatabase( + options.databasePath, + options.profileId, + options.quarantineRoot, + options.reason ?? 'profile-state-json-rollback', + recoveryFiles + ) + + // Invalidate authority-dependent caches while the database and recovery exports still exist. + options.beforeRestore?.() + mkdirSync(dirname(options.dataFile), { recursive: true }) + writeFileDurableSync(durableWriteTempPath(options.dataFile), options.dataFile, rawJson) + + const removedDatabaseFiles = profileStateDatabaseFiles(options.databasePath).filter((path) => + existsSync(path) + ) + for (const path of removedDatabaseFiles) { + rmSync(path) + } + // Removing the reserved exports completes the authority transition back to JSON. Keeping one + // would make established startup correctly reject the restored legacy state as a stale mirror. + const retainedArtifacts = [...retainedBackups, ...retainedExports] + for (const path of retainedArtifacts) { + if (path !== options.exportPath) { + rmSync(path) + } + } + // Keep the selected revision retryable until no reserved artifact can block JSON startup. + if (retainedArtifacts.includes(options.exportPath)) { + rmSync(options.exportPath) + } + bestEffortFsyncDirectorySync(dirname(options.databasePath)) + return { quarantine, removedDatabaseFiles } +} + +function readRecoveryExport(exportPath: string): string { + if (exportPath.length === 0 || exportPath.includes('\0')) { + throw new Error('Profile state recovery export path is invalid') + } + const rawJson = readFileSync(exportPath, 'utf8') + parseProfileStateRoot(rawJson) + return rawJson +} diff --git a/src/main/persistence/profile-state/profile-state-revision-readmission.ts b/src/main/persistence/profile-state/profile-state-revision-readmission.ts new file mode 100644 index 00000000000..8a3c3df338d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-revision-readmission.ts @@ -0,0 +1,20 @@ +import { openProfileStateDatabaseReadOnly } from './profile-state-database' +import { readProfileStateRevision } from './profile-state-documents' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' + +/** Reopening a live snapshot cannot adopt another writer's intervening revision. */ +export function assertProfileStateRevisionOnDisk( + databasePath: string, + profileId: string, + revision: number +): void { + const admitted = openProfileStateDatabaseReadOnly(databasePath, profileId) + try { + const actual = readProfileStateRevision(admitted.db) + if (actual !== revision) { + throw new ProfileStateRevisionConflictError(revision, actual) + } + } finally { + admitted.db.close() + } +} diff --git a/src/main/persistence/profile-state/profile-state-revision.ts b/src/main/persistence/profile-state/profile-state-revision.ts new file mode 100644 index 00000000000..6c64714606f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-revision.ts @@ -0,0 +1,31 @@ +import type Database from '../../sqlite/sync-database' +import { PROFILE_STATE_META_REVISION } from './profile-state-database-schema' +import { isRecord, ProfileStateDocumentCorruptionError } from './profile-state-document-validation' + +export function readProfileStateRevision(db: Database.Database): number { + const row = db + .prepare('SELECT value FROM profile_state_meta WHERE key = ?') + .get(PROFILE_STATE_META_REVISION) + if (!isRecord(row) || typeof row.value !== 'string') { + return 0 + } + const revision = Number(row.value) + if (!Number.isSafeInteger(revision) || revision < 0) { + throw new ProfileStateDocumentCorruptionError('Profile state revision is invalid') + } + return revision +} + +/** Unchanged domains may lag the profile revision, but cannot lead it. */ +export function assertProfileStateDocumentRevision( + revision: number, + profileRevision: number, + domain: string +): void { + if (revision > profileRevision) { + throw new ProfileStateDocumentCorruptionError( + `Profile state document revision ${revision} exceeds profile revision ${profileRevision}`, + domain + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-sqlite-authority.ts b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts new file mode 100644 index 00000000000..158357924d2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-sqlite-authority.ts @@ -0,0 +1,339 @@ +import { existsSync } from 'node:fs' +import type { + ProfileStateAuthority, + ProfileStateAuthorityInitialState, + ProfileStateDomainReplacement, + ProfileStateMaintenance +} from '../loading-store/profile-state-authority' +import { + importProfileStateJson, + readAcceptedProfileStateParsedSnapshot, + readProfileStateParsedSnapshot, + readProfileStateRevision, + readProfileStateSnapshot +} from './profile-state-documents' +import { + openWritableProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { writeProfileStateDomains } from './profile-state-domain-writes' +import { + prepareProfileStateDomainMutation, + validateProfileStateDomainTransaction +} from './profile-state-domain-write-validation' +import { + parseProfileStateRoot, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { assertProfileStateRevisionOnDisk } from './profile-state-revision-readmission' +import { + quarantineProfileStateDatabase, + type ProfileStateDatabaseQuarantine +} from './profile-state-database-quarantine' +import { + writeProfileStateAuthorityJsonExport, + writeProfileStateAuthorityCompatibilityExport, + writeProfileStateAuthorityCompatibilityExportAsync +} from './profile-state-authority-exports' +import { buildCompleteDocumentReplacements } from './profile-state-complete-replacements' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import type { ProfileStateWriterInitialization } from './profile-state-writer-protocol' + +/** + * Complete-document authority for the Store cutover. + * + * A Store authority keeps one writable handle for its lifetime so repeated + * domain commits do not pay connection and pragma setup costs. Store teardown + * calls {@link close} before profile switches or process removal. Complete + * payloads use one fenced domain transaction, so unchanged normalized rows are + * not rebuilt; Store callers can still opt into narrower dirty-domain writes. + */ +export class ProfileStateSqliteAuthority implements ProfileStateAuthority { + private retired = false + private observedRevision: number | undefined + private writableDatabase: ReturnType | undefined + private backupRotation: ProfileStateBackupRotation | undefined + + constructor( + private readonly databasePath: string, + private readonly profileId: string + ) {} + + retireForWorker(): ProfileStateWriterInitialization { + this.assertActive() + if (this.observedRevision === undefined || this.backupRotation !== undefined) { + throw new Error('Profile state worker handoff requires an admitted bootstrap authority') + } + const initialization = { + databasePath: this.databasePath, + profileId: this.profileId, + revision: this.observedRevision + } + this.close() + this.retired = true + return initialization + } + + initializeFromRevision(revision: number): void { + this.assertActive() + if (this.observedRevision !== undefined || !Number.isSafeInteger(revision) || revision < 0) { + throw new Error('Invalid profile state worker revision handoff') + } + assertProfileStateRevisionOnDisk(this.databasePath, this.profileId, revision) + this.observedRevision = revision + this.assertCurrentRevision() + } + + get revision(): number { + this.assertActive() + if (this.observedRevision === undefined) { + throw new Error('Profile state authority has no admitted revision') + } + return this.observedRevision + } + + /** Keep the startup payload and write fence on the same accepted revision. */ + readAcceptedState( + rawJson: string + ): ProfileStateAuthorityInitialState | undefined { + this.assertActive() + const opened = openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readAcceptedProfileStateParsedSnapshot(opened.db, rawJson) + if (snapshot === undefined) { + return undefined + } + return this.createInitialState(snapshot.revision, snapshot.state) + } finally { + opened.db.close() + } + } + + readInitialState(): ProfileStateAuthorityInitialState { + this.assertActive() + if (!this.writableDatabase && !existsSync(this.databasePath)) { + return this.createInitialState(0, undefined) + } + const opened = + this.writableDatabase ?? openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readProfileStateParsedSnapshot(opened.db) + return this.createInitialState( + snapshot.revision, + snapshot.revision === 0 ? undefined : snapshot.state + ) + } finally { + if (opened !== this.writableDatabase) { + opened.db.close() + } + } + } + + readSerializedState(): string | undefined { + this.assertActive() + if (!this.writableDatabase && !existsSync(this.databasePath)) { + // Treat an absent database as the empty revision so a concurrent creator + // cannot race this authority's first commit. + this.observedRevision = 0 + return undefined + } + const opened = + this.writableDatabase ?? openProfileStateDatabaseReadOnly(this.databasePath, this.profileId) + try { + const snapshot = readProfileStateSnapshot(opened.db) + this.observedRevision = snapshot.revision + return snapshot.revision === 0 ? undefined : snapshot.json + } finally { + if (opened !== this.writableDatabase) { + opened.db.close() + } + } + } + + writeSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[], + automationRunsAfter?: readonly unknown[] + ): void { + this.assertActive() + if (this.observedRevision === undefined) { + // Store normally reads before its first write. Establishing the revision + // here keeps direct authority callers fenced too. + this.readSerializedState() + } + const opened = this.openWritableDatabase() + this.observedRevision = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? 0, + replacements, + automationRunsAfter + }).revision + } + + assertCurrentRevision(): void { + const actualRevision = readProfileStateRevision(this.openWritableDatabase().db) + if (this.observedRevision === undefined || actualRevision !== this.observedRevision) { + throw new ProfileStateRevisionConflictError(this.observedRevision ?? 0, actualRevision) + } + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly unknown[] + ): void { + this.writeSerializedDomains(replacements, runs) + } + + writeSerializedState(payload: Buffer): void { + this.assertActive() + const serialized = payload.toString('utf8') + if (!Buffer.from(serialized, 'utf8').equals(payload)) { + throw new Error('Profile state payload is not valid UTF-8') + } + const parsed = parseProfileStateRoot(serialized) + this.writeCompleteSerializedDomains( + Object.entries(parsed).map(([domain, value]) => { + const payload = JSON.stringify(value) + if (payload === undefined) { + throw new Error(`Profile state domain payload is not serializable: ${domain}`) + } + return { domain, payload } + }) + ) + } + + writeCompleteSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): void { + this.assertActive() + if (this.observedRevision === undefined) { + this.readSerializedState() + } + if (!Array.isArray(replacements) || replacements.length > 0) { + validateProfileStateDomainTransaction({ + expectedRevision: this.observedRevision ?? 0, + replacements + }) + } + const opened = this.openWritableDatabase() + const currentRevision = readProfileStateRevision(opened.db) + const complete = buildCompleteDocumentReplacements(opened.db, replacements) + if (currentRevision === 0 || complete.length === 0) { + // Each fragment must be valid independently before it can become part of a root object. + for (const replacement of replacements) { + prepareProfileStateDomainMutation(replacement) + } + const rawJson = `{${replacements + .filter(({ payload }) => payload !== null) + .map(({ domain, payload }) => `${JSON.stringify(domain)}:${payload}`) + .join(',')}}` + this.observedRevision = importProfileStateJson(opened.db, rawJson, { + expectedRevision: this.observedRevision + }) + return + } + this.observedRevision = writeProfileStateDomains(opened.db, { + expectedRevision: this.observedRevision ?? currentRevision, + replacements: complete + }).revision + } + + scheduleBackup(): void { + this.assertActive() + this.backupRotation ??= new ProfileStateBackupRotation(this.databasePath, this.profileId) + this.backupRotation.schedule() + } + + async drainBackups(): Promise { + this.assertActive() + await this.backupRotation?.drain() + } + + writeJsonExport(targetPath: string): number { + return writeProfileStateAuthorityJsonExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + writeJsonCompatibilityExport(targetPath: string): number | undefined { + return writeProfileStateAuthorityCompatibilityExport( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return writeProfileStateAuthorityCompatibilityExportAsync( + this.openWritableDatabase().db, + targetPath, + this.observedRevision + ) + } + + quarantineDatabase(quarantineRoot?: string, reason?: string): ProfileStateDatabaseQuarantine { + this.assertActive() + this.backupRotation?.assertIdle() + this.close() + return quarantineProfileStateDatabase(this.databasePath, this.profileId, quarantineRoot, reason) + } + + close(): void { + this.assertActive() + this.backupRotation?.stop() + this.writableDatabase?.db.close() + this.writableDatabase = undefined + } + + async pauseForMaintenance(): Promise { + const revision = this.revision + await this.drainBackups() + this.close() + let consumed = false + return { + resume: async () => { + if (consumed) { + throw new Error('Profile maintenance resume has already been consumed') + } + consumed = true + assertProfileStateRevisionOnDisk(this.databasePath, this.profileId, revision) + this.assertCurrentRevision() + this.backupRotation = undefined + } + } + } + + private createInitialState( + revision: number, + value: Record | undefined + ): ProfileStateAuthorityInitialState { + let pending: { revision: number; value: Record | undefined } | undefined = { + revision, + value + } + this.observedRevision = revision + return { + authority: this, + takeParsedState: () => { + this.assertActive() + const snapshot = pending + if (snapshot === undefined) { + throw new Error('Profile state startup snapshot has already been consumed') + } + pending = undefined + this.observedRevision = snapshot.revision + return snapshot.value + } + } + } + + private assertActive(): void { + if (this.retired) { + throw new Error('Profile state authority was retired for worker ownership') + } + } + + private openWritableDatabase(): NonNullable { + this.assertActive() + this.writableDatabase ??= openWritableProfileStateDatabase(this.databasePath, this.profileId) + return this.writableDatabase + } +} diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts new file mode 100644 index 00000000000..2eb64a50d4d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -0,0 +1,329 @@ +import { build } from 'esbuild' +import type * as WorkerEntryPath from '../../worker-thread-entry-path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { + createProfileStateStoreForStartup, + desktopProfileStateAuthorityMode, + orcadProfileStateAuthorityMode, + ProfileStateStartupAuthorityError, + type ProfileStateStartupAuthorityOptions +} from './profile-state-startup-authority' +import { openProfileStateDatabase, profileStateDatabaseFile } from './profile-state-database' + +const bundle = vi.hoisted(() => ({ directory: '' })) +vi.mock('../../worker-thread-entry-path', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + resolveWorkerThreadEntryPath: ( + layout: Parameters[0], + name: string + ) => + name.startsWith('profile-state-') + ? join(bundle.directory, name) + : original.resolveWorkerThreadEntryPath(layout, name) + } +}) + +beforeAll(async () => { + bundle.directory = mkdtempSync(join(tmpdir(), 'orca-startup-writers-')) + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: bundle.directory, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) +afterAll(() => rmSync(bundle.directory, { recursive: true, force: true })) + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('profile-state startup authority boundary', () => { + it('establishes desktop SQLite by default while preserving orcad capability selection', () => { + expect(desktopProfileStateAuthorityMode()).toBe('sqlite-candidate') + expect(orcadProfileStateAuthorityMode(true)).toBe('sqlite-candidate') + expect(orcadProfileStateAuthorityMode(false)).toBe('legacy') + }) + + it('imports legacy desktop state by default and reopens acknowledged SQLite state', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-authority-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify(buildProfileStateCutoverFixture(directory))) + + const base: Omit = { + dataFile, + databaseFile, + profileId: 'startup-authority-test', + storageAuthority: 'desktop' + } + const legacy = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: 'legacy' + }) + expect(legacy.backend).toBe('json') + expect(existsSync(databaseFile)).toBe(false) + await legacy.store.freezeWritesAsync() + + const candidate = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(candidate.backend).toBe('sqlite') + expect(candidate.migrated).toBe(true) + candidate.store.updateSettings({ theme: 'dark' }) + await candidate.store.flushPendingOrThrowAsync() + await candidate.store.freezeWritesAsync() + rmSync(dataFile) + + const restarted = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.classification).toBe('sqlite-only') + expect(restarted.store.getSettings().theme).toBe('dark') + await restarted.store.freezeWritesAsync() + + const packaged = await createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode() + }) + expect(packaged.backend).toBe('sqlite') + expect(packaged.classification).toBe('sqlite-only') + expect(packaged.store.getSettings().theme).toBe('dark') + await packaged.store.freezeWritesAsync() + + await expect( + createProfileStateStoreForStartup({ + ...base, + runtime: 'desktop', + authorityMode: 'legacy' + }) + ).rejects.toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + + const orcad = await createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + expect(orcad.backend).toBe('sqlite') + await orcad.store.freezeWritesAsync() + + await expect( + createProfileStateStoreForStartup({ + ...base, + runtime: 'orcad', + authorityMode: 'legacy', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(expect.objectContaining({ code: 'profile-state-authority-required' })) + }) + + it('rejects an orcad candidate request on a Node 18-style host', async () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'node:sqlite') { + return undefined + } + return original(id) + }) + + await expect( + createProfileStateStoreForStartup({ + dataFile: join(tmpdir(), 'missing-orca-data.json'), + databaseFile: join(tmpdir(), 'missing-profile-state.db'), + profileId: 'startup-authority-node18-test', + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + }) + + it('creates an empty desktop profile directly in SQLite and preserves its first acknowledged write', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-default-empty-profile-')) + temporaryDirectories.push(directory) + const options: ProfileStateStartupAuthorityOptions = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-empty', + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode(), + storageAuthority: 'desktop' + } + const first = await createProfileStateStoreForStartup(options) + try { + expect(first.backend).toBe('sqlite') + expect(first.classification).toBe('neither') + first.store.updateSettings({ terminalFontSize: 19 }) + await first.store.flushPendingOrThrowAsync() + expect(existsSync(options.databaseFile)).toBe(true) + expect(existsSync(options.dataFile)).toBe(false) + } finally { + await first.store.freezeWritesAsync() + } + const reopened = await createProfileStateStoreForStartup(options) + try { + expect(reopened.backend).toBe('sqlite') + expect(reopened.migrated).toBe(false) + expect(reopened.store.getSettings().terminalFontSize).toBe(19) + } finally { + await reopened.store.freezeWritesAsync() + } + }) + + it.each(['corrupt', 'future-schema', 'ambiguous'] as const)( + 'refuses %s storage under the desktop default without replacing the authority', + async (kind) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-default-invalid-profile-')) + temporaryDirectories.push(directory) + const options: ProfileStateStartupAuthorityOptions = { + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'default-invalid', + runtime: 'desktop', + authorityMode: desktopProfileStateAuthorityMode(), + storageAuthority: 'desktop' + } + if (kind === 'corrupt') { + writeFileSync(options.databaseFile, 'not a SQLite database') + } else { + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + try { + if (kind === 'future-schema') { + opened.db.exec('PRAGMA user_version = 999') + } else { + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + } + } finally { + opened.db.close() + } + } + const before = readFileSync(options.databaseFile) + await expect(createProfileStateStoreForStartup(options)).rejects.toMatchObject({ + code: + kind === 'future-schema' + ? 'newer-schema' + : kind === 'ambiguous' + ? 'ambiguous-profile-state' + : 'profile-state-recovery-required' + }) + expect(readFileSync(options.databaseFile)).toEqual(before) + if (kind === 'ambiguous') { + expect(readFileSync(options.dataFile, 'utf8')).toBe('{"settings":{"theme":"dark"}}') + } + } + ) + + it('migrates a JSON-only orcad profile when the runtime exposes SQLite', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-capable-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + + const result = await createProfileStateStoreForStartup({ + dataFile, + databaseFile, + profileId: 'orcad-capable-test', + runtime: 'orcad', + authorityMode: orcadProfileStateAuthorityMode(true), + storageAuthority: 'runtime' + }) + + expect(result.backend).toBe('sqlite') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + await result.store.freezeWritesAsync() + }) + + it('keeps a runtime with SQLite but no native backup on JSON authority', async () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + return id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) + }) + expect(orcadProfileStateAuthorityMode()).toBe('legacy') + await expect( + createProfileStateStoreForStartup({ + dataFile: join(tmpdir(), 'missing-backup-orca-data.json'), + databaseFile: join(tmpdir(), 'missing-backup-profile-state.db'), + profileId: 'missing-native-backup', + runtime: 'orcad', + authorityMode: 'sqlite-candidate', + storageAuthority: 'runtime' + }) + ).rejects.toThrowError(ProfileStateStartupAuthorityError) + }) + + it('keeps a JSON-only orcad profile on JSON when the runtime lacks SQLite', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-orcad-node18-')) + temporaryDirectories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = profileStateDatabaseFile(directory) + writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + + const result = await createProfileStateStoreForStartup({ + dataFile, + databaseFile, + profileId: 'orcad-node18-test', + runtime: 'orcad', + authorityMode: orcadProfileStateAuthorityMode(false), + storageAuthority: 'runtime' + }) + + expect(result.backend).toBe('json') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + await result.store.freezeWritesAsync() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts new file mode 100644 index 00000000000..ef8d597d8de --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -0,0 +1,68 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import { isProfileStateSqliteAvailable } from './profile-state-database' +import type { + ProfileStateStoreAuthorityMode, + ProfileStateStoreFactoryOptions, + ProfileStateStoreFactoryResult +} from './profile-state-store-factory' +import { createLiveProfileStateStore } from './profile-state-live-store-factory' + +/** Runtime roots sharing the profile-state selection boundary. */ +export type ProfileStateStartupRuntime = 'desktop' | 'orcad' + +export type ProfileStateStartupAuthorityOptions = Omit< + ProfileStateStoreFactoryOptions, + 'authorityMode' | 'storageAuthority' +> & { + runtime: ProfileStateStartupRuntime + authorityMode: ProfileStateStoreAuthorityMode + storageAuthority: AutomationStorageAuthority + onPersistenceFailure?: (error: Error) => void +} + +export class ProfileStateStartupAuthorityError extends Error { + readonly code = 'orcad-sqlite-authority-unsupported' as const + + constructor() { + super('orcad requires node:sqlite database and backup support to select SQLite profile state') + this.name = 'ProfileStateStartupAuthorityError' + } +} + +/** Desktop startup establishes SQLite for legacy and empty profiles. */ +export function desktopProfileStateAuthorityMode(): ProfileStateStoreAuthorityMode { + return 'sqlite-candidate' +} + +/** + * Select orcad's authority from the runtime capability, without raising the + * Node floor shared by the relay and older remote hosts. + * + * A capable host may establish SQLite for a JSON-only profile. An older host + * stays on the legacy path, while the factory still refuses to open an + * already-established database it cannot validate. + */ +export function orcadProfileStateAuthorityMode( + sqliteAvailable = isProfileStateSqliteAvailable() +): ProfileStateStoreAuthorityMode { + return sqliteAvailable ? 'sqlite-candidate' : 'legacy' +} + +/** Construct both runtimes through the same validated authority boundary. */ +export async function createProfileStateStoreForStartup( + options: ProfileStateStartupAuthorityOptions +): Promise { + if ( + options.runtime === 'orcad' && + options.authorityMode === 'sqlite-candidate' && + !isProfileStateSqliteAvailable() + ) { + throw new ProfileStateStartupAuthorityError() + } + return createLiveProfileStateStore(options, { + onFailure: + options.onPersistenceFailure ?? + ((error) => + console.error('[persistence] Saving has stopped. Restart Orca before continuing.', error)) + }) +} diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.test.ts b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts new file mode 100644 index 00000000000..e82ee6ba56f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + ProfileStateAuthorityBootstrapError, + ProfileStateRecoveryRequiredError +} from './profile-state-authority-bootstrap' +import { + formatProfileStateStartupFailure, + profileStateStartupFailureClass +} from './profile-state-startup-failure' +import { ProfileStateWriterError } from './profile-state-writer-errors' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' + +describe('profile-state startup failure formatting', () => { + it('asks for a newer build instead of rollback when the schema is newer', () => { + const error = new ProfileStateDatabaseOpenError('newer-schema', 'Newer schema: 999') + expect(profileStateStartupFailureClass(error)).toBe('newer-schema') + const message = formatProfileStateStartupFailure(error) + expect(message).toContain('newer version of Orca') + expect(message).not.toContain('rollback') + expect(message).not.toContain('unreadable') + }) + + it('prints recovery paths and the offline rollback command', () => { + const error = new ProfileStateRecoveryRequiredError( + { + dataFile: '/profile/orca-data.json', + databaseFile: '/profile/profile-state.db', + profileId: 'profile-a' + }, + new Error('database is corrupt') + ) + + expect(formatProfileStateStartupFailure(error)).toContain( + 'orca profile state rollback --revision ' + ) + expect(formatProfileStateStartupFailure(error)).toContain('/profile/profile-state.db') + expect(profileStateStartupFailureClass(error)).toBe('recovery-required') + }) + + it('explains ambiguity and offers explicit inspection before choosing a recovery point', () => { + const message = formatProfileStateStartupFailure( + new ProfileStateAuthorityBootstrapError('both profile stores are present') + ) + + expect(message).toContain( + 'Orca cannot safely choose a profile-state authority: both profile stores are present' + ) + expect(message).toContain('neither is selected automatically') + expect(message).toContain('orca profile state rollback --current-json') + expect(message).toContain('does not merge') + expect(message).toContain('orca profile state exports') + expect(message).toContain('orca profile state rollback --backup ') + expect( + profileStateStartupFailureClass(new ProfileStateAuthorityBootstrapError('ambiguous')) + ).toBe('ambiguous-authority') + }) + + it('shows retained SQLite backups and their explicit recovery command without JSON exports', () => { + const message = formatProfileStateStartupFailure({ + code: 'profile-state-recovery-required', + dataFile: '/profile/orca-data.json', + databaseFile: '/profile/profile-state.db', + exportPaths: [], + backupPaths: ['/profile/profile-state.db.backup.latest.db'] + }) + expect(message).toContain( + 'Retained SQLite backups:\n /profile/profile-state.db.backup.latest.db' + ) + expect(message).toContain('orca profile state rollback --backup ') + }) + + it('leaves unrelated startup errors on the existing fatal path', () => { + expect(formatProfileStateStartupFailure(new Error('unrelated startup failure'))).toBeUndefined() + expect(profileStateStartupFailureClass(new Error('unrelated startup failure'))).toBeUndefined() + }) + + it('reports a missing writer without exposing its cause or suggesting database rollback', () => { + const error = new ProfileStateWriterError( + 'profile-state-writer-unavailable', + 'Profile state writer could not start', + 'known-failure', + { cause: new Error('private runtime path') } + ) + expect(profileStateStartupFailureClass(error)).toBe('writer-unavailable') + expect(formatProfileStateStartupFailure(error)).toBe( + 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + ) + }) + + it('reports an admission race as a conflicting writer', () => { + const error = new ProfileStateRevisionConflictError(2, 3) + expect(profileStateStartupFailureClass(error)).toBe('revision-conflict') + expect(formatProfileStateStartupFailure(error)).toContain('Close other Orca processes') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts new file mode 100644 index 00000000000..6b67b42c145 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -0,0 +1,127 @@ +type ProfileStateRecoveryFailure = { + code: 'profile-state-recovery-required' + dataFile: string + databaseFile: string + exportPaths: readonly string[] + backupPaths?: readonly string[] +} + +type ProfileStateAuthorityFailure = { + code: 'ambiguous-profile-state' + message: string +} + +export type ProfileStateStartupFailureClass = + | 'recovery-required' + | 'ambiguous-authority' + | 'revision-conflict' + | 'writer-unavailable' + | 'newer-schema' + | 'publication-unavailable' + +/** Return the bounded failure class used by startup breadcrumbs and support diagnostics. */ +export function profileStateStartupFailureClass( + error: unknown +): ProfileStateStartupFailureClass | undefined { + if (isProfileStateRecoveryFailure(error)) { + return 'recovery-required' + } + if (isProfileStateAuthorityFailure(error)) { + return 'ambiguous-authority' + } + if (isRecord(error) && typeof error.code === 'string') { + if (error.code === 'profile-state-publication-unavailable') { + return 'publication-unavailable' + } + if (error.code === 'newer-schema') { + return 'newer-schema' + } + if (error.code === 'profile-state-revision-conflict') { + return 'revision-conflict' + } + if (error.code.startsWith('profile-state-writer-')) { + return 'writer-unavailable' + } + } + return undefined +} + +/** Format profile-state startup failures without exposing a generic fatal-error path. */ +export function formatProfileStateStartupFailure(error: unknown): string | undefined { + if (isProfileStateRecoveryFailure(error)) { + const retainedBackups = !error.backupPaths?.length + ? ' (none found)' + : error.backupPaths.map((path) => ` ${path}`).join('\n') + const retainedExports = + error.exportPaths.length === 0 + ? ' (none found)' + : error.exportPaths.map((path) => ` ${path}`).join('\n') + return [ + 'Orca cannot safely open the active profile because its SQLite state is unreadable.', + `Legacy JSON path: ${error.dataFile}`, + `SQLite path: ${error.databaseFile}`, + 'Retained SQLite backups:', + retainedBackups, + 'Retained JSON exports:', + retainedExports, + 'Stop Orca, then run `orca profile state exports` and choose a known-good recovery artifact.', + 'Restore SQLite with `orca profile state rollback --backup `, or restore a JSON export with', + '`orca profile state rollback --revision `.' + ].join('\n') + } + + if (isProfileStateAuthorityFailure(error)) { + return [ + `Orca cannot safely choose a profile-state authority: ${error.message}`, + 'An older build may have changed the JSON file. Both copies are preserved; neither is selected automatically.', + 'Stop Orca and copy the profile directory before choosing which state to keep.', + 'To keep the current JSON, including edits from an older build, run `orca profile state rollback --current-json`. This archives both copies and does not merge their contents.', + 'Run `orca profile state exports` to inspect retained recovery points.', + 'Use `orca profile state rollback --backup ` or `orca profile state rollback --revision ` only after selecting the state you want to restore.' + ].join('\n') + } + + const failureClass = profileStateStartupFailureClass(error) + if ( + failureClass === 'publication-unavailable' && + isRecord(error) && + typeof error.message === 'string' + ) { + return error.message + } + if (failureClass === 'newer-schema') { + return 'This profile was saved by a newer version of Orca. Open it with that version or a newer release. Your profile has not been changed.' + } + if (failureClass === 'revision-conflict') { + return 'The active profile changed while Orca was starting. Close other Orca processes using this profile, then restart Orca.' + } + if (failureClass === 'writer-unavailable') { + return 'Orca could not start profile persistence. Restart Orca; if the problem continues, repair or reinstall this build.' + } + + return undefined +} + +function isProfileStateRecoveryFailure(error: unknown): error is ProfileStateRecoveryFailure { + return ( + isRecord(error) && + error.code === 'profile-state-recovery-required' && + typeof error.dataFile === 'string' && + typeof error.databaseFile === 'string' && + Array.isArray(error.exportPaths) && + error.exportPaths.every((path) => typeof path === 'string') && + (error.backupPaths === undefined || + (Array.isArray(error.backupPaths) && + error.backupPaths.every((path) => typeof path === 'string'))) + ) +} + +function isProfileStateAuthorityFailure(error: unknown): error is ProfileStateAuthorityFailure { + return ( + isRecord(error) && error.code === 'ambiguous-profile-state' && typeof error.message === 'string' + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts new file mode 100644 index 00000000000..44fb9ccf4bf --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { presentProfileStateStartupRecoveryDialog } from './profile-state-startup-recovery-dialog' + +describe('profile state startup recovery dialog', () => { + it('offers a copyable offline export command and does not mutate state', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 0 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'SQLite state is unreadable.\nSQLite path: /tmp/profile-state.db', + recoveryCommand: 'orca profile state exports', + showMessageBox, + copyToClipboard + }) + + expect(showMessageBox).toHaveBeenCalledWith({ + type: 'error', + buttons: ['Copy recovery command', 'Quit'], + defaultId: 1, + cancelId: 1, + title: 'Orca profile state cannot be opened', + message: 'Orca cannot safely open this profile.', + detail: + 'SQLite state is unreadable.\nSQLite path: /tmp/profile-state.db\n\nCopy the recovery command, then run it after Orca closes.' + }) + expect(copyToClipboard).toHaveBeenCalledWith('orca profile state exports') + }) + + it('leaves the clipboard untouched when the user quits', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 1 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'ambiguous profile state', + recoveryCommand: 'orca profile state exports', + showMessageBox, + copyToClipboard + }) + + expect(copyToClipboard).not.toHaveBeenCalled() + }) + + it('does not offer rollback for an authority ambiguity', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 0 }) + const copyToClipboard = vi.fn() + + await presentProfileStateStartupRecoveryDialog({ + message: 'both profile authorities are present', + showMessageBox, + copyToClipboard + }) + + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Quit'], + defaultId: 0, + cancelId: 0, + detail: + 'both profile authorities are present\n\nQuit Orca and resolve the profile-state authority before retrying.' + }) + ) + expect(copyToClipboard).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts new file mode 100644 index 00000000000..d89fbbb7729 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts @@ -0,0 +1,30 @@ +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' + +export type ProfileStateStartupRecoveryDialogDeps = { + message: string + recoveryCommand?: string + showMessageBox: (options: MessageBoxOptions) => Promise + copyToClipboard: (text: string) => void +} + +/** Present the only safe desktop recovery action without changing the failed authority. */ +export async function presentProfileStateStartupRecoveryDialog( + deps: ProfileStateStartupRecoveryDialogDeps +): Promise { + const buttons = deps.recoveryCommand ? ['Copy recovery command', 'Quit'] : ['Quit'] + const detail = deps.recoveryCommand + ? `${deps.message}\n\nCopy the recovery command, then run it after Orca closes.` + : `${deps.message}\n\nQuit Orca and resolve the profile-state authority before retrying.` + const { response } = await deps.showMessageBox({ + type: 'error', + buttons, + defaultId: buttons.length - 1, + cancelId: buttons.length - 1, + title: 'Orca profile state cannot be opened', + message: 'Orca cannot safely open this profile.', + detail + }) + if (response === 0 && deps.recoveryCommand) { + deps.copyToClipboard(deps.recoveryCommand) + } +} diff --git a/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts b/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts new file mode 100644 index 00000000000..e9fbd395bd0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-secrets.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getSecretStore, + hasSecretStore, + setSecretStore, + _resetSecretStoreForTests +} from '../../../shared/secret-store' +import { Store } from '../loading-store/store' +import * as storeDomains from '../loading-store/store-domain-composition' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' + +let keyState: 'available' | 'unavailable' | 'decrypt-fails' = 'available' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +let originalSecretStore: ReturnType | undefined +beforeEach(() => { + originalSecretStore = hasSecretStore() ? getSecretStore() : undefined + setSecretStore({ + isEncryptionAvailable: () => keyState !== 'unavailable', + encryptString: (value) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value) => { + if (keyState === 'decrypt-fails') { + throw new Error('keychain denied decryption') + } + return value.toString('utf8').slice('encrypted:'.length) + }, + describeProtectionGap: () => null + }) +}) + +const directories: string[] = [] +const stores: Store[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const store of stores) { + store.freezeWrites() + } + for (const store of stores.splice(0)) { + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + keyState = 'available' + if (originalSecretStore) { + setSecretStore(originalSecretStore) + } else { + _resetSecretStoreForTests() + } +}) + +const secrets = { + proxy: 'http://user:password@proxy.test:8080', + cookie: 'startup-secret-cookie', + kagi: 'https://kagi.com/session?t=startup-secret', + lease: `startup-owner-lease-${'x'.repeat(480)}` +} +const sealed = (value: string) => Buffer.from(`encrypted:${value}`, 'utf8').toString('base64') + +it.each([false, true])( + 'rejects reused input before Store context installation (secret=%s)', + (hasSecret) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-consumed-startup-')) + directories.push(directory) + const authority = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'once') + if (hasSecret) { + authority.writeSerializedState( + Buffer.from( + JSON.stringify({ + settings: { opencodeSessionCookie: sealed(secrets.cookie) } + }) + ) + ) + } + const options = { + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority, + initialAuthorityState: authority.readInitialState() + } + const store = new Store(options) + stores.push(store) + if (hasSecret) { + expect(store.getSettings().opencodeSessionCookie).toBe(secrets.cookie) + } + const install = vi.spyOn(storeDomains, 'installStoreDomainContexts') + + expect(() => new Store(options)).toThrow('already been consumed') + expect(install).not.toHaveBeenCalled() + } +) + +describe.each(['serialized', 'parsed'] as const)( + '%s startup secret retention', + (representation) => { + it.each(['available', 'unavailable', 'decrypt-fails'] as const)( + 'preserves every protected slot through an unrelated save when keys are %s', + async (failure) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-startup-secrets-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + function open() { + const authority = new ProfileStateSqliteAuthority(databaseFile, 'startup-secrets') + const store = new Store({ + dataFile, + profileStateAuthority: authority, + ...(representation === 'parsed' + ? { initialAuthorityState: authority.readInitialState() } + : {}) + }) + stores.push(store) + return { store, authority } + } + + const seeded = open() + seeded.store.updateSettings({ + httpProxyUrl: secrets.proxy, + opencodeSessionCookie: secrets.cookie + }) + seeded.store.updateUI({ browserKagiSessionLink: secrets.kagi }) + await seeded.store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'server-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: secrets.lease + }) + await seeded.store.flushAsync() + seeded.store.freezeWrites() + + keyState = failure + const reopened = open() + reopened.store.updateSettings({ terminalFontSize: 19 }) + await reopened.store.flushAsync() + const persisted = reopened.authority.readSerializedState() + expect(persisted).toBeDefined() + for (const value of Object.values(secrets)) { + expect(persisted).not.toContain(value) + } + expect(JSON.parse(persisted ?? 'null')).toMatchObject({ + settings: { + terminalFontSize: 19, + httpProxyUrl: sealed(secrets.proxy), + opencodeSessionCookie: sealed(secrets.cookie) + }, + ui: { browserKagiSessionLink: sealed(secrets.kagi) }, + sshPtyConsumerRecoveries: [{ ownerLease: sealed(secrets.lease) }] + }) + reopened.store.freezeWrites() + + keyState = 'available' + const restored = open().store + expect(restored.getSettings().httpProxyUrl).toBe(secrets.proxy) + expect(restored.getSettings().opencodeSessionCookie).toBe(secrets.cookie) + expect(restored.getUI().browserKagiSessionLink).toBe(secrets.kagi) + expect(restored.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(secrets.lease) + } + ) + } +) diff --git a/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts new file mode 100644 index 00000000000..af5043a7d23 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-startup-snapshot.test.ts @@ -0,0 +1,310 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProfileStateAuthority } from '../loading-store/profile-state-authority' +import { Store } from '../loading-store/store' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import * as profileStateDatabase from './profile-state-database' +import * as profileStateDocumentReader from './profile-state-document-reader' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { createProfileStateStore } from './profile-state-store-factory' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] +const authorities: ProfileStateAuthority[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + const openedStores = stores.splice(0) + const openedAuthorities = authorities.splice(0) + for (const store of openedStores) { + store.freezeWrites() + } + for (const authority of openedAuthorities) { + authority.close?.() + } + for (const store of openedStores) { + await store.flushAsync() + } + await Promise.all(openedAuthorities.map((authority) => authority.drainBackups?.())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createPaths(): { dataFile: string; databaseFile: string; profileId: string } { + const directory = mkdtempSync(join(tmpdir(), 'orca-profile-state-startup-snapshot-')) + directories.push(directory) + return { + dataFile: join(directory, 'orca-data.json'), + databaseFile: join(directory, 'profile-state.db'), + profileId: 'startup-snapshot-test' + } +} + +function createEstablishedProfile(keepJson: boolean): ReturnType { + const paths = createPaths() + const source = JSON.stringify({ settings: { theme: 'dark' }, futureDomain: { keep: true } }) + if (keepJson) { + writeFileSync(paths.dataFile, source) + } + const opened = profileStateDatabase.openProfileStateDatabase(paths.databaseFile, paths.profileId) + try { + importProfileStateJson(opened.db, source, { + acceptedLegacyJsonHash: hashProfileStateJson(source) + }) + } finally { + opened.db.close() + } + return paths +} + +describe('profile state startup snapshot handoff', () => { + it('keeps serialized-only authorities usable without SQLite capability', () => { + const paths = createPaths() + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? undefined : original(id) + ) + let serialized = '{"settings":{"theme":"dark"},"futureDomain":{"keep":true}}' + const authority: ProfileStateAuthority = { + readSerializedState: () => serialized, + writeSerializedState: (payload) => { + serialized = payload.toString('utf8') + } + } + const store = new Store({ dataFile: paths.dataFile, profileStateAuthority: authority }) + stores.push(store) + expect(store.getSettings().theme).toBe('dark') + expect(store.getSettings().terminalFontSize).toBeGreaterThan(0) + store.updateSettings({ theme: 'light' }) + store.flushOrThrow() + expect(JSON.parse(serialized)).toMatchObject({ + settings: { theme: 'light' }, + futureDomain: { keep: true } + }) + }) + + it.each([false, true])('reads established storage once with retained JSON=%s', (keepJson) => { + const paths = createEstablishedProfile(keepJson) + const open = vi.spyOn(profileStateDatabase, 'openProfileStateDatabaseReadOnly') + const documentRead = vi.spyOn(profileStateDocumentReader, 'readProfileStateDocuments') + const initialRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readInitialState') + const serializedRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readSerializedState') + const acceptedRead = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readAcceptedState') + + const result = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }) + stores.push(result.store) + + expect(open).toHaveBeenCalledTimes(1) + expect(documentRead).toHaveBeenCalledTimes(1) + expect(initialRead).toHaveBeenCalledTimes(keepJson ? 0 : 1) + expect(serializedRead).not.toHaveBeenCalled() + expect(acceptedRead).toHaveBeenCalledTimes(keepJson ? 1 : 0) + expect(result.store.getSettings().theme).toBe('dark') + expect(JSON.parse(result.store.prepareProfileStateExport().json)).toMatchObject({ + futureDomain: { keep: true } + }) + }) + + it('uses the validated empty snapshot without rereading SQLite or consulting JSON', () => { + const paths = createPaths() + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + opened.db.close() + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + expect(bootstrapped.initialState.serializedState).toBeUndefined() + const read = vi.spyOn(bootstrapped.authority, 'readSerializedState') + writeFileSync(paths.dataFile, '{"settings":{"opencodeSessionCookie":"stale-json"}}') + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: bootstrapped.authority, + initialAuthorityState: bootstrapped.initialState + }) + stores.push(store) + + expect(read).not.toHaveBeenCalled() + expect(store.getSettings().opencodeSessionCookie).not.toBe('stale-json') + store.updateSettings({ theme: 'dark' }) + store.flushOrThrow() + expect(readFileSync(paths.dataFile, 'utf8')).toContain('stale-json') + expect(bootstrapped.authority.readSerializedState()).toContain('"dark"') + }) + + it('consumes a parsed startup snapshot once, including an empty revision', () => { + const paths = createPaths() + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + opened.db.close() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(authority) + const initial = authority.readInitialState() + expect(initial.takeParsedState?.()).toBeUndefined() + expect(() => initial.takeParsedState?.()).toThrow('already been consumed') + }) + + it.each([false, true])( + 'fences a writer between bootstrap and Store construction with retained JSON=%s', + (keepJson) => { + const paths = createEstablishedProfile(keepJson) + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + const opened = profileStateDatabase.openProfileStateDatabase( + paths.databaseFile, + paths.profileId + ) + try { + importProfileStateJson(opened.db, '{"settings":{"theme":"light"}}', { + expectedRevision: 1 + }) + } finally { + opened.db.close() + } + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: bootstrapped.authority, + initialAuthorityState: bootstrapped.initialState + }) + stores.push(store) + + expect(store.getSettings().theme).toBe('dark') + store.updateSettings({ theme: 'system' }) + expect(() => store.flushOrThrow()).toThrowError( + expect.objectContaining({ + code: 'profile-state-revision-conflict', + expectedRevision: 1, + actualRevision: 2 + }) + ) + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"light"}}') + } + ) + + it('restores the captured fence after a same-authority refresh', () => { + const paths = createEstablishedProfile(true) + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(authority) + const initial = authority.readInitialState() + const writer = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(writer) + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}')) + expect(authority.readSerializedState()).toContain('"light"') + + const store = new Store({ + dataFile: paths.dataFile, + profileStateAuthority: authority, + initialAuthorityState: initial + }) + stores.push(store) + store.updateSettings({ theme: 'system' }) + expect(() => store.flushOrThrow()).toThrowError( + expect.objectContaining({ code: 'profile-state-revision-conflict', actualRevision: 2 }) + ) + expect(writer.readSerializedState()).toContain('"light"') + }) + + it('keeps direct authority reads fresh after bootstrap', () => { + const paths = createEstablishedProfile(true) + const bootstrapped = bootstrapProfileStateAuthority(paths) + if (bootstrapped.authority === undefined) { + throw new Error('Expected SQLite authority') + } + authorities.push(bootstrapped.authority) + const writer = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + authorities.push(writer) + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}')) + + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"light"}}') + writer.writeSerializedState(Buffer.from('{"settings":{"theme":"system"}}')) + expect(bootstrapped.authority.readSerializedState()).toBe('{"settings":{"theme":"system"}}') + }) + + it('decrypts and normalizes the startup snapshot through the existing Store loader', () => { + const paths = createPaths() + writeFileSync(paths.dataFile, '{"settings":{"theme":"dark"}}') + const first = createProfileStateStore({ ...paths, authorityMode: 'sqlite-candidate' }).store + stores.push(first) + first.updateSettings({ opencodeSessionCookie: 'startup-secret' }) + first.flushOrThrow() + first.freezeWrites() + + const reopened = createProfileStateStore({ + ...paths, + authorityMode: 'sqlite-established' + }).store + stores.push(reopened) + expect(reopened.getSettings().opencodeSessionCookie).toBe('startup-secret') + expect(reopened.getSettings().terminalFontSize).toBeGreaterThan(0) + reopened.updateSettings({ theme: 'light' }) + reopened.flushOrThrow() + expect(reopened.prepareProfileStateExport().json).not.toContain('startup-secret') + const again = createProfileStateStore({ ...paths, authorityMode: 'sqlite-established' }).store + stores.push(again) + expect(again.getSettings().opencodeSessionCookie).toBe('startup-secret') + expect(again.getSettings().theme).toBe('light') + }) + + it('rejects initial state without its authority or alongside migration input', () => { + const paths = createPaths() + const authority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + const otherAuthority = new ProfileStateSqliteAuthority(paths.databaseFile, paths.profileId) + const initialAuthorityState = { authority, serializedState: '{}' } + + expect(() => new Store({ dataFile: paths.dataFile, initialAuthorityState })).toThrow( + 'must belong to its profile-state authority' + ) + expect( + () => new Store({ profileStateAuthority: otherAuthority, initialAuthorityState }) + ).toThrow('must belong to its profile-state authority') + expect( + () => + new Store({ + profileStateAuthority: authority, + initialAuthorityState, + serializedState: '{}' + }) + ).toThrow('cannot use both a profile-state authority and serialized state') + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-storage-classification.ts b/src/main/persistence/profile-state/profile-state-storage-classification.ts new file mode 100644 index 00000000000..987f2a7bbe7 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-storage-classification.ts @@ -0,0 +1,32 @@ +import { existsSync } from 'node:fs' + +export type ProfileStateStorageClassification = 'json-only' | 'sqlite-only' | 'both' | 'neither' + +/** Primary first: recovery must remove it before any journal can be replayed. */ +export function profileStateDatabaseFiles(databaseFile: string): string[] { + return ['', '-wal', '-shm', '-journal'].map((suffix) => `${databaseFile}${suffix}`) +} + +/** Any surviving database-family file rules out a fresh profile or JSON fallback. */ +export function hasProfileStateDatabaseFiles(databaseFile: string): boolean { + return profileStateDatabaseFiles(databaseFile).some(existsSync) +} + +/** Classify storage without opening SQLite or changing either representation. */ +export function classifyProfileStateStorage( + dataFile: string, + databaseFile: string +): ProfileStateStorageClassification { + const hasJson = existsSync(dataFile) + const hasDatabase = hasProfileStateDatabaseFiles(databaseFile) + if (hasJson && hasDatabase) { + return 'both' + } + if (hasJson) { + return 'json-only' + } + if (hasDatabase) { + return 'sqlite-only' + } + return 'neither' +} diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts new file mode 100644 index 00000000000..20af26f1c1c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -0,0 +1,460 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isProfileStateSqliteAvailable, + openProfileStateDatabase, + profileStateDatabaseFile +} from './profile-state-database' +import { + createProfileStateStore as createProfileStateStoreImpl, + type ProfileStateStoreFactoryOptions +} from './profile-state-store-factory' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' +import { ProfileStateRecoveryRequiredError } from './profile-state-authority-bootstrap' +import { acquireProfileStateMaintenance } from './profile-state-access' +import { restoreProfileStateJsonExport } from './profile-state-recovery' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { LoadedStateParsingOperations } from '../loading-store/loaded-state-parsing' +import { hashProfileStateJson, importProfileStateJson } from './profile-state-documents' +import { PROFILE_STATE_DATABASE_SCHEMA_VERSION } from './profile-state-database-schema' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8').slice('encrypted:'.length) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +vi.mock('../../telemetry/client', () => ({ track: () => {} })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const temporaryDirectories: string[] = [] +const storesToClose: ReturnType['store'][] = [] + +function createProfileStateStore( + options: ProfileStateStoreFactoryOptions +): ReturnType { + const result = createProfileStateStoreImpl(options) + storesToClose.push(result.store) + return result +} + +afterEach(async () => { + vi.restoreAllMocks() + const openedStores = storesToClose.splice(0) + for (const store of openedStores) { + store.freezeWrites() + } + for (const store of openedStores) { + await store.flushAsync() + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createOptions(): ProfileStateStoreFactoryOptions & { directory: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-profile-state-store-factory-')) + temporaryDirectories.push(root) + const directory = join(root, 'profiles', 'profile-factory-test') + mkdirSync(directory, { recursive: true }) + return { + directory, + dataFile: join(directory, 'orca-data.json'), + databaseFile: profileStateDatabaseFile(directory), + profileId: 'profile-factory-test' + } +} + +describe('profile state Store authority factory', () => { + it.each([false, true])( + 'refuses future schemas without changing storage (retained JSON=%s)', + (keepJson) => { + const options = createOptions() + const source = '{"settings":{"theme":"dark"},"futureDomain":{"keep":true}}' + const { db } = openProfileStateDatabase(options.databaseFile, options.profileId) + try { + importProfileStateJson(db, source, { acceptedLegacyJsonHash: hashProfileStateJson(source) }) + db.pragma(`user_version = ${PROFILE_STATE_DATABASE_SCHEMA_VERSION + 1}`) + } finally { + db.close() + } + if (keepJson) { + writeFileSync(options.dataFile, source) + } + const databaseBefore = readFileSync(options.databaseFile) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + ).toThrow(expect.objectContaining({ code: 'newer-schema' })) + expect(readFileSync(options.databaseFile)).toEqual(databaseBefore) + expect(existsSync(options.dataFile)).toBe(keepJson) + if (keepJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + + it('closes a migrated authority when Store normalization fails', () => { + const options = createOptions() + writeFileSync(options.dataFile, '{"settings":{"theme":"dark"}}') + const close = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'close') + const readInitialState = vi.spyOn(ProfileStateSqliteAuthority.prototype, 'readInitialState') + vi.spyOn(LoadedStateParsingOperations.prototype, 'loadParsedFromAuthority').mockImplementation( + () => { + throw new Error('injected normalization failure') + } + ) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow('injected normalization failure') + expect(close).toHaveBeenCalledOnce() + expect(readInitialState).toHaveBeenCalledOnce() + const initial = readInitialState.mock.results[0] + if (initial?.type !== 'return') { + throw new Error('Expected a startup token before normalization') + } + expect(() => initial.value.takeParsedState?.()).toThrow('already been consumed') + }) + + it('uses a capability probe that remains false on a Node 18-style host', () => { + const original = process.getBuiltinModule + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'node:sqlite') { + return undefined + } + return original(id) + }) + + expect(isProfileStateSqliteAvailable()).toBe(false) + }) + + it('keeps legacy construction read/write-compatible and does not create SQLite', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore(options) + + expect(result.backend).toBe('json') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it.each([0, 1, 2, 3, 4])( + 'requires selected recovery when only legacy backup slot %s remains', + (slot) => { + const options = createOptions() + const backup = `${options.dataFile}.bak.${slot}` + const source = '{"settings":{"theme":"dark"},"futureDomain":{"preserved":true}}' + writeFileSync(backup, source) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow('restore a selected backup') + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(false) + expect(readFileSync(backup, 'utf8')).toBe(source) + + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(options.directory))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + profileId: options.profileId, + exportPath: backup + }) + const recovered = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + expect(recovered.backend).toBe('sqlite') + expect(JSON.parse(recovered.store.prepareProfileStateExport().json)).toMatchObject({ + settings: { theme: 'dark' }, + futureDomain: { preserved: true } + }) + expect(readFileSync(backup, 'utf8')).toBe(source) + } + ) + + it.each(['legacy', 'sqlite-established'] as const)( + 'preserves admitted %s recovery from a missing JSON primary', + (authorityMode) => { + const options = createOptions() + const source = '{"settings":{"theme":"dark"}}' + writeFileSync(`${options.dataFile}.bak.0`, source) + + const recovered = createProfileStateStore({ ...options, authorityMode }) + expect(recovered.backend).toBe('json') + expect(recovered.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + ) + + it('uses one explicit candidate policy to migrate JSON and construct a SQLite Store', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' }, unknownDomain: { keep: true } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(true) + expect(result.store.getSettings().theme).toBe('dark') + expect(existsSync(options.databaseFile)).toBe(true) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it('keeps an unmigrated JSON profile on the legacy path in established mode', () => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' } }) + writeFileSync(options.dataFile, source) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + + expect(result.backend).toBe('json') + expect(result.classification).toBe('json-only') + expect(result.migrated).toBe(false) + expect(existsSync(options.databaseFile)).toBe(false) + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + }) + + it('reuses the candidate authority after the legacy export is removed', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + rmSync(options.dataFile) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('reopens an established SQLite profile without the migration switch', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + rmSync(options.dataFile) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.migrated).toBe(false) + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('fails closed instead of falling back to a stale JSON mirror when SQLite is missing', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + expect(existsSync(exportPath)).toBe(true) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + ).toThrow(ProfileStateRecoveryRequiredError) + }) + + it('does not let candidate mode re-import JSON after SQLite was established', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow(ProfileStateRecoveryRequiredError) + }) + + it('reopens JSON after an explicit rollback removes the SQLite export marker', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + const migrated = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + const exportPath = profileStateJsonExportPath(options.dataFile, 1) + migrated.store.freezeWrites() + + restoreProfileStateJsonExport({ + maintenance: acquireProfileStateMaintenance(dirname(dirname(options.directory))), + databasePath: options.databaseFile, + dataFile: options.dataFile, + exportPath, + profileId: options.profileId + }) + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + expect(result.backend).toBe('json') + expect(result.store.getSettings().theme).toBe('dark') + }) + + it('keeps a migrated profile on SQLite across mutation and restart', () => { + const options = createOptions() + const source = JSON.stringify({ + settings: { theme: 'light' }, + unknownDomain: { preserved: true } + }) + writeFileSync(options.dataFile, source) + const first = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + first.store.updateSettings({ theme: 'dark' }) + first.store.flushOrThrow() + + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + rmSync(options.dataFile) + const restarted = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(restarted.store.getSettings().theme).toBe('dark') + expect(JSON.parse(restarted.store.prepareProfileStateExport().json)).toMatchObject({ + settings: { theme: 'dark' }, + unknownDomain: { preserved: true } + }) + }) + + it('initializes a valid empty SQLite profile instead of falling back to legacy JSON', () => { + const options = createOptions() + const opened = openProfileStateDatabase(options.databaseFile, options.profileId) + opened.db.close() + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('sqlite-only') + expect(result.store.getSettings()).toBeDefined() + result.store.updateSettings({ theme: 'dark' }) + result.store.flushOrThrow() + + const verifier = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + expect(verifier.store.getSettings().theme).toBe('dark') + }) + + it('establishes SQLite for a fresh candidate profile before its first write', () => { + const options = createOptions() + + const result = createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(result.backend).toBe('sqlite') + expect(result.classification).toBe('neither') + expect(result.migrated).toBe(false) + expect(existsSync(options.databaseFile)).toBe(true) + result.store.updateSettings({ theme: 'dark' }) + result.store.flushOrThrow() + result.store.freezeWrites() + + const restarted = createProfileStateStore({ ...options, authorityMode: 'sqlite-established' }) + expect(restarted.backend).toBe('sqlite') + expect(restarted.store.getSettings().theme).toBe('dark') + restarted.store.freezeWrites() + }) + + it('does not let legacy construction silently edit a SQLite profile', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + rmSync(options.dataFile) + + expect(() => createProfileStateStore(options)).toThrowError( + expect.objectContaining({ + code: 'profile-state-authority-required' + }) + ) + }) + + it('refuses a stale JSON mirror when candidate mode sees both files', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'light' } })) + + expect(() => + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + ).toThrow('matching acceptance marker') + }) + + it('fails closed for the Node 18 legacy path when migration leaves both authorities', () => { + const options = createOptions() + writeFileSync(options.dataFile, JSON.stringify({ settings: { theme: 'dark' } })) + createProfileStateStore({ ...options, authorityMode: 'sqlite-candidate' }) + + expect(() => createProfileStateStore(options)).toThrowError( + expect.objectContaining({ + code: 'profile-state-authority-required' + }) + ) + }) + + describe.each(['legacy', 'sqlite-established', 'sqlite-candidate'] as const)( + '%s missing database recovery', + (authorityMode) => { + it.each([ + { hasJson: true, artifact: 'export' }, + { hasJson: false, artifact: 'export' }, + { hasJson: true, artifact: 'backup' }, + { hasJson: false, artifact: 'backup' } + ])( + 'fails closed with a retained $artifact and JSON present=$hasJson', + ({ hasJson, artifact }) => { + const options = createOptions() + const source = JSON.stringify({ settings: { theme: 'dark' } }) + writeFileSync(options.dataFile, source) + const migrated = createProfileStateStore({ + ...options, + authorityMode: 'sqlite-candidate' + }) + migrated.store.freezeWrites() + rmSync(options.databaseFile) + if (artifact === 'backup') { + for (const path of profileStateJsonExportPaths(options.dataFile)) { + rmSync(path) + } + writeFileSync( + `${options.databaseFile}.backup.1789999999999-00000000-0000-4000-8000-000000000000.db`, + 'reserved recovery artifact' + ) + } + if (!hasJson) { + rmSync(options.dataFile) + } + + expect(() => createProfileStateStore({ ...options, authorityMode })).toThrowError( + ProfileStateRecoveryRequiredError + ) + expect(existsSync(options.databaseFile)).toBe(false) + expect(existsSync(options.dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(options.dataFile, 'utf8')).toBe(source) + } + } + ) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts new file mode 100644 index 00000000000..1b833acb5b8 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -0,0 +1,121 @@ +import type { AutomationStorageAuthority } from '../scheduling-automations/automation-owner-projection' +import type { ProfileStateAuthorityInitialState } from '../loading-store/profile-state-authority' +import type { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { Store } from '../loading-store/store' +import { bootstrapProfileStateAuthority } from './profile-state-authority-bootstrap' +import { + classifyProfileStateStorage, + type ProfileStateStorageClassification +} from './profile-state-storage-classification' +import { assertNoRetainedProfileStateExports } from './profile-state-recovery-required' + +/** Legacy refuses SQLite; candidate migrates; established only reopens existing SQLite. */ +export type ProfileStateStoreAuthorityMode = + | 'legacy' + | 'sqlite-candidate' + /** Use SQLite only when a prior migration already established it. */ + | 'sqlite-established' + +export type ProfileStateStoreFactoryOptions = { + dataFile: string + databaseFile: string + profileId: string + storageAuthority?: AutomationStorageAuthority + authorityMode?: ProfileStateStoreAuthorityMode +} + +export class ProfileStateStoreFactoryError extends Error { + readonly code = 'profile-state-authority-required' as const + + constructor(message: string) { + super(message) + this.name = 'ProfileStateStoreFactoryError' + } +} + +export type ProfileStateStoreFactoryResult = { + store: Store + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + migrated: boolean +} + +/** Centralize authority selection for desktop, orcad and offline callers. */ +export function createProfileStateStore( + options: ProfileStateStoreFactoryOptions +): ProfileStateStoreFactoryResult { + const { initialState, ...prepared } = prepareProfileStateStore(options) + try { + return { + ...prepared, + store: new Store({ + dataFile: options.dataFile, + storageAuthority: options.storageAuthority, + profileStateAuthority: initialState?.authority, + initialAuthorityState: initialState + }) + } + } catch (error) { + // Store construction owns the authority only after its load boundary succeeds. + initialState?.authority.close?.() + throw error + } +} + +type PreparedProfileStateStore = Omit & { + initialState?: ProfileStateAuthorityInitialState +} + +/** Admission is shared by live worker startup and synchronous offline operations. */ +export function prepareProfileStateStore( + options: ProfileStateStoreFactoryOptions +): PreparedProfileStateStore { + const authorityMode = options.authorityMode ?? 'legacy' + const classification = classifyProfileStateStorage(options.dataFile, options.databaseFile) + if (classification === 'json-only' || classification === 'neither') { + assertNoRetainedProfileStateExports(options) + } + if (authorityMode === 'legacy') { + if (classification === 'sqlite-only' || classification === 'both') { + throw new ProfileStateStoreFactoryError( + 'SQLite profile state is present; construct the Store with sqlite-candidate authority mode' + ) + } + return { + backend: 'json', + classification, + migrated: false + } + } + + if ( + authorityMode === 'sqlite-established' && + (classification === 'neither' || classification === 'json-only') + ) { + return { + backend: 'json', + classification, + migrated: false + } + } + + const bootstrap = bootstrapProfileStateAuthority({ + ...options, + allowEmptyProfileState: authorityMode === 'sqlite-candidate' + }) + const authority = bootstrap.authority + if (authority === undefined) { + return { + backend: 'json', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } + } + + return { + initialState: bootstrap.initialState, + backend: 'sqlite', + classification: bootstrap.classification, + migrated: bootstrap.migrated + } +} diff --git a/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts b/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts new file mode 100644 index 00000000000..537023ac45a --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-streaming-validation.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { verifyProfileStateSchema } from './profile-state-database-validation' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateParsedSnapshot, + readProfileStateSnapshot, + validateProfileStateSnapshot +} from './profile-state-documents' + +const fixtures: { directory: string; db: ReturnType['db'] }[] = [] +afterEach(() => { + for (const { directory, db } of fixtures.splice(0)) { + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(source = '{"automationRuns":[{"id":"a"},{"id":"b"}],"last":null}') { + const directory = mkdtempSync(join(tmpdir(), 'orca-streamed-profile-')) + const { db } = openProfileStateDatabase(join(directory, 'profile-state.db'), 'stream-test') + fixtures.push({ directory, db }) + importProfileStateJson(db, source) + return db +} + +const readers = [ + readProfileStateSnapshot, + readProfileStateParsedSnapshot, + validateProfileStateSnapshot +] + +describe('complete streaming profile validation', () => { + it('preserves history order and bytes with rowids at both SQLite integer limits', () => { + const source = + '{"before":null,"automationRuns":[{"id":"a","text":"雪\\ud800"},{"id":"b"}],"after":true}' + const db = fixture(source) + const before = readProfileStateSnapshot(db) + const update = db.prepare('UPDATE profile_state_automation_runs SET rowid = ? WHERE run_id = ?') + update.run(9223372036854775807n, 'a') + update.run(-9223372036854775808n, 'b') + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(readProfileStateParsedSnapshot(db)).toEqual({ + revision: before.revision, + state: JSON.parse(source) + }) + expect(validateProfileStateSnapshot(db)).toBe(before.revision) + expect(db.isTransaction).toBe(false) + }) + + it('accepts extra columns that shadow every SQLite rowid alias', () => { + const db = fixture() + const before = readProfileStateSnapshot(db) + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN rowid TEXT DEFAULT 'shadow'") + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN _rowid_ TEXT DEFAULT 'shadow'") + db.exec("ALTER TABLE profile_state_automation_runs ADD COLUMN oid TEXT DEFAULT 'shadow'") + verifyProfileStateSchema(db, 'stream-test') + expect(readProfileStateSnapshot(db)).toEqual(before) + expect(readProfileStateParsedSnapshot(db).state).toEqual(JSON.parse(before.json)) + expect(validateProfileStateSnapshot(db)).toBe(before.revision) + }) + + it.each([ + '{}', + '{"automationRuns":null}', + '{"automationRuns":[]}', + '{"automationRuns":{"future":true}}' + ])('validates history presence without constructing a returned state: %s', (source) => { + const db = fixture(source) + expect(validateProfileStateSnapshot(db)).toBe(readProfileStateSnapshot(db).revision) + expect(readProfileStateParsedSnapshot(db).state).toEqual(JSON.parse(source)) + }) + + it.each([ + [ + 'row hash', + "UPDATE profile_state_automation_runs SET content_hash = printf('%064d', 0) WHERE ordinal = 1" + ], + ['ordering', 'UPDATE profile_state_automation_runs SET ordinal = 0 WHERE ordinal = 1'], + ['row revision', 'UPDATE profile_state_automation_runs SET revision = 99 WHERE ordinal = 1'], + [ + 'row timestamp', + 'UPDATE profile_state_automation_runs SET updated_at = updated_at + 1 WHERE ordinal = 1' + ], + [ + 'aggregate hash', + "UPDATE profile_state_automation_runs_meta SET content_hash = printf('%064d', 0)" + ], + ['domain hash', "UPDATE profile_state_documents SET payload = 'true' WHERE domain = 'last'"], + ['domain revision', "UPDATE profile_state_documents SET revision = 99 WHERE domain = 'last'"] + ])('rejects late %s corruption in every representation', (_, sql) => { + const db = fixture() + db.exec(sql) + for (const read of readers) { + expect(() => read(db)).toThrow() + expect(db.isTransaction).toBe(false) + } + }) + + it.each([false, true])( + 'closes failed iterators while preserving caller transaction ownership (%s)', + (ownsTransaction) => { + const db = fixture() + if (ownsTransaction) { + db.exec('BEGIN') + } + const payload = 'null,"injected":true' + const update = db.prepare( + 'UPDATE profile_state_documents SET payload = ?, content_hash = ? WHERE domain = ?' + ) + update.run(payload, hashProfileStateJson(payload), 'last') + expect(() => validateProfileStateSnapshot(db)).toThrow('invalid JSON') + expect(db.isTransaction).toBe(ownsTransaction) + update.run('null', hashProfileStateJson('null'), 'last') + expect(validateProfileStateSnapshot(db)).toBe(1) + expect(db.isTransaction).toBe(ownsTransaction) + if (ownsTransaction) { + db.exec('ROLLBACK') + } + expect(readProfileStateSnapshot(db).json).toBe( + '{"automationRuns":[{"id":"a"},{"id":"b"}],"last":null}' + ) + } + ) + + it('closes the ordered-history iterator when the inner lookup fails validation', () => { + const db = fixture() + const update = db.prepare( + 'UPDATE profile_state_automation_runs SET payload = ?, content_hash = ? WHERE ordinal = 1' + ) + const invalid = '{"id":"wrong"}' + update.run(invalid, hashProfileStateJson(invalid)) + for (const read of readers) { + expect(() => read(db)).toThrow('identity is corrupt') + expect(db.isTransaction).toBe(false) + } + const valid = '{"id":"b"}' + update.run(valid, hashProfileStateJson(valid)) + expect(validateProfileStateSnapshot(db)).toBe(1) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.test.ts b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts new file mode 100644 index 00000000000..c74781ed96f --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-versioned-export.test.ts @@ -0,0 +1,113 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' +import { profileStateJsonExportPath } from './profile-state-export-path' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' + +vi.mock('node:fs', async (original) => ({ ...(await original()) })) + +const roots: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = fs.mkdtempSync(join(tmpdir(), 'orca-versioned-export-')) + roots.push(root) + const dataFile = join(root, 'orca-data.json') + const target = profileStateJsonExportPath(dataFile, 4) + const source = '{"settings":{"theme":"dark"}}' + const write = (revision = 4) => + writeVersionedProfileStateExport(dataFile, (staging) => { + writeFileDurableSync(durableWriteTempPath(staging), staging, source) + return revision + }) + return { root, target, source, write } +} + +describe('immutable versioned profile exports', () => { + it('publishes once and accepts repeated identical exports', () => { + const { root, target, source, write } = fixture() + expect(write()).toBe(4) + expect(write()).toBe(4) + expect(fs.readFileSync(target, 'utf8')).toBe(source) + expect(fs.readdirSync(root)).toEqual([basename(target)]) + }) + + it('retains only five successfully published exports and leaves unrelated entries alone', () => { + const { root, write } = fixture() + for (let revision = 1; revision <= 7; revision++) { + expect(write(revision)).toBe(revision) + } + const reservedDirectory = join(root, 'orca-data.json.sqlite-export.1.json') + fs.mkdirSync(reservedDirectory) + const unrelated = join(root, 'orca-data.json.sqlite-export.notes.json') + fs.writeFileSync(unrelated, 'keep') + write(8) + expect(fs.readdirSync(root).sort()).toEqual([ + 'orca-data.json.sqlite-export.1.json', + ...[4, 5, 6, 7, 8].map((revision) => `orca-data.json.sqlite-export.${revision}.json`), + 'orca-data.json.sqlite-export.notes.json' + ]) + expect(fs.lstatSync(reservedDirectory).isDirectory()).toBe(true) + }) + + it('preserves every recovery point when the next export fails', () => { + const { root, write } = fixture() + for (let revision = 1; revision <= 5; revision++) { + write(revision) + } + const retained = fs.readdirSync(root) + expect(() => + writeVersionedProfileStateExport(join(root, 'orca-data.json'), () => { + throw new Error('disk full') + }) + ).toThrow('disk full') + expect(fs.readdirSync(root)).toEqual(retained) + }) + + it('does not retain an empty profile export', () => { + const { root, write } = fixture() + expect(write(0)).toBeUndefined() + expect(fs.readdirSync(root)).toEqual([]) + }) + + it.each(['identical', 'divergent'] as const)( + 'preserves a concurrently published %s revision', + (kind) => { + const { root, target, source, write } = fixture() + const competing = kind === 'identical' ? source : '{"settings":{"theme":"light"}}' + let raced = false + const publishCompetitor = (path: fs.PathLike) => { + if (!raced && path === target) { + raced = true + fs.writeFileSync(target, competing) + } + } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + publishCompetitor(to) + rename(from, to) + }) + const link = fs.linkSync + vi.spyOn(fs, 'linkSync').mockImplementation((from, to) => { + publishCompetitor(to) + link(from, to) + }) + + if (kind === 'identical') { + expect(write()).toBe(4) + } else { + expect(write).toThrow('already exists with different content') + } + expect(raced).toBe(true) + expect(fs.readFileSync(target, 'utf8')).toBe(competing) + expect(fs.readdirSync(root)).toEqual([basename(target)]) + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-versioned-export.ts b/src/main/persistence/profile-state/profile-state-versioned-export.ts new file mode 100644 index 00000000000..70cbee1d72e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-versioned-export.ts @@ -0,0 +1,52 @@ +import { lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../../shared/secure-file' +import { durableWriteTempPath, publishFileDurableSync } from '../../durable-file-write' +import { + profileStateJsonExportPath, + profileStateJsonExportPaths +} from './profile-state-export-path' + +/** An existing revision must never be replaced with different content. */ +export function writeVersionedProfileStateExport( + dataFile: string, + writeExport: (targetPath: string) => number +): number | undefined { + const stagingPath = durableWriteTempPath(`${dataFile}.sqlite-export.pending`) + try { + const revision = writeExport(stagingPath) + if (revision === 0) { + return undefined + } + const targetPath = profileStateJsonExportPath(dataFile, revision) + mkdirSync(dirname(targetPath), { recursive: true }) + if (!publishFileDurableSync(stagingPath, targetPath)) { + const staged = readFileSync(stagingPath) + const existing = readFileSync(targetPath) + if (!staged.equals(existing)) { + throw new Error( + `Profile state export revision ${revision} already exists with different content` + ) + } + fsyncFileSync(targetPath) + bestEffortFsyncDirectorySync(dirname(targetPath)) + } + pruneProfileStateJsonExports(dataFile) + return revision + } finally { + rmSync(stagingPath, { force: true }) + } +} + +function pruneProfileStateJsonExports(dataFile: string): void { + try { + const regularExports = profileStateJsonExportPaths(dataFile).filter((path) => + lstatSync(path).isFile() + ) + for (const path of regularExports.slice(5)) { + rmSync(path, { force: true }) + } + } catch (error) { + console.warn('[persistence] Failed to prune retained JSON exports:', error) + } +} diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.test.ts b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts new file mode 100644 index 00000000000..cc42f350581 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.test.ts @@ -0,0 +1,76 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from '../loading-store/profile-state-maintenance-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('worker authority close admission', () => { + it('reports a failed maintenance resume without accepting a changed database', async () => { + const notify = vi.fn() + const { authority, peer } = await createWorkerMaintenanceFixture(undefined, notify) + const maintenance = await authority.pauseForMaintenance() + const other = peer() + other.writeSerializedDomains([{ domain: 'ui', payload: '{"external":true}' }]) + other.close() + + await expect(maintenance.resume()).rejects.toMatchObject({ + code: 'profile-state-revision-conflict' + }) + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'profile-state-revision-conflict' }) + ) + expect(() => authority.assertWritable()).toThrow() + }) + + it('refuses new commands while its existing backup drains', async () => { + const { authority, directory, readState } = await createWorkerMaintenanceFixture() + const before = readState() + const release = maintenanceBarrier() + vi.spyOn(ProfileStateBackupRotation.prototype, 'drain').mockReturnValueOnce(release.promise) + const closeWriter = vi.spyOn(ProfileStateWriteWorkerClient.prototype, 'close') + + const closing = authority.close() + expect(authority.close()).toBe(closing) + expect(closeWriter).toHaveBeenCalledOnce() + expect(() => authority.assertWritable()).toThrow('closing') + await expect( + authority.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-closed', outcome: 'known-failure' }) + await expect( + authority.writeJsonExport(join(directory, 'late-export.json')) + ).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + + release.resolve() + await closing + expect(closeWriter).toHaveBeenCalledOnce() + expect(readState()).toEqual(before) + }) + + it('finishes an accepted write before releasing its database', async () => { + const { authority, readState } = await createWorkerMaintenanceFixture() + const accepted = authority.writeSerializedDomains([ + { domain: 'ui', payload: '{"accepted":true}' } + ]) + const closing = authority.close() + await expect(accepted).resolves.toBeUndefined() + await closing + expect(readState().ui).toEqual({ accepted: true }) + await expect(authority.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-worker-authority.ts b/src/main/persistence/profile-state/profile-state-worker-authority.ts new file mode 100644 index 00000000000..efd93652c17 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-authority.ts @@ -0,0 +1,168 @@ +import type { AutomationRun } from '../../../shared/automations-types' +import type { + AsyncProfileStateAuthority, + ProfileStateDomainReplacement, + ProfileStateMaintenance +} from '../loading-store/profile-state-authority' +import { ProfileStateBackupRotation } from './profile-state-backup-rotation' +import { runProfileStateBackupWorker } from './profile-state-backup-worker' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' +import { + ProfileStateWriteWorkerClient, + type ProfileStateWriterInitialization +} from './profile-state-writer-worker-client' + +/** Main owns backup scheduling; the persistent worker owns every live SQL command. */ +export class ProfileStateWorkerAuthority implements AsyncProfileStateAuthority { + readonly asynchronous = true + private writer: ProfileStateWriteWorkerClient + private backups: ProfileStateBackupRotation + private closing: Promise | undefined + + constructor( + private readonly initialization: ProfileStateWriterInitialization, + private readonly options: { + workerPath?: string + backupWorkerPath?: string + onFailure?: (error: Error) => void + } = {} + ) { + this.writer = new ProfileStateWriteWorkerClient(initialization, options) + this.backups = this.createBackups() + } + + get ready(): Promise { + return this.writer.ready + } + + readSerializedState(): never { + throw new Error('Live profile state requires its admitted startup snapshot') + } + + assertWritable(): void { + this.writer.assertWritable() + } + + abort(): Promise { + return this.writer.abort() + } + + assertCurrentRevision(): Promise { + return this.writer.assertCurrentRevision().then(() => {}) + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise { + return this.writer.writeSerializedDomains(replacements).then(() => {}) + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly AutomationRun[] + ): Promise { + return this.writer.writeSerializedAutomationRuns(replacements, runs).then(() => {}) + } + + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise { + return this.writer.writeCompleteSerializedDomains(replacements).then(() => {}) + } + + writeSerializedState(payload: Buffer): Promise { + return this.writer.writeSerializedState(payload).then(() => {}) + } + + writeJsonExport(targetPath: string): Promise { + return this.writer.writeJsonExport(targetPath) + } + + writeLatestJsonExport(dataFile: string): Promise { + return this.writer.writeLatestJsonExport(dataFile) + } + + writeJsonCompatibilityExport(targetPath: string): Promise { + return this.writer.writeJsonCompatibilityExportAsync(targetPath) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return this.writeJsonCompatibilityExport(targetPath) + } + + scheduleBackup(): void { + if (!this.closing) { + this.backups.schedule() + } + } + + drainBackups(cancel = false): Promise { + if (cancel) { + this.backups.stop() + } + return this.backups.drain() + } + + close(): Promise { + this.writer.stopAdmission() + this.closing ??= this.finishClose() + return this.closing + } + + async pauseForMaintenance(): Promise { + this.assertWritable() + const writer = this.writer + await this.close() + const revision = writer.acknowledgedRevision + let consumed = false + return { + resume: async () => { + if (consumed || this.writer !== writer) { + throw new Error('Profile maintenance resume has already been consumed') + } + consumed = true + this.writer = new ProfileStateWriteWorkerClient( + { ...this.initialization, revision }, + { ...this.options, reportInitializationFailure: true } + ) + this.backups = this.createBackups() + this.closing = undefined + try { + await this.writer.ready + this.assertWritable() + } catch (error) { + await this.close() + throw error + } + } + } + } + + async quarantineDatabase(quarantineRoot?: string, reason?: string) { + await this.close() + return quarantineProfileStateDatabase( + this.initialization.databasePath, + this.initialization.profileId, + quarantineRoot, + reason + ) + } + + private async finishClose(): Promise { + this.backups.stop() + const settled = await Promise.allSettled([this.backups.drain(), this.writer.close()]) + for (const result of settled) { + if (result.status === 'rejected') { + throw result.reason + } + } + } + + private createBackups(): ProfileStateBackupRotation { + return new ProfileStateBackupRotation( + this.initialization.databasePath, + this.initialization.profileId, + Date.now, + (job, signal) => + runProfileStateBackupWorker(job, { workerPath: this.options.backupWorkerPath, signal }) + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts new file mode 100644 index 00000000000..56e21da43e5 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts @@ -0,0 +1,177 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' +import { openProfileStateDatabase } from './profile-state-database' +import { + hashProfileStateJson, + importProfileStateJson, + readProfileStateJsonAcceptance +} from './profile-state-documents' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +let bundleRoot: string +let workerPath: string +const fixtures: { root: string; client: ProfileStateWriteWorkerClient }[] = [] +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-export-worker-bundle-')) + workerPath = join(bundleRoot, 'writer.cjs') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) +afterEach(async () => { + for (const { root, client } of fixtures.splice(0)) { + await client.close().catch(() => {}) + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +async function fixture(fault: 'rename' | 'commit' | 'exit' | 'read-rollback' | 'none') { + const root = mkdtempSync(join(tmpdir(), 'orca-export-worker-')) + const databasePath = join(root, 'profile-state.db') + const dataFile = join(root, 'orca-data.json') + const profileId = 'export-failure' + const original = '{"settings":{"theme":"light"}}' + writeFileSync(dataFile, original) + const withDatabase = ( + run: (db: ReturnType['db']) => T + ): T => { + const { db } = openProfileStateDatabase(databasePath, profileId) + try { + return run(db) + } finally { + db.close() + } + } + withDatabase((db) => + importProfileStateJson(db, original, { + acceptedLegacyJsonHash: hashProfileStateJson(original) + }) + ) + const bootstrap = new ProfileStateSqliteAuthority(databasePath, profileId) + bootstrap.readSerializedState() + bootstrap.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + const wrapper = join(root, 'fault-worker.cjs') + const faultSource = + fault === 'commit' || fault === 'read-rollback' + ? ` + const DatabaseSync = process.versions.bun + ? require('bun:sqlite').Database + : require('node:sqlite').DatabaseSync + const { existsSync } = require('node:fs') + let writing = false + const exec = DatabaseSync.prototype.exec + DatabaseSync.prototype.exec = function(sql) { + if (${JSON.stringify(fault)} === 'read-rollback' && + existsSync(${JSON.stringify(join(root, 'armed'))}) && + (sql === 'COMMIT' || sql === 'ROLLBACK')) { + throw new Error('injected read transaction release failure') + } + const result = exec.call(this, sql) + if (sql === 'BEGIN IMMEDIATE' && existsSync(${JSON.stringify(join(root, 'armed'))})) writing = true + if (writing && sql === 'COMMIT') throw new Error('injected post-COMMIT failure') + return result + } + ` + : fault === 'none' + ? '' + : ` + const fs = require('node:fs/promises') + const rename = fs.rename + let injected = false + fs.rename = async function(source, target) { + if (!injected && target === ${JSON.stringify(dataFile)}) { + injected = true + if (${JSON.stringify(fault)} === 'exit') { + await rename(source, target) + process.exit(19) + } + throw Object.assign(new Error('injected publication failure'), { code: 'ENOSPC' }) + } + return rename(source, target) + } + ` + writeFileSync(wrapper, `${faultSource}\nrequire(${JSON.stringify(workerPath)})`) + const onFailure = vi.fn() + const client = new ProfileStateWriteWorkerClient(bootstrap.retireForWorker(), { + workerPath: wrapper, + onFailure + }) + fixtures.push({ root, client }) + await client.ready + writeFileSync(join(root, 'armed'), '') + const readAccepted = () => { + const reader = new ProfileStateSqliteAuthority(databasePath, profileId) + try { + return reader.readAcceptedState(readFileSync(dataFile, 'utf8'))?.takeParsedState?.() + } finally { + reader.close() + } + } + return { client, dataFile, original, withDatabase, readAccepted, onFailure } +} + +it.each(['rename', 'staging', 'promotion'] as const)( + 'keeps the real worker usable after known compatibility %s failure', + async (phase) => { + const f = await fixture(phase === 'rename' ? phase : 'none') + if (phase !== 'rename') { + f.withDatabase((db) => + db.exec(` + CREATE TRIGGER reject_acceptance BEFORE INSERT ON profile_state_meta + WHEN NEW.key = 'legacy_json_acceptance' + ${phase === 'promotion' ? "AND json_type(NEW.value, '$.pending') IS NULL" : ''} + BEGIN SELECT RAISE(ABORT, 'injected marker failure'); END + `) + ) + } + await expect(f.client.writeJsonCompatibilityExportAsync(f.dataFile)).rejects.toMatchObject({ + outcome: 'known-failure' + }) + expect(JSON.parse(readFileSync(f.dataFile, 'utf8')).settings.theme).toBe( + phase === 'promotion' ? 'dark' : 'light' + ) + expect(f.readAccepted()).toEqual({ settings: { theme: 'dark' } }) + expect(f.onFailure).not.toHaveBeenCalled() + expect(await f.client.assertCurrentRevision()).toBe(2) + f.withDatabase((db) => db.exec('DROP TRIGGER IF EXISTS reject_acceptance')) + await f.client.writeSerializedDomains([{ domain: 'settings', payload: '{"theme":"system"}' }]) + await f.client.writeJsonCompatibilityExportAsync(f.dataFile) + expect(f.readAccepted()).toEqual({ settings: { theme: 'system' } }) + expect(f.withDatabase(readProfileStateJsonAcceptance)).toEqual({ + jsonHash: hashProfileStateJson(readFileSync(f.dataFile, 'utf8')), + acceptedRevision: 3 + }) + } +) + +it.each(['commit', 'exit', 'read-rollback'] as const)( + 'keeps an unacknowledged export %s fenced even when its files remain recoverable', + async (fault) => { + const f = await fixture(fault) + const failure = await f.client + .writeJsonCompatibilityExportAsync(f.dataFile) + .catch((error: unknown) => error) + expect(failure).toMatchObject({ outcome: 'indeterminate' }) + await expect( + f.client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toBe(failure) + await f.client.close() + expect(f.onFailure).toHaveBeenCalledExactlyOnceWith(failure) + expect(f.readAccepted()).toEqual({ settings: { theme: 'dark' } }) + expect(JSON.parse(readFileSync(f.dataFile, 'utf8')).settings.theme).toBe( + fault === 'exit' ? 'dark' : 'light' + ) + } +) diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts new file mode 100644 index 00000000000..2f4dade139c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + ProfileStateIndeterminateWriteError, + withProfileStateWriteTransaction +} from './profile-state-write-transaction' + +describe('profile state write transaction ownership', () => { + it('preserves SQLITE_FULL after SQLite rolls back the transaction itself', () => { + const db = new Database(':memory:') + try { + db.exec('PRAGMA page_size=512; CREATE TABLE writes (data BLOB); PRAGMA max_page_count=2') + const rollback = vi.spyOn(db, 'exec') + expect(() => + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO writes VALUES (zeroblob(4096))') + }) + ).toThrow(/database or disk is full/) + expect(db.isTransaction).toBe(false) + expect(rollback).not.toHaveBeenCalledWith('ROLLBACK') + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => db.exec("INSERT INTO writes VALUES ('small')")) + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + + it('rolls back a failed deferred commit and leaves the connection usable', () => { + const db = new Database(':memory:') + try { + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child (parent_id INTEGER REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); + `) + expect(() => + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO child VALUES (1)') + }) + ).toThrow(/FOREIGN KEY/) + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => { + db.exec('INSERT INTO parent VALUES (1); INSERT INTO child VALUES (1)') + }) + expect(db.prepare('SELECT COUNT(*) AS count FROM child').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + + it('leaves a caller-owned transaction intact when a nested write is refused', () => { + const db = new Database(':memory:') + try { + db.exec('CREATE TABLE pending (id INTEGER); BEGIN; INSERT INTO pending VALUES (1)') + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('DELETE FROM pending')) + ).toThrow(/idle database/) + expect(db.isTransaction).toBe(true) + db.exec('COMMIT') + expect(db.prepare('SELECT id FROM pending').get()).toMatchObject({ id: 1 }) + } finally { + db.close() + } + }) + it.each([false, true])( + 'reports failed rollback as indeterminate after commit=%s', + (commitFirst) => { + const db = new Database(':memory:') + db.exec('CREATE TABLE writes (id INTEGER)') + const exec = db.exec.bind(db) + const injected = vi.spyOn(db, 'exec').mockImplementation((sql) => { + if (sql === 'ROLLBACK') { + throw new Error('injected rollback failure') + } + if (sql === 'COMMIT') { + if (commitFirst) { + exec(sql) + } + throw new Error('injected commit failure') + } + exec(sql) + }) + try { + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('INSERT INTO writes VALUES (1)')) + ).toThrow(ProfileStateIndeterminateWriteError) + expect(db.isTransaction).toBe(!commitFirst) + if (db.isTransaction) { + exec('ROLLBACK') + } + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ + count: commitFirst ? 1 : 0 + }) + } finally { + injected.mockRestore() + db.close() + } + } + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.ts b/src/main/persistence/profile-state/profile-state-write-transaction.ts new file mode 100644 index 00000000000..1dbf10d5bed --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -0,0 +1,42 @@ +import type Database from '../../sqlite/sync-database' + +export class ProfileStateIndeterminateWriteError extends Error { + readonly code = 'profile-state-write-indeterminate' as const + + constructor( + cause: unknown, + readonly rollbackError: unknown + ) { + super('Profile state write failed without a confirmed rollback', { cause }) + this.name = 'ProfileStateIndeterminateWriteError' + } +} + +/** Own the write transaction; joining a caller's transaction would weaken its revision fence. */ +export function withProfileStateWriteTransaction(db: Database.Database, write: () => T): T { + if (db.isTransaction) { + throw new Error('Profile state write requires an idle database connection') + } + db.exec('BEGIN IMMEDIATE') + let committing = false + try { + const result = write() + committing = true + db.exec('COMMIT') + return result + } catch (error) { + if (!db.isTransaction) { + // SQLite can roll back a failed statement itself; a failed COMMIT is ambiguous. + if (committing) { + throw new ProfileStateIndeterminateWriteError(error, undefined) + } + throw error + } + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + throw new ProfileStateIndeterminateWriteError(error, rollbackError) + } + throw error + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-connection.ts b/src/main/persistence/profile-state/profile-state-writer-connection.ts new file mode 100644 index 00000000000..bd956f48592 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-connection.ts @@ -0,0 +1,302 @@ +import { Worker } from 'node:worker_threads' +import { resolveProfileStateWriterWorkerPath } from './profile-state-writer-worker-path' +import { + createProfileStateWriterRequest, + isExpectedProfileStateWriterSuccess, + type PendingProfileStateWriterRequest, + type SuccessfulProfileStateWriterResponse +} from './profile-state-writer-request' +import { + decodeProfileStateWriterError, + ProfileStateWriterError +} from './profile-state-writer-errors' +import { + isProfileStateWriterResponse, + type ProfileStateWriterCommand, + type ProfileStateWriterInitialization +} from './profile-state-writer-protocol' + +const REQUEST_TIMEOUT_MS = 30_000 + +/** One materialized command; Store owns coalescing and never queues snapshots here. */ +export class ProfileStateWriterConnection { + readonly ready: Promise + private worker: Worker | undefined + private active: PendingProfileStateWriterRequest | undefined + private nextId = 1 + private failure: Error | undefined + private draining = false + private closePromise: Promise | undefined + private readonly exit = Promise.withResolvers() + private didExit = false + private closeAcknowledged = false + private readonly timeoutMs: number + private readonly initialRevision: number + private latestRevision: number | undefined + + constructor( + initialization: ProfileStateWriterInitialization, + private readonly options: { + workerPath?: string + timeoutMs?: number + onFailure?: (error: Error) => void + reportInitializationFailure?: boolean + } = {} + ) { + this.initialRevision = initialization.revision + this.timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS + const pending = this.createPending(0, 'initialize') + this.active = pending + this.ready = pending.promise.then(() => {}) + // Initialization failures remain observable through ready without an unhandled rejection. + void this.ready.catch(() => {}) + try { + const worker = new Worker(options.workerPath ?? resolveProfileStateWriterWorkerPath(), { + workerData: initialization, + execArgv: [] + }) + this.worker = worker + worker.on('message', (response: unknown) => this.receive(response)) + worker.on('error', (cause: Error) => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-exit', + 'Profile state writer failed', + this.dispatchedOutcome(), + { cause } + ) + ) + ) + worker.once('exit', (code) => { + this.didExit = true + this.exit.resolve() + if (!this.closeAcknowledged || code !== 0) { + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-exit', + `Profile state writer exited without a completed close (${code})`, + this.dispatchedOutcome() + ) + ) + } + }) + } catch (cause) { + this.didExit = true + this.exit.resolve() + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-unavailable', + 'Profile state writer could not start', + 'known-failure', + { cause } + ) + ) + } + } + + /** Abandoning an active wait cannot establish whether SQLite committed. */ + async abort(): Promise { + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-aborted', + 'Profile state writer was aborted', + this.dispatchedOutcome() + ) + ) + await this.exit.promise + } + + stopAdmission(): void { + this.draining = true + } + + close(): Promise { + this.stopAdmission() + this.closePromise ??= this.finishClose() + return this.closePromise + } + + get acknowledgedRevision(): number { + if (this.failure) { + throw this.failure + } + if (this.latestRevision === undefined) { + throw new Error('Profile state writer has no acknowledged revision') + } + return this.latestRevision + } + + private async finishClose(): Promise { + await this.active?.promise.catch(() => {}) + if (!this.failure && !this.didExit) { + try { + await this.dispatch({ command: 'close' }) + } finally { + const timer = setTimeout( + () => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-close-timeout', + 'Profile state writer did not exit after close', + 'indeterminate' + ) + ), + this.timeoutMs + ) + try { + await this.exit.promise + } finally { + clearTimeout(timer) + } + } + if (this.failure) { + throw this.failure + } + } else { + await this.exit.promise + } + } + + protected assertDispatchable(closing = false): void { + if (this.failure) { + throw this.failure + } + if (this.didExit || (this.draining && !closing)) { + throw new ProfileStateWriterError( + 'profile-state-writer-closed', + 'Profile state writer is closing', + 'known-failure' + ) + } + if (this.active) { + throw new ProfileStateWriterError( + 'profile-state-writer-busy', + 'Await the active profile state command before dispatching another snapshot', + 'known-failure' + ) + } + } + + protected dispatch( + command: ProfileStateWriterCommand + ): Promise { + try { + this.assertDispatchable(command.command === 'close') + } catch (error) { + return Promise.reject(error) + } + const pending = this.createPending(this.nextId++, command.command) + this.active = pending + try { + this.worker?.postMessage({ ...command, id: pending.id }) + } catch (cause) { + // postMessage did not dispatch a message when serialization fails. + this.settle( + undefined, + new ProfileStateWriterError( + 'profile-state-writer-message', + 'Profile state command could not be transferred', + 'known-failure', + { cause } + ) + ) + } + return pending.promise + } + + private createPending( + id: number, + command: PendingProfileStateWriterRequest['command'] + ): PendingProfileStateWriterRequest { + return createProfileStateWriterRequest(id, command, this.timeoutMs, () => + this.fault( + new ProfileStateWriterError( + 'profile-state-writer-timeout', + 'Profile state writer command timed out', + this.dispatchedOutcome() + ) + ) + ) + } + + private receive(value: unknown): void { + if (this.failure) { + return + } + const pending = this.active + if (!isProfileStateWriterResponse(value) || !pending || value.id !== pending.id) { + this.invalidResponse() + return + } + if (!value.ok) { + const error = decodeProfileStateWriterError(value.error) + if (pending.command === 'initialize' || value.error.outcome === 'indeterminate') { + this.fault(error) + } else { + this.settle(undefined, error) + } + return + } + if ( + !isExpectedProfileStateWriterSuccess( + pending.command, + value, + this.latestRevision ?? this.initialRevision + ) + ) { + this.invalidResponse() + return + } + if (pending.command === 'close') { + this.closeAcknowledged = true + } + this.latestRevision = value.revision + this.settle(value) + } + + private settle(response?: SuccessfulProfileStateWriterResponse, error?: Error): void { + const pending = this.active + this.active = undefined + if (!pending) { + return + } + clearTimeout(pending.timer) + if (response) { + pending.resolve(response) + } else { + pending.reject(error ?? new Error('Profile state request failed')) + } + } + + private dispatchedOutcome(): 'known-failure' | 'indeterminate' { + return this.active?.command === 'initialize' ? 'known-failure' : 'indeterminate' + } + + private invalidResponse(): void { + const error = new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer response', + this.dispatchedOutcome() + ) + this.fault(error) + } + + private fault(error: Error): void { + if (this.failure) { + return + } + this.failure = error + this.settle(undefined, this.failure) + if (!this.didExit) { + void this.worker?.terminate().catch(() => {}) + } + // Startup failures already reject ready; admitted writers must also alert idle callers. + if (this.latestRevision !== undefined || this.options.reportInitializationFailure) { + try { + this.options.onFailure?.(error) + } catch (notificationError) { + console.error('[persistence] Could not report stopped saving:', notificationError) + } + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-errors.ts b/src/main/persistence/profile-state/profile-state-writer-errors.ts new file mode 100644 index 00000000000..28237862ce2 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -0,0 +1,94 @@ +import { + ProfileStateDocumentCorruptionError, + ProfileStateRevisionConflictError +} from './profile-state-document-validation' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' +import { ProfileStateIndeterminateWriteError } from './profile-state-write-transaction' +import { ProfileStateReadRollbackError } from './profile-state-read-snapshot' +import type { + ProfileStateWriterErrorData, + ProfileStateWriterFailureOutcome +} from './profile-state-writer-protocol' + +export class ProfileStateWriterError extends Error { + constructor( + readonly code: string, + message: string, + readonly outcome: ProfileStateWriterFailureOutcome, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ProfileStateWriterError' + } +} + +export function profileStateWriterFailureOutcome(error: unknown): ProfileStateWriterFailureOutcome { + if ( + error instanceof ProfileStateIndeterminateWriteError || + error instanceof ProfileStateReadRollbackError + ) { + return 'indeterminate' + } + if (error instanceof ProfileStateWriterError) { + return error.outcome + } + if (error instanceof Error && error.cause !== undefined) { + return profileStateWriterFailureOutcome(error.cause) + } + return 'known-failure' +} + +export function encodeProfileStateWriterError( + error: unknown, + forceIndeterminate = false +): ProfileStateWriterErrorData { + const outcome = forceIndeterminate ? 'indeterminate' : profileStateWriterFailureOutcome(error) + if (error instanceof ProfileStateRevisionConflictError) { + return { + code: error.code, + message: error.message, + outcome, + expectedRevision: error.expectedRevision, + actualRevision: error.actualRevision + } + } + if (error instanceof ProfileStateDocumentCorruptionError) { + return { code: error.code, message: error.message, outcome, domain: error.domain } + } + if ( + error instanceof ProfileStateDatabaseOpenError || + error instanceof ProfileStateWriterError || + error instanceof ProfileStateIndeterminateWriteError || + error instanceof ProfileStateReadRollbackError + ) { + return { code: error.code, message: error.message, outcome } + } + return { + code: 'profile-state-write-failed', + message: 'Profile state persistence failed', + outcome + } +} + +export function decodeProfileStateWriterError(data: ProfileStateWriterErrorData): Error { + if (data.outcome === 'known-failure') { + if ( + data.code === 'profile-state-revision-conflict' && + data.expectedRevision !== undefined && + data.actualRevision !== undefined + ) { + return new ProfileStateRevisionConflictError(data.expectedRevision, data.actualRevision) + } + if (data.code === 'corrupt-document') { + return new ProfileStateDocumentCorruptionError(data.message, data.domain ?? null) + } + if ( + data.code === 'unreadable' || + data.code === 'identity-mismatch' || + data.code === 'invalid-profile-id' + ) { + return new ProfileStateDatabaseOpenError(data.code, data.message) + } + } + return new ProfileStateWriterError(data.code, data.message, data.outcome) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts new file mode 100644 index 00000000000..80a92f00118 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol-faults.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +const clients: ProfileStateWriteWorkerClient[] = [] +const roots: string[] = [] +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function clientFor( + response: string, + initialization = '{ id: 0, ok: true, revision: 1 }', + onFailure?: (error: Error) => void, + startup = '' +) { + const root = mkdtempSync(join(tmpdir(), 'orca-writer-protocol-')) + roots.push(root) + const workerPath = join(root, 'writer.cjs') + writeFileSync( + workerPath, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage(${initialization}) + parentPort.on('message', (request) => { ${response} }) + ${startup} + ` + ) + const client = new ProfileStateWriteWorkerClient( + { databasePath: join(root, 'unused.db'), profileId: 'protocol-test', revision: 1 }, + { workerPath, timeoutMs: 1000, onFailure } + ) + clients.push(client) + return client +} + +describe('writer protocol refuses uncertain acknowledgements', () => { + it.each([ + '{ id: request.id + 1, ok: true, revision: 2 }', + '{ id: request.id, ok: true, revision: 0 }', + '{ id: request.id, ok: true, revision: 3 }', + '{ id: request.id, ok: true, revision: 2, exportedRevision: 2 }', + '{ id: request.id, ok: true, revision: "2" }' + ])('faults instead of acknowledging malformed write response %s', async (response) => { + const client = clientFor(`parentPort.postMessage(${response})`) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-protocol', outcome: 'indeterminate' }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-protocol' + }) + }) + + it.each(['undefined', 'null', '0', '2'])( + 'refuses a compatibility export with revision %s for an admitted revision of one', + async (exportedRevision) => { + const client = clientFor(`parentPort.postMessage({ + id: request.id, ok: true, revision: 1, exportedRevision: ${exportedRevision} + })`) + await client.ready + await expect(client.writeJsonCompatibilityExportAsync('unused.json')).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'indeterminate' + }) + } + ) + + it('refuses a revision jump at an unchanged-state fence', async () => { + const client = clientFor('parentPort.postMessage({ id: request.id, ok: true, revision: 2 })') + await client.ready + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'indeterminate' + }) + }) + + it('refuses an initialization acknowledgement for a different revision', async () => { + const client = clientFor('', '{ id: 0, ok: true, revision: 2 }') + await expect(client.ready).rejects.toMatchObject({ + code: 'profile-state-writer-protocol', + outcome: 'known-failure' + }) + }) + + it('faults an unanswered request and rejects later work without retry', async () => { + const notify = vi.fn() + const client = clientFor('', undefined, notify) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-timeout', outcome: 'indeterminate' }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-timeout' + }) + await client.close() + expect(notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'profile-state-writer-timeout' }) + ) + }) + + it('reports an idle writer exit even without a subsequent save', async () => { + const notify = vi.fn() + const client = clientFor('', undefined, notify, 'setTimeout(() => process.exit(1), 50)') + await client.ready + await vi.waitFor(() => expect(notify).toHaveBeenCalledOnce()) + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ code: 'profile-state-writer-exit' }) + ) + await client.close() + expect(notify).toHaveBeenCalledOnce() + }) + + it('leaves startup failure reporting to the startup caller', async () => { + const notify = vi.fn() + const client = clientFor('', '{ id: 0, ok: true, revision: 2 }', notify) + await expect(client.ready).rejects.toThrow() + await client.close() + expect(notify).not.toHaveBeenCalled() + }) + + it('does not report saving stopped after a recoverable request failure or clean close', async () => { + const notify = vi.fn() + const client = clientFor( + ` + if (request.command === 'close') { + parentPort.postMessage({ id: request.id, ok: true, revision: 1 }) + parentPort.close() + } else { + parentPort.postMessage({ id: request.id, ok: false, + error: { code: 'SQLITE_BUSY', message: 'busy', outcome: 'known-failure' } }) + } + `, + undefined, + notify + ) + await client.ready + await expect(client.assertCurrentRevision()).rejects.toThrow('busy') + expect(() => client.assertWritable()).not.toThrow() + await client.close() + expect(notify).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol.ts b/src/main/persistence/profile-state/profile-state-writer-protocol.ts new file mode 100644 index 00000000000..1a7dc665abf --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-protocol.ts @@ -0,0 +1,115 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import { isRecord } from './profile-state-document-validation' + +export type ProfileStateWriterInitialization = { + databasePath: string + profileId: string + revision: number +} + +export type ProfileStateWriterCommand = + | { command: 'write-state'; payload: Uint8Array } + | { + command: 'write-complete' | 'write-domains' + replacements: readonly ProfileStateDomainReplacement[] + } + | { + command: 'write-automation' + replacements: readonly ProfileStateDomainReplacement[] + runPayloads: readonly string[] + } + | { command: 'assert-revision' | 'close' } + | { command: 'export-json' | 'export-latest' | 'export-compatibility'; targetPath: string } + +export type ProfileStateWriterRequest = ProfileStateWriterCommand & { id: number } +export type ProfileStateWriterFailureOutcome = 'known-failure' | 'indeterminate' +export type ProfileStateWriterErrorData = { + code: string + message: string + outcome: ProfileStateWriterFailureOutcome + expectedRevision?: number + actualRevision?: number + domain?: string | null +} +export type ProfileStateWriterResponse = + | { id: number; ok: true; revision: number; exportedRevision?: number | null } + | { id: number; ok: false; error: ProfileStateWriterErrorData } + +export function isProfileStateRevision(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +export function isProfileStateWriterInitialization( + value: unknown +): value is ProfileStateWriterInitialization { + return ( + isRecord(value) && + typeof value.databasePath === 'string' && + value.databasePath.length > 0 && + typeof value.profileId === 'string' && + value.profileId.length > 0 && + isProfileStateRevision(value.revision) + ) +} + +function isReplacement(value: unknown): value is ProfileStateDomainReplacement { + return ( + isRecord(value) && + typeof value.domain === 'string' && + value.domain.length > 0 && + (typeof value.payload === 'string' || value.payload === null) + ) +} + +export function isProfileStateWriterRequest(value: unknown): value is ProfileStateWriterRequest { + if (!isRecord(value) || !isProfileStateRevision(value.id) || value.id === 0) { + return false + } + switch (value.command) { + case 'write-state': + return value.payload instanceof Uint8Array + case 'assert-revision': + case 'close': + return true + case 'export-json': + case 'export-latest': + case 'export-compatibility': + return typeof value.targetPath === 'string' && value.targetPath.length > 0 + case 'write-complete': + case 'write-domains': + return Array.isArray(value.replacements) && value.replacements.every(isReplacement) + case 'write-automation': + return ( + Array.isArray(value.replacements) && + value.replacements.every(isReplacement) && + Array.isArray(value.runPayloads) && + value.runPayloads.every((payload: unknown) => typeof payload === 'string') + ) + default: + return false + } +} + +function isErrorData(value: unknown): value is ProfileStateWriterErrorData { + return ( + isRecord(value) && + typeof value.code === 'string' && + typeof value.message === 'string' && + (value.outcome === 'known-failure' || value.outcome === 'indeterminate') && + (value.expectedRevision === undefined || isProfileStateRevision(value.expectedRevision)) && + (value.actualRevision === undefined || isProfileStateRevision(value.actualRevision)) && + (value.domain === undefined || value.domain === null || typeof value.domain === 'string') + ) +} + +export function isProfileStateWriterResponse(value: unknown): value is ProfileStateWriterResponse { + if (!isRecord(value) || !isProfileStateRevision(value.id)) { + return false + } + return value.ok === true + ? isProfileStateRevision(value.revision) && + (value.exportedRevision === undefined || + value.exportedRevision === null || + isProfileStateRevision(value.exportedRevision)) + : value.ok === false && isErrorData(value.error) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-request.ts b/src/main/persistence/profile-state/profile-state-writer-request.ts new file mode 100644 index 00000000000..8db4c4f6580 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-request.ts @@ -0,0 +1,59 @@ +import type { + ProfileStateWriterCommand, + ProfileStateWriterResponse +} from './profile-state-writer-protocol' + +export type SuccessfulProfileStateWriterResponse = Extract +export type PendingProfileStateWriterRequest = { + id: number + command: ProfileStateWriterCommand['command'] | 'initialize' + promise: Promise + resolve: (response: SuccessfulProfileStateWriterResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +export function isExpectedProfileStateWriterSuccess( + command: PendingProfileStateWriterRequest['command'], + response: SuccessfulProfileStateWriterResponse, + previousRevision: number +): boolean { + const mayWrite = command.startsWith('write-') + if ( + response.revision < previousRevision || + response.revision > previousRevision + (mayWrite ? 1 : 0) + ) { + return false + } + if ( + response.exportedRevision !== undefined && + response.exportedRevision !== null && + response.exportedRevision !== response.revision + ) { + return false + } + if (command === 'export-json') { + return response.exportedRevision !== undefined && response.exportedRevision !== null + } + if (command === 'export-compatibility' || command === 'export-latest') { + return ( + response.exportedRevision !== undefined && + (response.exportedRevision !== null || response.revision === 0) + ) + } + return response.exportedRevision === undefined +} + +export function createProfileStateWriterRequest( + id: number, + command: PendingProfileStateWriterRequest['command'], + timeoutMs: number, + onTimeout: () => void +): PendingProfileStateWriterRequest { + return { + id, + command, + ...Promise.withResolvers(), + timer: setTimeout(onTimeout, timeoutMs) + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-client.ts b/src/main/persistence/profile-state/profile-state-writer-worker-client.ts new file mode 100644 index 00000000000..215af1f3499 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-client.ts @@ -0,0 +1,75 @@ +import type { ProfileStateDomainReplacement } from '../loading-store/profile-state-authority' +import { ProfileStateWriterConnection } from './profile-state-writer-connection' +export { resolveProfileStateWriterWorkerPath } from './profile-state-writer-worker-path' +export { + ProfileStateWriterError, + profileStateWriterFailureOutcome +} from './profile-state-writer-errors' +export type { ProfileStateWriterInitialization } from './profile-state-writer-protocol' + +export class ProfileStateWriteWorkerClient extends ProfileStateWriterConnection { + assertWritable(): void { + this.assertDispatchable() + } + + writeSerializedState(payload: Buffer): Promise { + return this.dispatch({ command: 'write-state', payload }).then((response) => response.revision) + } + + writeCompleteSerializedDomains( + replacements: readonly ProfileStateDomainReplacement[] + ): Promise { + return this.dispatch({ command: 'write-complete', replacements }).then( + (response) => response.revision + ) + } + + writeSerializedDomains(replacements: readonly ProfileStateDomainReplacement[]): Promise { + return this.dispatch({ command: 'write-domains', replacements }).then( + (response) => response.revision + ) + } + + writeSerializedAutomationRuns( + replacements: readonly ProfileStateDomainReplacement[], + runs: readonly unknown[] + ): Promise { + try { + this.assertDispatchable() + const runPayloads = runs.map((run) => { + const payload = JSON.stringify(run) + if (payload === undefined) { + throw new Error('Automation run is not serializable') + } + return payload + }) + return this.dispatch({ command: 'write-automation', replacements, runPayloads }).then( + (response) => response.revision + ) + } catch (error) { + return Promise.reject(error) + } + } + + assertCurrentRevision(): Promise { + return this.dispatch({ command: 'assert-revision' }).then((response) => response.revision) + } + + writeJsonExport(targetPath: string): Promise { + return this.dispatch({ command: 'export-json', targetPath }).then( + (response) => response.exportedRevision ?? response.revision + ) + } + + writeLatestJsonExport(dataFile: string): Promise { + return this.dispatch({ command: 'export-latest', targetPath: dataFile }).then( + (response) => response.exportedRevision ?? undefined + ) + } + + writeJsonCompatibilityExportAsync(targetPath: string): Promise { + return this.dispatch({ command: 'export-compatibility', targetPath }).then( + (response) => response.exportedRevision ?? undefined + ) + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts new file mode 100644 index 00000000000..a63eb83e71d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-entry.ts @@ -0,0 +1,148 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { writeVersionedProfileStateExport } from './profile-state-versioned-export' +import { + encodeProfileStateWriterError, + ProfileStateWriterError +} from './profile-state-writer-errors' +import { + isProfileStateWriterInitialization, + isProfileStateWriterRequest, + type ProfileStateWriterRequest, + type ProfileStateWriterResponse +} from './profile-state-writer-protocol' + +if (!parentPort) { + throw new Error('Profile state writer requires a worker thread') +} +const port = parentPort +let authority: ProfileStateSqliteAuthority | undefined +let busy = false +let stopping = false +let previousId = 0 + +function reply(response: ProfileStateWriterResponse): void { + port.postMessage(response) +} + +function close(): void { + stopping = true + try { + authority?.close() + } finally { + port.close() + } +} + +async function execute(request: ProfileStateWriterRequest): Promise { + if (!authority) { + throw new Error('Profile state writer is not initialized') + } + let exportedRevision: number | null | undefined + switch (request.command) { + case 'write-state': + authority.writeSerializedState(Buffer.from(request.payload)) + break + case 'write-complete': + authority.writeCompleteSerializedDomains(request.replacements) + break + case 'write-domains': + authority.writeSerializedDomains(request.replacements) + break + case 'write-automation': + authority.writeSerializedAutomationRuns( + request.replacements, + request.runPayloads.map((payload): unknown => JSON.parse(payload)) + ) + break + case 'assert-revision': + authority.assertCurrentRevision() + break + case 'export-json': + authority.assertCurrentRevision() + exportedRevision = authority.writeJsonExport(request.targetPath) + break + case 'export-latest': + authority.assertCurrentRevision() + exportedRevision = + writeVersionedProfileStateExport( + request.targetPath, + authority.writeJsonExport.bind(authority) + ) ?? null + break + case 'export-compatibility': + authority.assertCurrentRevision() + exportedRevision = + (await authority.writeJsonCompatibilityExportAsync(request.targetPath)) ?? null + break + case 'close': + authority.close() + stopping = true + break + } + return { + id: request.id, + ok: true, + revision: authority.revision, + ...(exportedRevision === undefined ? {} : { exportedRevision }) + } +} + +async function accept(value: unknown): Promise { + if (stopping) { + return + } + if (!isProfileStateWriterRequest(value) || busy || value.id <= previousId) { + stopping = true + reply({ + id: 0, + ok: false, + error: encodeProfileStateWriterError( + new ProfileStateWriterError( + 'profile-state-writer-protocol', + 'Invalid profile state writer request', + 'indeterminate' + ) + ) + }) + if (!busy) { + close() + } + return + } + busy = true + previousId = value.id + try { + reply(await execute(value)) + } catch (error) { + // Export staging accepts both JSON versions; only uncertain SQL outcomes retire the writer. + const failure = encodeProfileStateWriterError(error, value.command === 'close') + reply({ id: value.id, ok: false, error: failure }) + stopping ||= failure.outcome === 'indeterminate' + } finally { + busy = false + if (stopping) { + close() + } + } +} + +try { + const initialization: unknown = workerData + if (!isProfileStateWriterInitialization(initialization)) { + throw new ProfileStateWriterError( + 'profile-state-writer-initialization', + 'Invalid profile state writer initialization', + 'known-failure' + ) + } + authority = new ProfileStateSqliteAuthority(initialization.databasePath, initialization.profileId) + authority.initializeFromRevision(initialization.revision) + reply({ id: 0, ok: true, revision: authority.revision }) + port.on('message', (value: unknown) => { + void accept(value) + }) +} catch (error) { + reply({ id: 0, ok: false, error: encodeProfileStateWriterError(error) }) + close() +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker-path.ts b/src/main/persistence/profile-state/profile-state-writer-worker-path.ts new file mode 100644 index 00000000000..d1068eb26b0 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker-path.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { + currentWorkerEntryLayout, + resolveWorkerThreadEntryPath +} from '../../worker-thread-entry-path' + +export function resolveProfileStateWriterWorkerPath(moduleDir = __dirname): string { + const entry = resolveWorkerThreadEntryPath( + currentWorkerEntryLayout(moduleDir), + 'profile-state-writer-worker-entry.js' + ) + return ( + [entry, join(dirname(entry), '..', 'profile-state-writer-worker-entry.js')].find(existsSync) ?? + entry + ) +} diff --git a/src/main/persistence/profile-state/profile-state-writer-worker.test.ts b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts new file mode 100644 index 00000000000..6fa1efe9a6e --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-worker.test.ts @@ -0,0 +1,341 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { ProfileStateRevisionConflictError } from './profile-state-document-validation' +import { openProfileStateDatabase } from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' +import { + ProfileStateWriteWorkerClient, + profileStateWriterFailureOutcome, + resolveProfileStateWriterWorkerPath +} from './profile-state-writer-worker-client' + +let bundleRoot: string +let workerPath: string +const roots: string[] = [] +const clients: ProfileStateWriteWorkerClient[] = [] + +beforeAll(async () => { + bundleRoot = mkdtempSync(join(tmpdir(), 'orca-writer-bundle-')) + workerPath = join(bundleRoot, 'profile-state-writer-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +afterAll(() => rmSync(bundleRoot, { recursive: true, force: true })) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-writer-')) + roots.push(root) + const path = join(root, 'profile-state.db') + const profileId = 'writer-test' + const db = openProfileStateDatabase(path, profileId) + db.db.close() + const authority = new ProfileStateSqliteAuthority(path, profileId) + authority.readInitialState().takeParsedState?.() + authority.writeCompleteSerializedDomains([{ domain: 'settings', payload: '{"theme":"dark"}' }]) + const initialization = authority.retireForWorker() + return { root, path, profileId, authority, initialization } +} + +function clientFor( + initialization: ReturnType['initialization'], + path = workerPath, + timeoutMs = 5000 +) { + const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: path, timeoutMs }) + clients.push(client) + return client +} + +function readState(path: string, profileId: string): unknown { + const db = openProfileStateDatabase(path, profileId) + try { + return JSON.parse(readProfileStateSnapshot(db.db).json) + } finally { + db.db.close() + } +} + +function script(root: string, source: string): string { + const path = join(root, 'fault-worker.cjs') + writeFileSync(path, source) + return path +} + +describe('persistent profile state write worker', () => { + it('commits full, selective, automation and byte payloads, exports and releases the database', async () => { + const f = fixture() + const client = clientFor(f.initialization) + await client.ready + expect( + await client.writeSerializedDomains([{ domain: 'ui', payload: '{"note":"hi \\ud800"}' }]) + ).toBe(2) + const run = { + id: 'run-1', + output: 'first', + toJSON() { + return { id: this.id, output: this.output } + } + } + const write = client.writeSerializedAutomationRuns([], [run]) + run.output = 'changed after capture' + expect(await write).toBe(3) + const exported = join(f.root, 'state.json') + expect(await client.writeJsonExport(exported)).toBe(3) + expect(JSON.parse(readFileSync(exported, 'utf8'))).toEqual({ + settings: { theme: 'dark' }, + ui: { note: 'hi \ud800' }, + automationRuns: [{ id: 'run-1', output: 'first' }] + }) + const compatibility = join(f.root, 'compatibility.json') + expect(await client.writeJsonCompatibilityExportAsync(compatibility)).toBe(3) + expect(readFileSync(compatibility, 'utf8')).toBe(readFileSync(exported, 'utf8')) + expect(await client.writeSerializedState(Buffer.from('{"settings":{"theme":"light"}}'))).toBe(4) + expect(await client.assertCurrentRevision()).toBe(4) + await client.close() + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'light' } }) + rmSync(f.root, { recursive: true }) + expect(existsSync(f.root)).toBe(false) + }) + + it('requires a present admitted database and refuses startup revision races', async () => { + const f = fixture() + const peer = new ProfileStateSqliteAuthority(f.path, f.profileId) + peer.readInitialState() + peer.writeSerializedDomains([{ domain: 'settings', payload: '{"peer":true}' }]) + peer.close() + const client = clientFor(f.initialization) + await expect(client.ready).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { peer: true } }) + const missing = clientFor({ ...f.initialization, databasePath: join(f.root, 'missing.db') }) + await expect(missing.ready).rejects.toMatchObject({ code: 'unreadable' }) + await missing.close() + expect(existsSync(join(f.root, 'missing.db'))).toBe(false) + }) + + it('permanently retires bootstrap authority and refuses subsequent calls', () => { + const f = fixture() + for (const call of [ + () => f.authority.readInitialState(), + () => f.authority.readAcceptedState('{}'), + () => f.authority.readSerializedState(), + () => f.authority.writeSerializedDomains([]), + () => f.authority.writeCompleteSerializedDomains([]), + () => f.authority.writeSerializedAutomationRuns([], []), + () => f.authority.writeSerializedState(Buffer.from('{}')), + () => f.authority.writeJsonExport(join(f.root, 'export.json')), + () => f.authority.scheduleBackup(), + () => f.authority.assertCurrentRevision(), + () => f.authority.initializeFromRevision(1), + () => f.authority.retireForWorker(), + () => f.authority.close() + ]) { + expect(call).toThrow(/retired/) + } + }) + + it('preserves known validation failures and rejects a peer at the no-op fence', async () => { + const f = fixture() + const client = clientFor(f.initialization) + await client.ready + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{' }]) + ).rejects.toMatchObject({ code: 'profile-state-write-failed', outcome: 'known-failure' }) + expect(await client.assertCurrentRevision()).toBe(1) + const peer = new ProfileStateSqliteAuthority(f.path, f.profileId) + peer.readInitialState() + peer.writeSerializedDomains([{ domain: 'ui', payload: '{"peer":true}' }]) + peer.close() + const failure = await client.assertCurrentRevision().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(ProfileStateRevisionConflictError) + expect(profileStateWriterFailureOutcome(failure)).toBe('known-failure') + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + ).rejects.toBeInstanceOf(ProfileStateRevisionConflictError) + }) + + it('rejects a second materialized write and waits for the first before close', async () => { + const f = fixture() + const delayed = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { setTimeout(() => send.call(this, value, ...rest), 60); return } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayed) + await client.ready + const first = client.writeSerializedDomains([{ domain: 'settings', payload: '{"first":true}' }]) + await expect( + client.writeSerializedDomains([{ domain: 'settings', payload: '{"second":true}' }]) + ).rejects.toMatchObject({ code: 'profile-state-writer-busy' }) + const closing = client.close() + expect(await first).toBe(2) + await closing + expect(readState(f.path, f.profileId)).toEqual({ settings: { first: true } }) + await expect(client.assertCurrentRevision()).rejects.toMatchObject({ + code: 'profile-state-writer-closed' + }) + }) + + it('waits for actual worker exit after receiving its close acknowledgement', async () => { + const f = fixture() + const marker = join(f.root, 'worker-exited.txt') + const delayedExit = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) { + setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'released'), 60) + } + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, delayedExit) + await client.ready + await client.close() + expect(readFileSync(marker, 'utf8')).toBe('released') + }) + + it('reports post-commit worker death as indeterminate without replaying the committed write', async () => { + const f = fixture() + const crashAfterCommit = script( + f.root, + ` + const { MessagePort } = require('node:worker_threads') + const send = MessagePort.prototype.postMessage + MessagePort.prototype.postMessage = function(value, ...rest) { + if (value?.id === 1 && value.ok) process.exit(13) + return send.call(this, value, ...rest) + } + require(${JSON.stringify(workerPath)}) + ` + ) + const client = clientFor(f.initialization, crashAfterCommit) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{"committed":true}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await expect(client.assertCurrentRevision()).rejects.toBe(failure) + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { committed: true } }) + }) + + it.each(['mismatch', 'timeout', 'exit'])( + 'fails closed after a dispatched %s and awaits actual exit', + async (mode) => { + const f = fixture() + const broken = script( + f.root, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 1 }) + parentPort.on('message', request => { + if (${JSON.stringify(mode)} === 'exit') process.exit(0) + if (${JSON.stringify(mode)} === 'mismatch') parentPort.postMessage({ id: request.id + 1, ok: true, revision: 2 }) + }) + ` + ) + const client = clientFor(f.initialization, broken, 150) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) + rmSync(f.root, { recursive: true }) + } + ) + + it('recovers the prior committed revision after the worker exits inside a transaction', async () => { + const f = fixture() + const interruptedPath = join(f.root, 'interrupted-worker.cjs') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts') + ], + outfile: interruptedPath, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', + plugins: [ + { + name: 'interrupt-transaction', + setup(builder) { + builder.onLoad({ filter: /profile-state-write-transaction\.ts$/ }, (args) => ({ + contents: readFileSync(args.path, 'utf8').replace( + "db.exec('COMMIT')", + 'process.exit(17)' + ), + loader: 'ts' + })) + } + } + ] + }) + const client = clientFor(f.initialization, interruptedPath) + await client.ready + const failure = await client + .writeSerializedDomains([{ domain: 'settings', payload: '{"uncommitted":true}' }]) + .catch((error: unknown) => error) + expect(profileStateWriterFailureOutcome(failure)).toBe('indeterminate') + await client.close() + expect(readState(f.path, f.profileId)).toEqual({ settings: { theme: 'dark' } }) + }) + + it('aborts an active request without treating the pending write as rolled back', async () => { + const f = fixture() + const hung = script( + f.root, + ` + const { parentPort } = require('node:worker_threads') + parentPort.postMessage({ id: 0, ok: true, revision: 1 }) + parentPort.on('message', () => {}) + ` + ) + const client = clientFor(f.initialization, hung) + await client.ready + const write = client.writeSerializedDomains([{ domain: 'settings', payload: '{}' }]) + const failed = expect(write).rejects.toMatchObject({ outcome: 'indeterminate' }) + await client.abort() + await failed + await client.close() + }) + + it('resolves flat and shared-chunk entry layouts', () => { + expect(resolveProfileStateWriterWorkerPath(bundleRoot)).toBe(workerPath) + expect(resolveProfileStateWriterWorkerPath(join(bundleRoot, 'chunks'))).toBe(workerPath) + }) +}) diff --git a/src/main/persistence/restoring-sessions/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts index 98d97a649a3..6cd2bbf129f 100644 --- a/src/main/persistence/restoring-sessions/pane-identity-migration.ts +++ b/src/main/persistence/restoring-sessions/pane-identity-migration.ts @@ -1,8 +1,8 @@ +import type { ProfileStateStartupPaneAlias } from '../loading-store/profile-state-authority' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' -import { agentHookServer } from '../../agent-hooks/server' import { collectLayoutLeafIdsInOrder, firstLayoutLeafId } from './terminal-layout-normalization' export function findWorktreeIdForTab( @@ -67,47 +67,49 @@ export function createLazyTerminalTabLookup(session: WorkspaceSessionState): Ter } } -/** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */ -export function registerLegacyPaneKeyAliasesForTab(args: { +export type PaneAliasNormalizationOptions = { + registerAliases?: boolean + collectUnboundPaneAlias?: (entry: ProfileStateStartupPaneAlias) => void +} + +type LegacyPaneKeyAlias = Omit & { ptyId?: string } + +/** Includes unbound aliases needed by the live hook server, even though they are not persisted. */ +export function collectLegacyPaneKeyAliasesForTab(args: { tabId: string tab: TerminalTab | undefined inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map -}): LegacyPaneKeyAliasEntry[] { - const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] +}): LegacyPaneKeyAlias[] { + const legacyPaneKeyAliasEntries: LegacyPaneKeyAlias[] = [] const registeredLegacyPaneKeys = new Set() const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 const fallbackPtyId = !hasLeafPtyBindings && typeof args.tab?.ptyId === 'string' ? args.tab.ptyId : undefined - const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { + const collectLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): void => { if (!isTerminalLeafId(leafId)) { - return false + return } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { - return false + return } const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] if (!numeric) { - return false + return } // Why: PaneManager ids are 1-based; a zero-based alias in split layouts makes tab:1 ambiguous and misroutes panes. const legacyPaneKey = `${args.tabId}:${numeric}` - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) registeredLegacyPaneKeys.add(legacyPaneKey) - if (ptyId) { - legacyPaneKeyAliasEntries.push({ - ptyId, - legacyPaneKey, - stablePaneKey: paneKey, - updatedAt: Date.now() - }) - return true - } - return false + legacyPaneKeyAliasEntries.push({ + ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) } const inputLeafIds = new Set([ ...collectLayoutLeafIdsInOrder(args.inputLayout.root), @@ -119,7 +121,7 @@ export function registerLegacyPaneKeyAliasesForTab(args: { } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { - registerLegacyAlias( + collectLegacyAlias( inputLeafId, leafId, args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId @@ -142,7 +144,6 @@ export function registerLegacyPaneKeyAliasesForTab(args: { if (registeredLegacyPaneKeys.has(legacyPaneKey)) { continue } - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, args.tab.ptyId) legacyPaneKeyAliasEntries.push({ ptyId: args.tab.ptyId, legacyPaneKey, diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index cfc26c7bb8a..38ec014c7c9 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -2,6 +2,7 @@ import type { LegacyPaneKeyAliasEntry, PersistedState } from '../../../shared/pe import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types' +import { agentHookServer } from '../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId, @@ -12,7 +13,8 @@ import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id' import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids' import { createLazyTerminalTabLookup, - registerLegacyPaneKeyAliasesForTab + collectLegacyPaneKeyAliasesForTab, + type PaneAliasNormalizationOptions } from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' import { @@ -37,7 +39,7 @@ export { export function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, priorLayoutsByTabId: Record = {}, - options: { skipAliasTabIds?: ReadonlySet } = {} + options: PaneAliasNormalizationOptions & { skipAliasTabIds?: ReadonlySet } = {} ): { session: WorkspaceSessionState changed: boolean @@ -49,9 +51,6 @@ export function normalizeWorkspaceSessionPaneIdentities( let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() - // Why always empty: legacy numeric pane keys are bridged by aliases now, not persisted as - // restart-required rows; the field stays so callers keep clearing stale rows written by old builds. - const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} let tabsById: ReturnType | null = null @@ -64,7 +63,7 @@ export function normalizeWorkspaceSessionPaneIdentities( leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) if (!options.skipAliasTabIds?.has(tabId)) { tabsById ??= createLazyTerminalTabLookup(session) - const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({ + const tabAliasEntries = collectLegacyPaneKeyAliasesForTab({ tabId, tab: tabsById.get(tabId), inputLayout: layout, @@ -73,7 +72,18 @@ export function normalizeWorkspaceSessionPaneIdentities( }) // Why: old split layouts can generate enough alias rows to exceed V8's argument limit if spread into push(). for (const entry of tabAliasEntries) { - legacyPaneKeyAliasEntries.push(entry) + if (options.registerAliases !== false) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId + ) + } + if (entry.ptyId) { + legacyPaneKeyAliasEntries.push({ ...entry, ptyId: entry.ptyId }) + } else { + options.collectUnboundPaneAlias?.(entry) + } } } const leafIdByPtyId = new Map() @@ -97,7 +107,8 @@ export function normalizeWorkspaceSessionPaneIdentities( changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, - migrationUnsupportedEntries, + // Aliases replace old restart-required rows; callers still clear that legacy field. + migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } } @@ -163,7 +174,10 @@ function mergeAcknowledgementLeafIdMapsByTabId( return merged } -export function normalizePersistedPaneIdentityState(state: PersistedState): { +export function normalizePersistedPaneIdentityState( + state: PersistedState, + options: PaneAliasNormalizationOptions = {} +): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] @@ -174,6 +188,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { state.workspaceSession, {}, { + ...options, skipAliasTabIds: crossHostTabIds } ) @@ -200,6 +215,7 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { hostSession, {}, { + ...options, skipAliasTabIds: crossHostTabIds } ) diff --git a/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts b/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts new file mode 100644 index 00000000000..bdca21f7bbe --- /dev/null +++ b/src/main/persistence/restoring-sessions/workspace-session-write-rollback.ts @@ -0,0 +1,132 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +const MISSING = Symbol('missing') + +/** A JSON-shaped slot of persisted session state, or the absent-key sentinel. */ +type RollbackSlot = + | string + | number + | boolean + | null + | undefined + | typeof MISSING + | readonly RollbackSlot[] + | RollbackRecord + +type RollbackRecord = { readonly [key: string]: RollbackSlot } + +function isRecord(value: RollbackSlot): value is RollbackRecord { + return ( + value !== MISSING && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +type IdentifiedRecord = RollbackRecord & { readonly id: string } + +function identifiedRows(value: RollbackSlot): readonly IdentifiedRecord[] | null { + if (value === MISSING) { + return [] + } + if ( + !Array.isArray(value) || + !value.every((row): row is IdentifiedRecord => isRecord(row) && typeof row.id === 'string') + ) { + return null + } + return new Set(value.map((row) => row.id)).size === value.length ? value : null +} + +function rollbackIdentifiedRows( + original: RollbackSlot, + staged: RollbackSlot, + current: RollbackSlot +): readonly RollbackSlot[] | null { + if (!Array.isArray(original) && !Array.isArray(staged) && !Array.isArray(current)) { + return null + } + const before = identifiedRows(original) + const written = identifiedRows(staged) + const latest = identifiedRows(current) + if (!before || !written || !latest) { + return null + } + const beforeById = new Map(before.map((row) => [row.id, row])) + const writtenById = new Map(written.map((row) => [row.id, row])) + const latestById = new Map(latest.map((row) => [row.id, row])) + const restored = new Map() + for (const id of new Set([...beforeById.keys(), ...writtenById.keys(), ...latestById.keys()])) { + const value = rollbackValue( + beforeById.get(id) ?? MISSING, + writtenById.get(id) ?? MISSING, + latestById.get(id) ?? MISSING + ) + if (value !== MISSING) { + restored.set(id, value) + } + } + const order = latest.map((row) => row.id).filter((id) => restored.has(id)) + for (const [index, row] of before.entries()) { + if (restored.has(row.id) && !order.includes(row.id)) { + order.splice(Math.min(index, order.length), 0, row.id) + } + } + return order.map((id) => restored.get(id)) +} + +function rollbackValue( + original: RollbackSlot, + staged: RollbackSlot, + current: RollbackSlot +): RollbackSlot { + if (isDeepStrictEqual(original, staged)) { + return current + } + if (isDeepStrictEqual(current, staged)) { + return original + } + // Terminal and unified tab rows have stable ids; unrelated row edits must survive rollback. + const rows = rollbackIdentifiedRows(original, staged, current) + if (rows) { + return rows + } + if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { + return current + } + let changed = false + const next: Record = { ...current } + for (const key of new Set([ + ...Object.keys(original), + ...Object.keys(staged), + ...Object.keys(current) + ])) { + const value = rollbackValue( + Object.hasOwn(original, key) ? original[key] : MISSING, + Object.hasOwn(staged, key) ? staged[key] : MISSING, + Object.hasOwn(current, key) ? current[key] : MISSING + ) + if (value === MISSING) { + if (Object.hasOwn(next, key)) { + delete next[key] + changed = true + } + } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { + next[key] = value + changed = true + } + } + return changed ? next : current +} + +export function rollbackWorkspaceSessionAfterFailedAsyncWrite( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each restored field retains a value from the same field of a typed session. + return rollbackValue(original, staged, current) as WorkspaceSessionState +} diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index c25315e4986..1192b7aaf39 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -3,6 +3,7 @@ import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree import type { Automation, AutomationCreateInput, + AutomationRun, AutomationUpdateInput } from '../../../shared/automations-types' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -38,6 +39,7 @@ export type AutomationDefinitionOperations = { storageAuthority: AutomationStorageAuthority flush: () => void recordCreated: () => void + recordAutomationRunsMutation?: (runs: readonly AutomationRun[]) => void } export function listAutomations(state: PersistedState): Automation[] { @@ -263,6 +265,7 @@ export function deleteAutomation( operations.state.automationRuns = (operations.state.automationRuns ?? []).filter( (entry) => entry.automationId !== id ) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) // Why: the automation and its unfinished runs were pinning their workspace; both are gone (#17775). invalidateLocalWorktreeMetadataPruneInputs() operations.flush() diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index 0dc9e61731a..7415fd12cf1 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -28,6 +28,7 @@ import { export type AutomationRunOperations = { state: PersistedState flush: () => void + recordAutomationRunsMutation?: (runs: readonly AutomationRun[]) => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null } @@ -110,6 +111,7 @@ export function createAutomationRun( ...(operations.state.automationRuns ?? []), run ]) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns ?? []) if (trigger === 'manual') { operations.recordManualRun() } @@ -149,6 +151,7 @@ export function recordRepeatedAutomationSkip( } // Replaced, not patched in place: the list projection caches on array identity. operations.state.automationRuns = runs.map((run) => (run.id === latest.id ? updated : run)) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) touchAutomation(operations.state, automationId, now) operations.flush() return updated @@ -202,6 +205,7 @@ export function updateAutomationRun( operations.state.automationRuns = operations.state.automationRuns.map((run) => run.id === result.runId ? updated : run ) + operations.recordAutomationRunsMutation?.(operations.state.automationRuns) if (!isFinalAutomationRunStatus(current.status) && isFinalAutomationRunStatus(updated.status)) { // Why: only a non-final run pins its workspace, so finishing releases the claim (#17775). invalidateLocalWorktreeMetadataPruneInputs() @@ -229,6 +233,7 @@ export function snapshotAutomationRunWorkspaceDisplayName( return { ...run, workspaceDisplayName: normalizedDisplayName } }) if (updatedCount > 0) { + operations.recordAutomationRunsMutation?.(operations.state.automationRuns ?? []) operations.flush() } return updatedCount diff --git a/src/main/protected-secret-persistence-concurrency.test.ts b/src/main/protected-secret-persistence-concurrency.test.ts new file mode 100644 index 00000000000..1b429cdaa8b --- /dev/null +++ b/src/main/protected-secret-persistence-concurrency.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setSecretStore } from '../shared/secret-store' +import { + ProtectedSecretPersistence, + type ProtectedSecretRetentionUpdate +} from './protected-secret-persistence' + +const cipherState = { available: true, fails: false } +const ciphertext = (plaintext: string): string => + Buffer.from(`encrypted:${plaintext}`).toString('base64') + +function prepare( + secrets: ProtectedSecretPersistence, + slot: string, + plaintext: string +): ProtectedSecretRetentionUpdate { + const { retentionUpdate } = secrets.encrypt(slot, plaintext) + if (!retentionUpdate) { + throw new Error('Expected a prepared retention update') + } + return retentionUpdate +} + +describe('protected secret acknowledgements', () => { + beforeEach(() => { + cipherState.available = true + cipherState.fails = false + setSecretStore({ + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext) => { + if (cipherState.fails) { + throw new Error('Keyring encryption failed') + } + return Buffer.from(`encrypted:${plaintext}`) + }, + decryptString: (encrypted) => encrypted.toString().slice('encrypted:'.length), + describeProtectionGap: () => null + }) + }) + + afterEach(() => vi.restoreAllMocks()) + + it.each(['remove', 'empty encryption', 'empty decryption'])( + 'does not revive a secret cleared by %s while its save was in flight', + (clear) => { + const secrets = new ProtectedSecretPersistence() + const update = prepare(secrets, 'slot', 'first') + if (clear === 'remove') { + secrets.removeRetainedBlob('slot') + } else if (clear === 'empty encryption') { + secrets.encrypt('slot', '') + } else { + secrets.decrypt('slot', '') + } + + secrets.commitRetentionUpdates([update]) + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe('') + } + ) + + it.each(['replacement', ''])('preserves a reloaded sealed slot after an old %j save', (value) => { + const secrets = new ProtectedSecretPersistence() + secrets.decrypt('slot', ciphertext('original')) + const update = prepare(secrets, 'slot', value) + const reloaded = ciphertext('reloaded') + cipherState.available = false + secrets.decrypt('slot', reloaded) + + secrets.commitRetentionUpdates([update]) + + expect(secrets.isSealed('slot', reloaded)).toBe(true) + expect(secrets.encrypt('slot', '').blob).toBe(reloaded) + expect(secrets.hasPendingEncryption()).toBe(false) + }) + + it('preserves a successfully decrypted replacement after an older save acknowledges', () => { + const secrets = new ProtectedSecretPersistence() + const update = prepare(secrets, 'slot', 'first') + const reloaded = ciphertext('reloaded') + expect(secrets.decrypt('slot', reloaded)).toBe('reloaded') + + secrets.commitRetentionUpdates([update]) + + cipherState.available = false + expect(secrets.encrypt('slot', 'reloaded').blob).toBe(reloaded) + }) + + it.each(['unavailable', 'throws'])( + 'keeps a newer %s encryption pending after an old ack', + (failure) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const secrets = new ProtectedSecretPersistence() + const original = ciphertext('original') + secrets.decrypt('slot', original) + const update = prepare(secrets, 'slot', 'first') + cipherState.available = failure !== 'unavailable' + cipherState.fails = failure === 'throws' + expect(secrets.encrypt('slot', 'newer')).toEqual({ blob: original, degraded: true }) + + secrets.commitRetentionUpdates([update]) + + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = false + expect(secrets.encrypt('slot', 'newer').blob).toBe(original) + cipherState.available = true + cipherState.fails = false + const retry = prepare(secrets, 'slot', 'newer') + secrets.commitRetentionUpdates([retry]) + expect(secrets.hasPendingEncryption()).toBe(false) + cipherState.available = false + expect(secrets.encrypt('slot', 'newer').blob).toBe(ciphertext('newer')) + } + ) + + it.each([false, true])( + 'retains only the latest prepared ciphertext, reverse ack order: %s', + (reverse) => { + const secrets = new ProtectedSecretPersistence() + const older = prepare(secrets, 'slot', 'older') + const newer = prepare(secrets, 'slot', 'newer') + for (const update of reverse ? [newer, older] : [older, newer]) { + secrets.commitRetentionUpdates([update]) + } + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + } + ) + + it('retains the last same-slot value in a single acknowledged preparation batch', () => { + const secrets = new ProtectedSecretPersistence() + const updates = [prepare(secrets, 'slot', 'older'), prepare(secrets, 'slot', 'newer')] + + secrets.commitRetentionUpdates(updates) + + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + }) + + it('does not reuse an old acknowledgement when a removed dynamic slot is recreated', () => { + const secrets = new ProtectedSecretPersistence() + const removed = prepare(secrets, 'dynamic-slot', 'removed') + secrets.removeRetainedBlob('dynamic-slot') + const recreated = prepare(secrets, 'dynamic-slot', 'recreated') + + secrets.commitRetentionUpdates([removed, recreated, removed]) + + cipherState.available = false + expect(secrets.encrypt('dynamic-slot', 'replacement').blob).toBe(ciphertext('recreated')) + }) + + it('invalidates only the changed slot in an acknowledged batch', () => { + const secrets = new ProtectedSecretPersistence() + const updates = [prepare(secrets, 'keep', 'keep'), prepare(secrets, 'remove', 'remove')] + secrets.removeRetainedBlob('remove') + + secrets.commitRetentionUpdates(updates) + + cipherState.available = false + expect(secrets.encrypt('keep', 'replacement').blob).toBe(ciphertext('keep')) + expect(secrets.encrypt('remove', 'replacement').blob).toBe('') + }) + + it('does not accept a prepared update from a replaced persistence instance', () => { + const previous = new ProtectedSecretPersistence() + const current = new ProtectedSecretPersistence() + const older = prepare(previous, 'slot', 'older') + const newer = prepare(current, 'slot', 'newer') + + current.commitRetentionUpdates([older, newer]) + + cipherState.available = false + expect(current.encrypt('slot', 'replacement').blob).toBe(ciphertext('newer')) + }) + + it('keeps the previous ciphertext and pending retry when a prepared save has no confirmed ack', () => { + const secrets = new ProtectedSecretPersistence() + const original = ciphertext('original') + secrets.decrypt('slot', original) + cipherState.available = false + secrets.encrypt('slot', 'replacement') + cipherState.available = true + + prepare(secrets, 'slot', 'replacement') + + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = false + expect(secrets.encrypt('slot', 'replacement').blob).toBe(original) + }) +}) diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts index 28a9ca6cf43..cbd1561f344 100644 --- a/src/main/protected-secret-persistence.test.ts +++ b/src/main/protected-secret-persistence.test.ts @@ -62,6 +62,7 @@ describe('ProtectedSecretPersistence', () => { degraded: true, hashValue: ciphertext }) + expect(secrets.hasPendingEncryption()).toBe(false) cipherState.available = true expect(secrets.encrypt(slot, '')).toEqual({ @@ -74,4 +75,34 @@ describe('ProtectedSecretPersistence', () => { secrets.removeRetainedBlob(slot) expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false }) }) + + it('keeps deferred encryption pending until its retention update commits', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + cipherState.available = false + secrets.encrypt('slot', 'pending') + expect(secrets.hasPendingEncryption()).toBe(true) + cipherState.available = true + const encrypted = secrets.encrypt('slot', 'pending') + expect(secrets.hasPendingEncryption()).toBe(true) + if (!encrypted.retentionUpdate) { + throw new Error('Expected a retention update') + } + secrets.commitRetentionUpdates([encrypted.retentionUpdate]) + expect(secrets.hasPendingEncryption()).toBe(false) + }) + + it('retires a deferred empty secret without retaining a phantom retry', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + cipherState.available = false + secrets.encrypt('slot', 'pending') + const cleared = secrets.encrypt('slot', '') + expect(secrets.hasPendingEncryption()).toBe(true) + if (!cleared.retentionUpdate) { + throw new Error('Expected a retention update') + } + secrets.commitRetentionUpdates([cleared.retentionUpdate]) + expect(secrets.hasPendingEncryption()).toBe(false) + }) }) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index 90bb2ee334d..c72af096619 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -19,6 +19,7 @@ export type ProtectedSecretDecryption = { export type ProtectedSecretRetentionUpdate = { slot: string blob: string | null + epoch: symbol } export type LegacyPlaintextValidator = (value: string) => boolean @@ -34,10 +35,18 @@ type ProtectedSecretEncryption = { export class ProtectedSecretPersistence { private readonly retainedBlobs = new Map() private readonly sealedSlots = new Set() + private readonly pendingEncryption = new Set() + private readonly retentionEpochs = new Map() + + hasPendingEncryption(): boolean { + return this.pendingEncryption.size > 0 + } removeRetainedBlob(slot: string): void { + this.retentionEpochs.delete(slot) this.retainedBlobs.delete(slot) this.sealedSlots.delete(slot) + this.pendingEncryption.delete(slot) } isSealed(slot: string, value: string): boolean { @@ -46,6 +55,12 @@ export class ProtectedSecretPersistence { commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { for (const update of updates) { + // A delayed save must not overwrite a newer secret decision. + if (this.retentionEpochs.get(update.slot) !== update.epoch) { + continue + } + this.retentionEpochs.delete(update.slot) + this.pendingEncryption.delete(update.slot) if (update.blob === null) { this.removeRetainedBlob(update.slot) } else { @@ -56,11 +71,21 @@ export class ProtectedSecretPersistence { } encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { + this.retentionEpochs.delete(slot) const retained = this.retainedBlobs.get(slot) ?? '' if (!plaintext && !retained) { - return { blob: '', degraded: false } + return { + blob: '', + degraded: false, + ...(this.pendingEncryption.has(slot) + ? { retentionUpdate: this.prepareRetentionUpdate(slot, null) } + : {}) + } } if (!this.encryptionAvailable()) { + if (!this.isSealed(slot, plaintext) && (plaintext || !this.sealedSlots.has(slot))) { + this.pendingEncryption.add(slot) + } return { blob: retained, degraded: true, @@ -74,7 +99,7 @@ export class ProtectedSecretPersistence { return { blob: '', degraded: false, - retentionUpdate: { slot, blob: null } + retentionUpdate: this.prepareRetentionUpdate(slot, null) } } try { @@ -82,9 +107,10 @@ export class ProtectedSecretPersistence { return { blob, degraded: false, - retentionUpdate: { slot, blob } + retentionUpdate: this.prepareRetentionUpdate(slot, blob) } } catch (err) { + this.pendingEncryption.add(slot) console.error('[persistence] Encryption failed; retaining the prior protected value:', err) return { blob: retained, degraded: true } } @@ -99,6 +125,7 @@ export class ProtectedSecretPersistence { ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator ): ProtectedSecretDecryption { + this.retentionEpochs.delete(slot) if (!ciphertext) { this.removeRetainedBlob(slot) return { plaintext: '', status: 'decrypted' } @@ -129,6 +156,15 @@ export class ProtectedSecretPersistence { } } + private prepareRetentionUpdate( + slot: string, + blob: string | null + ): ProtectedSecretRetentionUpdate { + const epoch = Symbol() + this.retentionEpochs.set(slot, epoch) + return { slot, blob, epoch } + } + private encryptionAvailable(): boolean { // Why getSecretStore() sits outside the try: an uninstalled store is a startup bug, // not a keyring failure. Swallowing it would degrade to an empty blob and report diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts index 94528481622..4e2ebdf6026 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { vi, type Mock } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -101,7 +102,7 @@ function createHarness( badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -117,7 +118,7 @@ function createHarness( throw flushError } }) - } + }) const acknowledged = makeDeferred() let closeTerminalTabError: Error | null = null let closeTerminalTabAction: (() => void | Promise) | null = null @@ -201,7 +202,7 @@ function createHarness( } } graph.syncFixtureGraph() - return { + return withDurableRuntimeStore({ runtime, acknowledged, closeTerminal, @@ -264,7 +265,7 @@ function createHarness( } } } - } + }) } function createPtyBackedPublishedSurfaceHarness(): CloseContinuityHarness { diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 2173d080dcb..51cd8b4cd8b 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -5,6 +5,8 @@ import { vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { DelayedAuthority } from '../persistence/loading-store/profile-state-delayed-authority-fixture' import { Store } from '../persistence/loading-store/store' import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' @@ -28,7 +30,13 @@ function deferred(): { promise: Promise; resolve: () => void } { export function createAcknowledgedTabRetirementFixture(bound = false) { const directory = mkdtempSync(join(tmpdir(), 'orca-close-ack-')) - const store = new Store({ dataFile: join(directory, 'orca-data.json') }) + const authority = new DelayedAuthority( + new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'ack-retirement') + ) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: authority + }) store.addRepo({ id: 'repo1', path: '/tmp/worktree', @@ -150,7 +158,7 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { ACK_TAB ) store.setWorkspaceSession({ ...closed.session, terminalTopologyRevisionByRepoId: undefined }) - store.flushOrThrow() + await store.flushPendingOrThrowAsync() entered.resolve() await acknowledgement.promise }) @@ -171,6 +179,7 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { return { runtime, store, + authority, entered, acknowledgement, closeTerminalTab, @@ -182,9 +191,8 @@ export function createAcknowledgedTabRetirementFixture(bound = false) { dispose: async () => { runtime.setNotifier(null) runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) - store.flush() - store.freezeWrites() - await store.waitForPendingWrite() + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() setRuntimeDesktopSurface(null) rmSync(directory, { recursive: true, force: true }) } diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 91c3cb1b1d6..179dba29bd1 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -8,6 +8,7 @@ import { createAcknowledgedTabRetirementFixture } from './acknowledged-terminal-tab-retirement-fixture' import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' const fixtures: ReturnType[] = [] afterEach(async () => { @@ -78,7 +79,7 @@ it.each([false, true])( } const pending = f.close() await f.entered.promise - f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) expect(f.store.getWorkspaceSession().terminalLayoutsByTabId[ACK_TAB].ptyIdsByLeafId).toEqual({ [ACK_SECOND_LEAF]: 'pty-b' }) @@ -103,7 +104,7 @@ it('protects a persisted incarnation replacement on the same leaf and raw PTY ID const f = fixture(true) const pending = f.close() await f.entered.promise - f.store.persistPtyBinding({ + await f.store.persistPtyBinding({ worktreeId: ACK_WORKTREE, tabId: ACK_TAB, leafId: ACK_LEAF, @@ -133,13 +134,27 @@ it('rechecks current pins after renderer acknowledgement', async () => { f.acknowledgement.resolve() await expect(pending).rejects.toThrow('terminal_tab_pinned') expect(f.hasTab()).toBe(true) + await expect(f.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() +}) + +it('keeps persistence writable when worktree teardown finds remaining terminal rows', async () => { + const f = fixture() + f.store.updateRepo('repo1', { executionHostId: 'ssh:target' }) + f.store.setWorktreeMeta(ACK_WORKTREE, { hostId: 'ssh:target' }) + f.store.setWorkspaceSession(f.store.getWorkspaceSession(), 'ssh:target') + vi.spyOn(f.runtime, 'closeMobileSessionTab').mockResolvedValue(delegatedMobileSessionTabClose()) + await expect(f.runtime.closeTerminalsForWorktree(`id:${ACK_WORKTREE}`)).rejects.toThrow( + 'terminal_close_incomplete' + ) + expect(f.hasTab()).toBe(true) + await expect(f.store.flushPendingOrThrowAsync()).resolves.toBeUndefined() }) it('preserves dormant SSH kill IDs when the acknowledged tab becomes headless', async () => { const f = fixture() const visible = 'ssh:target@@visible' const dormant = 'ssh:target@@persisted-only' - f.store.persistPtyBinding({ + await f.store.persistPtyBinding({ worktreeId: ACK_WORKTREE, tabId: ACK_TAB, leafId: ACK_LEAF, diff --git a/src/main/runtime/automation-change-publication.test.ts b/src/main/runtime/automation-change-publication.test.ts index 5cde275d5ea..f9fc8508cab 100644 --- a/src/main/runtime/automation-change-publication.test.ts +++ b/src/main/runtime/automation-change-publication.test.ts @@ -132,9 +132,36 @@ afterEach(() => { }) describe('scoped automationsChanged publication', () => { + it.each(['update', 'delete'] as const)( + 'waits for durable %s before publishing success', + async (operation) => { + const { store, runtime, published } = await makeRuntime() + const gate = Promise.withResolvers() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockReturnValue(gate.promise) + const pending = + operation === 'update' + ? runtime.updateAutomation('local-1', { name: 'Changed' }) + : runtime.deleteAutomation('local-1') + await vi.waitFor(() => expect(store.flushPendingOrThrowAsync).toHaveBeenCalledOnce()) + expect(published).toEqual([]) + gate.resolve() + await pending + expect(published).toHaveLength(1) + } + ) + + it('rejects a failed durable definition write without publishing success', async () => { + const { store, runtime, published } = await makeRuntime() + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValue(new Error('disk full')) + await expect(runtime.updateAutomation('local-1', { name: 'Changed' })).rejects.toThrow( + 'disk full' + ) + expect(published).toEqual([]) + }) + it('names the host a delete removed a row from', async () => { const { runtime, published } = await makeRuntime() - runtime.deleteAutomation('ssh-1-a', { + await runtime.deleteAutomation('ssh-1-a', { selector: { kind: 'ssh', targetId: 'ssh-1', targetGeneration: 7 } }) expect(published).toEqual([ @@ -144,7 +171,7 @@ describe('scoped automationsChanged publication', () => { it('names the orphan bucket when an unowned row is deleted', async () => { const { runtime, published } = await makeRuntime() - runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) + await runtime.deleteAutomation('orphan-1', { selector: { kind: 'orphan' } }) expect(published).toEqual([{ reason: 'definition', selector: { kind: 'orphan' } }]) }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 16a0844f621..186f4e0f30d 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -349,6 +349,7 @@ describe('a structured Claude session over agentSession.*', () => { }) it('leaves unlisted shell exports out when inheritance is off', async () => { + vi.stubEnv('CODEX_LB_API_KEY', undefined) shellEnv = { ...shellEnv, CODEX_LB_API_KEY: 'shell-exported', LISTED_ONLY: 'yes' } shellEnvironmentPolicy = { inheritAll: false, names: ['LISTED_ONLY'] } diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index db14f74bad8..43eb15f66b3 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' @@ -66,7 +67,8 @@ function createRuntimeInternals( [WORKSPACE_A]: { hostId: 'local' }, [WORKSPACE_B]: { hostId: 'local' } } - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [REPO], getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, @@ -78,7 +80,7 @@ function createRuntimeInternals( getWorkspaceSession: () => options.session ?? getDefaultWorkspaceSession(), setWorkspaceSession: () => {}, flushOrThrow: () => {} - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, diff --git a/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts b/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts index e91f18e8c6d..a824191bb90 100644 --- a/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts +++ b/src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' /** * shouldPreserveHeadlessMobileSessionTab excludes the daemon ptyId form * @@ from its runtime-owned checks, so a host-created terminal @@ -51,7 +52,7 @@ function createHarness() { // Starts empty: only the create path may put this terminal in the session. let session: WorkspaceSessionState = { ...getDefaultWorkspaceSession() } const repo = makeRepo() - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -63,7 +64,7 @@ function createHarness() { session = next }, flushOrThrow: () => {} - } + }) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal: vi.fn(), diff --git a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts index b33579b783c..1a0344f393b 100644 --- a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts +++ b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import { OrcaRuntimeService } from './orca-runtime' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { @@ -25,7 +26,7 @@ function makeStore() { return { getWorkspaceSession: vi.fn(() => session), setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn(), + runDurableMutation: async (mutate: () => DurableProfileStateMutation) => mutate().value, getRepos: vi.fn(() => [ { id: 'repo-1', @@ -68,7 +69,7 @@ function storedSnapshot(tabs: RuntimeMobileSessionTerminalTab[]): RuntimeMobileS } } -function closeOneTab(): RuntimeMobileSessionTabsSnapshot { +async function closeOneTab(): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore covers the reads this suite drives. const runtime = new OrcaRuntimeService(makeStore() as never) const closedTab = terminalTab('tab-a', LEAF_ID) @@ -80,11 +81,11 @@ function closeOneTab(): RuntimeMobileSessionTabsSnapshot { snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, options?: Record - ) => void + ) => Promise mobileSessionTabsByWorktree: Map } internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, snapshot) - internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { + await internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, closedTab, { allowMissingPersistedTab: true, killPtys: false }) @@ -96,12 +97,12 @@ function closeOneTab(): RuntimeMobileSessionTabsSnapshot { } describe('closing a headless mobile terminal tab', () => { - it('keeps the worktree under the epoch that was already publishing it', () => { - expect(closeOneTab().publicationEpoch).toBe(LIVE_EPOCH) + it('keeps the worktree under the epoch that was already publishing it', async () => { + expect((await closeOneTab()).publicationEpoch).toBe(LIVE_EPOCH) }) - it('still advances the version so clients accept the frame', () => { - const published = closeOneTab() + it('still advances the version so clients accept the frame', async () => { + const published = await closeOneTab() expect(published.snapshotVersion).toBe(5) expect(published.tabs.map((tab) => tab.id)).toEqual([`tab-b::${LEAF_ID}`]) }) diff --git a/src/main/runtime/host-terminal-close-persistence-durability.test.ts b/src/main/runtime/host-terminal-close-persistence-durability.test.ts index 66a34f16ea6..642edd6cf17 100644 --- a/src/main/runtime/host-terminal-close-persistence-durability.test.ts +++ b/src/main/runtime/host-terminal-close-persistence-durability.test.ts @@ -62,7 +62,7 @@ describe('host-created terminal close durability', () => { it('a host-created terminal does NOT push a fresh repo into host-authoritative membership', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) const session = store.getWorkspaceSession() expect(session.tabsByWorktree?.[WT]?.map((t) => t.id)).toContain(TAB) // advanceTopologyFence (store.ts:3284) deliberately declines to arm the @@ -73,14 +73,14 @@ describe('host-created terminal close durability', () => { it('a renderer close write durably removes the row it persisted', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) expect(store.getWorkspaceSession().tabsByWorktree?.[WT] ?? []).toEqual([]) }) it('stays removed with the PTY still connected and no exit ever delivered', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) // Kill-failure shape: no retirement, no exit, just more renderer writes. for (let i = 0; i < 3; i += 1) { @@ -96,7 +96,7 @@ describe('host-created terminal close durability', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } }) - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) store.setWorkspaceSession(rendererWriteWithout(store.getWorkspaceSession(), TAB)) // Documents PRE-EXISTING behavior: with the fence already armed, a renderer // write that omits a row is treated as a stale replay and the row survives @@ -125,7 +125,7 @@ describe('topology fence census', () => { { startupCwd: '/tmp/wt-cli' } ].entries()) { const store = await makeStore() - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WT, tabId: `${TAB}-${index}`, leafId: LEAF, @@ -138,8 +138,8 @@ describe('topology fence census', () => { it('a second pane in the same tab still does not arm the fence', async () => { const store = await makeStore() - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: '22222222-2222-4222-8222-222222222222', @@ -154,7 +154,7 @@ describe('topology fence census', () => { ...store.getWorkspaceSession(), terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 } }) - store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) + await store.persistPtyBinding({ worktreeId: WT, tabId: TAB, leafId: LEAF, ptyId: PTY }) expect( store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.[REPO_ID] ?? 0 ).toBeGreaterThan(1) diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index e8caba4ddf3..0f027e8c68d 100644 --- a/src/main/runtime/mobile-session-terminal-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -115,7 +115,15 @@ function chooseGroupActiveTab( if (group.activeTabId && retainedTabIds.has(group.activeTabId)) { return group.activeTabId } - const recent = (group.recentTabIds ?? []).toReversed().find((tabId) => retainedTabIds.has(tabId)) + // Node 18 is the orcad floor and does not provide Array.prototype.toReversed. + let recent: string | undefined + for (let index = (group.recentTabIds?.length ?? 0) - 1; index >= 0; index -= 1) { + const tabId = group.recentTabIds?.[index] + if (tabId && retainedTabIds.has(tabId)) { + recent = tabId + break + } + } return recent ?? group.tabOrder.find((tabId) => retainedTabIds.has(tabId)) ?? null } diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index fe65979ed1e..662ea04c3da 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -120,12 +120,13 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg deleteAutomation( id: string, expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { + ): Promise<{ removed: boolean; id: string }> { return this.automation.withExternalProbePriority(() => { const selector = this.automationChangeSelector(id) - const result = this.automation.delete(id, expectedOwner as never) - this.publishAutomationDefinitionChange(selector, selector) - return result + return this.automation.delete(id, expectedOwner).then((result) => { + this.publishAutomationDefinitionChange(selector, selector) + return result + }) }) } diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 9c2ea907c91..26cbdec3d13 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -187,7 +187,7 @@ describe('OrcaRuntimeService automation methods', () => { const runtime = new OrcaRuntimeService(store as never) const updated = await runtime.updateAutomation('auto-1', { enabled: false }) - const removed = runtime.deleteAutomation('auto-1') + const removed = await runtime.deleteAutomation('auto-1') expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }, undefined) expect(updated).toMatchObject({ diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 5aa6e46cc58..dda3caa7cff 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -11,7 +11,9 @@ import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-sessi import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -78,44 +80,78 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu return tab ? { color: tab.color, isPinned: tab.isPinned } : null } - protected commitHeadlessTerminalTabRetirement( + protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { + const originalHostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { + const resolvedHostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) + // Emptying the last tab may reroute the worktree to its catalog host. + const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ? resolvedHostId + : originalHostId + return { + hostId, + session: this.store?.getWorkspaceSession?.(hostId) ?? null, + snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), + incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId + } + }) + } + + protected async commitHeadlessTerminalTabRetirement( worktreeId: string, parentTabId: string, options: { allowMissing?: boolean; force?: boolean } = {} - ): string[] { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) { + ): Promise { + if (!this.store?.setWorkspaceSession || !this.store.runDurableMutation) { throw new Error('workspace_session_unavailable') } - const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, { - force: options.force - }) - if (result.pinned) { - throw new Error('terminal_tab_pinned') - } - if (!result.closed) { - if (!options.allowMissing) { - throw new Error('tab_not_found') + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, parentTabId) + const committed = await this.store.runDurableMutation(() => { + if (!acknowledgeRetirement().matches) { + return { value: new Error('terminal_pane_owner_changed'), persist: false } } - } - const persisted = result.closed - ? advanceTerminalTopologyRevision(result.session, worktreeId) - : session - this.setWorkspaceSessionForWorktree(worktreeId, persisted) - const staged = this.getWorkspaceSessionForWorktree(worktreeId) - try { - this.store.flushOrThrow() - } catch (error) { - const current = this.getWorkspaceSessionForWorktree(worktreeId) - if (staged && current) { - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.setWorkspaceSessionForWorktree(worktreeId, rolledBack) + const hostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + const currentSession = this.store.getWorkspaceSession(hostId) + if (!currentSession) { + return { value: new Error('workspace_session_unavailable'), persist: false } + } + const session = cloneWorkspaceSessionState(currentSession) + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, { + force: options.force + }) + if (result.pinned) { + return { value: new Error('terminal_tab_pinned'), persist: false } + } + if (!result.closed && !options.allowMissing) { + return { value: new Error('tab_not_found'), persist: false } + } + const persisted = result.closed + ? advanceTerminalTopologyRevision(result.session, worktreeId) + : session + this.store.setWorkspaceSession(persisted, hostId) + const staged = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + return { + value: result.ptyIdsToKill, + rollback: () => { + const current = this.store.getWorkspaceSession(hostId) + if (current) { + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + session, + staged, + current + ) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } } } - throw error + }) + if (committed instanceof Error) { + throw committed } - return result.ptyIdsToKill + return committed } protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 159a709fd3c..d7a169d00e1 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -1,7 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithGetPtyRecordForPaneKey } from './orca-runtime-get-pty-record-for-pane-key' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { runtimeWorktreeIdentityKey, type ResolvedWorktree } from './runtime-worktree-path-identity' import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../shared/runtime-types' import { getLatestPtyTitle } from './runtime-worktree-status-projection' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -28,6 +28,14 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt const title = getLatestPtyTitle(pty) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) + // A live process awaiting its pane binding is not evidence of an orphan. + if ( + orphaned && + (this.pendingPtyRegistrationIncarnations.has(pty.ptyId) || + this.terminalMutationLock.hasActiveSpawns(runtimeWorktreeIdentityKey(pty.worktreeId))) + ) { + throw new Error('terminal_surface_ownership_unavailable') + } return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index 1f5498043c6..a981a09dc2d 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -13,7 +13,7 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab { - protected closeHeadlessMobileTerminalTab( + protected async closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, @@ -23,7 +23,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi authorizedPty?: RuntimePtyWorktreeRecord force?: boolean } = {} - ): void { + ): Promise { const closedParentTabId = tab.parentTabId const retirementProofs = snapshot.tabs.flatMap((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { @@ -36,11 +36,17 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi ) return proof ? [proof] : [] }) - const projectedPtyIds = this.commitHeadlessTerminalTabRetirement( + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, closedParentTabId) + const projectedPtyIds = await this.commitHeadlessTerminalTabRetirement( worktreeId, closedParentTabId, { allowMissing: options.allowMissingPersistedTab, force: options.force } ) + if (!acknowledgeRetirement().matches) { + throw new Error('terminal_pane_owner_changed') + } + // Renderer frames may add other tabs while the durable close is in flight. + snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) ?? snapshot this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) if (options.authorizedPty) { options.authorizedPty.runtimeSessionOwned = false diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 2ef5b77069d..fa624d6f3ce 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -20,7 +20,6 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' -import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { @@ -167,7 +166,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU // the relay when no renderer owns the parent: an adopted tab needs the // renderer's live pin guard and durable close transaction. if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { allowMissingPersistedTab: Boolean(ptyCloseAuthority), force: options.force, killPtys: @@ -180,16 +179,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } if (closingWholeParent && this.notifier?.closeTerminalTab) { // The renderer flush can rebase its omission; the host commits the acknowledged identity. - const acknowledgeRetirement = captureAcknowledgedTerminalTabRetirement( - worktreeId, - tab.parentTabId, - () => ({ - hostId: this.getWorkspaceSessionHostIdForWorktree(worktreeId), - session: this.getWorkspaceSessionForWorktree(worktreeId), - snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), - incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId - }) - ) + const acknowledgeRetirement = this.captureTerminalTabRetirement(worktreeId, tab.parentTabId) // Wait for the renderer's pin guard, retirement and forced session flush. const win = this.getAvailableAuthoritativeWindow() if (win?.webContents.isDestroyed?.()) { @@ -230,7 +220,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) : null // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { // Why: the renderer may already have durably removed the tab before acknowledging. allowMissingPersistedTab: true, force: options.force, @@ -238,17 +228,21 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU }) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) } else if (retirement.hasPersistedTab) { - this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, { + await this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, { force: options.force }) } + if (!acknowledgeRetirement().matches) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return refusedMobileSessionTabClose('stale-terminal', { snapshotRepublished: true }) + } this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) return finishCommittedClose() } // Why: notifier implementations without the acknowledged relay may expose // only raw pane close. Runtime-owned parents still need de-persist + kill. if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { force: options.force, ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) }) @@ -256,7 +250,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU return finishCommittedClose() } if (!this.notifier?.closeTerminal) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { + await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { force: options.force, ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) }) diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 5ef17ce4c2e..6d4242d7758 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -74,6 +74,7 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected providerSnapshotsWithLiveModeTransition = new WeakSet() protected ptyLifecycleGenerationById = new Map() + protected pendingPtySurfaceRetirementsByPtyId = new Map() protected nextPtyLifecycleGeneration = 1 diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 4d2219e3e56..39b8dbf0dcf 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -15,6 +15,7 @@ import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { @@ -46,6 +47,17 @@ type RuntimeStatusHost = { ): string[] } +function supportsDurableTerminalPromptDelivery(): boolean { + if (typeof process.getBuiltinModule !== 'function') { + return false + } + try { + return process.getBuiltinModule('node:sqlite') !== undefined + } catch { + return false + } +} + export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { private asRuntimeStatusHost(): RuntimeStatusHost { return this as unknown as RuntimeStatusHost @@ -76,7 +88,9 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || - capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) && + (capability !== TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY || + supportsDurableTerminalPromptDelivery()) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) diff --git a/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts b/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts index 9d6e5d56ca4..a348309a568 100644 --- a/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts +++ b/src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts @@ -140,6 +140,7 @@ export class OrcaRuntimeWithInvalidateAllHandlesForPty extends OrcaRuntimeWithRe options: { awaitsRegistration?: boolean } = {} ): void { this.invalidatePtyControllerInventoryForLifecycle(ptyId) + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) const existingPty = this.ptysById.get(ptyId) if ( existingPty && diff --git a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts index 5780b353944..4260c8213ef 100644 --- a/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts +++ b/src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' /** * STA-4593 incident: closing paired-remote tabs "worked briefly" but the tabs * returned seconds later and after workspace switches, on a host whose PTYs @@ -103,7 +104,7 @@ function createHarness() { badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -115,7 +116,7 @@ function createHarness() { session = next }, flushOrThrow: () => {} - } + }) const relayAck = makeDeferred() const closeTerminal = vi.fn() const closeTerminalTab = vi.fn(() => relayAck.promise) @@ -277,7 +278,7 @@ function createSplitHarness() { badgeColor: '#000000', addedAt: 1 } - const store = { + const store = withDurableRuntimeStore({ getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), getAllWorktreeMeta: () => ({}), @@ -289,7 +290,7 @@ function createSplitHarness() { session = next }, flushOrThrow: () => {} - } + }) const runtime = new OrcaRuntimeService(store as never) runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab: vi.fn(async () => {}) } as never) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 588a834d6e6..99fd88373fa 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -24,7 +24,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ providerExitObserved?: boolean } = {} - ): void { + ): void | Promise { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { return @@ -75,7 +75,10 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte >() for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { for (const tab of snapshot.tabs) { - if (tab.type === 'terminal' && tab.ptyId === ptyId) { + if ( + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) { exactSurfaceByKey.set(`${worktreeId}\0${tab.parentTabId}\0${tab.leafId}`, { worktreeId, parentTabId: tab.parentTabId, @@ -139,11 +142,8 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) - // Safe against respawn: `getPtyLifecycleGeneration` lazily mints from the - // monotonic `nextPtyLifecycleGeneration`, so a re-read after this delete - // returns a strictly newer number — never a reused one. Every comparison a - // stale frame makes therefore still fails, exactly as the advance above intends. this.ptyLifecycleGenerationById.delete(ptyId) + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) @@ -216,13 +216,24 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } + let retirement: Promise | undefined if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { // Why: permanent process exit is absence, not a starting/sleeping tab. // Retire before publishing so paired clients never persist a ghost. - this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + const pendingRetirement = {} + this.pendingPtySurfaceRetirementsByPtyId.set(ptyId, pendingRetirement) + retirement = this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) + .catch((error) => { + console.error('[runtime] failed to publish terminal retirement:', error) + }) + .finally(() => { + if (this.pendingPtySurfaceRetirementsByPtyId.get(ptyId) === pendingRetirement) { + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) + } + }) } const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] @@ -253,6 +264,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte } } this.pruneDisconnectedPtyRecords() + return retirement } private notifyPtyExitListeners(ptyId: string): void { diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index 6b9887d2cf8..bafc280d957 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -8,7 +8,8 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntimeWithTouchMobileSessionTabsForWorktree { @@ -18,92 +19,77 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim * against the local partition strands the real ghost and bumps a foreign host's epoch. * Returns null when nothing may be published because persistence is unavailable or failed. */ - protected persistTerminalSurfaceRetirements( + protected async persistTerminalSurfaceRetirements( retiredSurfaces: readonly RetiredTerminalSurface[] - ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { - const surfacesByHostId = new Map() - for (const surface of retiredSurfaces) { - const hostId = - this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID - const bucket = surfacesByHostId.get(hostId) - if (bucket) { - bucket.push(surface) - } else { - surfacesByHostId.set(hostId, [surface]) - } + ): Promise<{ accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null> { + if (!this.store?.runDurableMutation) { + const hasPersistedSession = retiredSurfaces.some((surface) => + this.store?.getWorkspaceSession?.( + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? + LOCAL_EXECUTION_HOST_ID + ) + ) + return hasPersistedSession ? null : { accepted: [], unpersisted: [...retiredSurfaces] } } - const accepted: RetiredTerminalSurface[] = [] - const unpersisted: RetiredTerminalSurface[] = [] - const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] - const originalSessions = new Map() - const stagedSessions = new Map() - for (const [hostId, surfaces] of surfacesByHostId) { - const session = this.store?.getWorkspaceSession?.(hostId) - if (!session) { - unpersisted.push(...surfaces) - continue - } - // Why: publishing absence before its host membership fence is durable lets a crash or - // stale renderer write resurrect the retired surface. - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - return null - } - originalSessions.set(hostId, session) - let nextSession = session - const acceptedForHost: RetiredTerminalSurface[] = [] - for (const surface of surfaces) { - const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) - if (candidate !== nextSession) { - acceptedForHost.push(surface) - nextSession = candidate - } - } - if (acceptedForHost.length === 0) { - continue - } - accepted.push(...acceptedForHost) - pendingWrites.push({ hostId, session: nextSession }) - } - if (pendingWrites.length > 0) { - try { - for (const write of pendingWrites) { - this.store?.setWorkspaceSession?.(write.session, write.hostId) - const staged = this.store?.getWorkspaceSession?.(write.hostId) - if (staged) { - stagedSessions.set(write.hostId, staged) - } - } - this.store?.flushOrThrow?.() - } catch (error) { - // setWorkspaceSession mutates the in-memory partition before the flush. Restore only - // fields still equal to our staged write so concurrent renderer updates survive. - for (const [hostId, original] of originalSessions) { - const staged = stagedSessions.get(hostId) - const current = this.store?.getWorkspaceSession?.(hostId) - if (!staged || !current) { + try { + return await this.store.runDurableMutation(() => { + const accepted: RetiredTerminalSurface[] = [] + const unpersisted: RetiredTerminalSurface[] = [] + const originals = new Map() + const staged = new Map() + for (const surface of retiredSurfaces) { + const hostId = + this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? + LOCAL_EXECUTION_HOST_ID + const current = this.store.getWorkspaceSession?.(hostId) + if (!current) { + unpersisted.push(surface) continue } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - staged, - current - ) - if (rolledBack !== current) { - this.store?.setWorkspaceSession?.(rolledBack, hostId) + if (!this.store.setWorkspaceSession) { + throw new Error('workspace_session_unavailable') + } + if (!originals.has(hostId)) { + originals.set(hostId, cloneWorkspaceSessionState(current)) + } + const next = retireTerminalSurfaceFromPersistence(current, surface) + if (next !== current) { + this.store.setWorkspaceSession(next, hostId) + staged.set(hostId, cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId))) + accepted.push(surface) } } - console.error('[runtime] failed to persist terminal retirement:', error) - return null - } + return { + value: { accepted, unpersisted }, + persist: staged.size > 0, + rollback: () => { + for (const [hostId, stagedSession] of staged) { + const current = this.store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + originals.get(hostId), + stagedSession, + current + ) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } + } + } + }) + } catch (error) { + console.error('[runtime] failed to persist terminal retirement:', error) + return null } - return { accepted, unpersisted } } - protected retireMobileSessionSurfacesForPty( + protected async retireMobileSessionSurfacesForPty( ptyId: string, incarnationId: string, exactSurfaces: readonly Pick[] - ): void { + ): Promise { + // Reads can mint a new frame generation while this independent retirement waits for disk. + const pendingRetirement = this.pendingPtySurfaceRetirementsByPtyId.get(ptyId) const terminalHandle = this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) ?? undefined const retiredSurfaceByKey = new Map() @@ -135,8 +121,13 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim if (retiredSurfaces.length === 0) { return } - const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) - if (!persisted) { + const persisted = await this.persistTerminalSurfaceRetirements(retiredSurfaces) + const currentIncarnation = this.ptysById.get(ptyId)?.incarnationId + if ( + !persisted || + this.pendingPtySurfaceRetirementsByPtyId.get(ptyId) !== pendingRetirement || + (currentIncarnation && currentIncarnation !== incarnationId) + ) { return } for (const surface of persisted.unpersisted) { diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 37a5434e5c5..31c1bc38627 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -27,6 +27,7 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand isWsl?: boolean ): void { this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) + this.pendingPtySurfaceRetirementsByPtyId.delete(ptyId) this.invalidatePtyControllerInventoryForLifecycle(ptyId, connectionId) const existingPty = this.ptysById.get(ptyId) const replacementHandle = binding?.terminalHandle?.trim() @@ -143,6 +144,12 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { + if ( + replacementHandle?.startsWith('term_') && + this.handleByPtyId.get(ptyId) !== replacementHandle + ) { + this.registerPreAllocatedHandleForPty(ptyId, replacementHandle) + } this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) } } diff --git a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts index 768448e2ac9..747384d2103 100644 --- a/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts @@ -11,7 +11,8 @@ import type { } from '../../shared/runtime-types' import type { WorktreeTerminalMutationKind } from './worktree-terminal-mutation-lock' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' import { getWorktreeExecutionHostId, parseExecutionHostId, @@ -88,7 +89,7 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso } closed += 1 } - this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) + await this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds) const { stopped } = await this.stopTerminalsForWorktree(`id:${worktree.id}`, { resolvedWorktreeId: worktree.id, ...hostFence @@ -109,60 +110,65 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso }) } - private clearWorktreeTerminalResumeRecords( + private async clearWorktreeTerminalResumeRecords( worktreeId: string, hostId: ExecutionHostId, closedTabIds: readonly string[] - ): void { + ): Promise { if ( !this.store?.getWorkspaceSession || !this.store.setWorkspaceSession || - !this.store.flushOrThrow + !this.store.runDurableMutation ) { throw new Error('workspace_session_unavailable') } - const session = this.store.getWorkspaceSession(hostId) - const sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([, record]) => record.worktreeId !== worktreeId + const refusal = await this.store.runDurableMutation(() => { + const session = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + const sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([, record]) => record.worktreeId !== worktreeId + ) ) - ) - const terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( - ([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`)) + const terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter( + ([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`)) + ) ) - ) - const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? [] - const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( - (tab) => tab.contentType === 'terminal' - ) - if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { - throw new Error('terminal_close_incomplete') - } - const hasChanges = - Object.keys(sleepingAgentSessionsByPaneKey).length !== - Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length || - Object.keys(terminalPtyIncarnationsByPaneKey).length !== - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length - if (!hasChanges) { - return - } - const next: WorkspaceSessionState = { - ...session, - sleepingAgentSessionsByPaneKey, - terminalPtyIncarnationsByPaneKey - } - this.store.setWorkspaceSession(next, hostId) - const staged = this.store.getWorkspaceSession(hostId) - try { - this.store.flushOrThrow() - } catch (error) { - const current = this.store.getWorkspaceSession(hostId) - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.store.setWorkspaceSession(rolledBack, hostId) + const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? [] + const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'terminal' + ) + if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) { + return { value: new Error('terminal_close_incomplete'), persist: false } } - throw error + const hasChanges = + Object.keys(sleepingAgentSessionsByPaneKey).length !== + Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length || + Object.keys(terminalPtyIncarnationsByPaneKey).length !== + Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length + if (!hasChanges) { + return { value: undefined, persist: false } + } + const next: WorkspaceSessionState = { + ...session, + sleepingAgentSessionsByPaneKey, + terminalPtyIncarnationsByPaneKey + } + this.store.setWorkspaceSession(next, hostId) + const staged = cloneWorkspaceSessionState(this.store.getWorkspaceSession(hostId)) + return { + value: undefined, + rollback: () => { + const current = this.store.getWorkspaceSession(hostId) + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.store.setWorkspaceSession(rolledBack, hostId) + } + } + } + }) + if (refusal) { + throw refusal } } diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts index 3079ce1f4da..063866c83c5 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' @@ -123,7 +124,8 @@ function partitionedStore(): PartitionedStoreHarness { ]) const writes: { hostId: ExecutionHostId | undefined; session: WorkspaceSessionState }[] = [] const reads: (ExecutionHostId | undefined)[] = [] - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [SSH_REPO], getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), getWorkspaceSessionHostIds: () => [...sessions.keys()], @@ -136,7 +138,7 @@ function partitionedStore(): PartitionedStoreHarness { sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session) }, flushOrThrow: vi.fn() - } as never + }) as never return { store, sessions, writes, reads } } @@ -210,7 +212,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', } ] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), getWorktreeMeta: () => undefined, @@ -221,7 +224,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) => sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, @@ -285,7 +288,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', // The SSH copy of the same `repoId::path` currently has no terminals. [SSH_HOST_ID, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [SSH_REPO], getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined), getWorktreeMeta: () => ({ hostId: SSH_HOST_ID }), @@ -298,7 +302,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn(), persistPtyBinding: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) const stopAndWait = vi.fn(async () => true) runtime.setPtyController({ @@ -334,7 +338,8 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', ], [staleHostId, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }] ]) - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }], getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }), getWorktreeMeta: () => ({}), @@ -347,7 +352,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session), flushOrThrow: vi.fn(), persistPtyBinding: vi.fn() - } as never + }) as never const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ write: () => true, @@ -491,7 +496,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', incarnationId: 'incarnation-a' }) - runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') + await runtime.onPtyExit(SSH_PTY_LEFT, 0, 'incarnation-a') // The durable retirement must land in the pane's own host partition. expect(harness.writes.map((write) => write.hostId)).toEqual([SSH_HOST_ID]) @@ -624,7 +629,7 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(harness.writes.map((write) => write.hostId ?? LOCAL_EXECUTION_HOST_ID)).toEqual([ LOCAL_EXECUTION_HOST_ID diff --git a/src/main/runtime/orca-runtime-terminal-retirement.test.ts b/src/main/runtime/orca-runtime-terminal-retirement.test.ts index e1a2c68242c..4422dc2a1e6 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -148,7 +149,7 @@ function makePersistedSplitSession(): WorkspaceSessionState { } describe('OrcaRuntimeService terminal surface retirement', () => { - it('releases each early-exit fence after its matching registration is rejected', () => { + it('releases each early-exit fence after its matching registration is rejected', async () => { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as { earlyExitedPtyIncarnations: Map @@ -158,7 +159,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const ptyId = `pty-early-${index}` const incarnationId = `incarnation-${index}` runtime.beginPtyRegistration(ptyId, incarnationId) - runtime.onPtyExit(ptyId, 0, incarnationId) + await runtime.onPtyExit(ptyId, 0, incarnationId) expect(() => runtime.assertPtyRegistrationAllowed(ptyId, incarnationId)).toThrow( 'agent_session_exited_during_start' ) @@ -168,7 +169,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { expect(internals.earlyExitedPtyIncarnations.size).toBe(0) }) - it('does not retain fences for completed surface-less lifecycles', () => { + it('does not retain fences for completed surface-less lifecycles', async () => { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as { earlyExitedPtyIncarnations: Map @@ -179,14 +180,14 @@ describe('OrcaRuntimeService terminal surface retirement', () => { runtime.onPtySpawned(`pty-headless-${index}`, `incarnation-${index}`, { awaitsRegistration: false }) - runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) + await runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`) } expect(internals.earlyExitedPtyIncarnations.size).toBe(0) expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0) }) - it('fences an early-exited replacement even when its pane already exists', () => { + it('fences an early-exited replacement even when its pane already exists', async () => { const runtime = new OrcaRuntimeService() runtime.attachWindow(1) syncSplit(runtime) @@ -197,7 +198,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }) runtime.onPtySpawned('pty-left', 'incarnation-replacement') - runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') + await runtime.onPtyExit('pty-left', 0, 'incarnation-replacement') expect(() => runtime.assertPtyRegistrationAllowed('pty-left', 'incarnation-replacement') @@ -224,7 +225,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { ? leftBeforeExit.terminal : null - runtime.onPtyExit('pty-left', 0) + await runtime.onPtyExit('pty-left', 0) expect(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).toMatchObject({ activeTabId: 'tab::right', @@ -467,16 +468,18 @@ describe('OrcaRuntimeService terminal surface retirement', () => { sleepingAgentSessionsByPaneKey: { 'tab:left': {} as never } } const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) syncSplit(runtime) - runtime.onPtyExit('pty-left', 0) + await runtime.onPtyExit('pty-left', 0) const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(result.tabs.find((tab) => tab.id === 'tab::left')).toBeUndefined() @@ -506,7 +509,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-b' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::left', status: 'ready' }), @@ -519,11 +522,13 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const session = makePersistedSplitSession() const setWorkspaceSession = vi.fn() const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -534,7 +539,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { }) runtime.acceptPtyIncarnationForExit('pty-left', 'incarnation-after-reconnect') - runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') + await runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::right', status: 'ready' }) @@ -567,11 +572,13 @@ describe('OrcaRuntimeService terminal surface retirement', () => { session = next }) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn() - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) ) runtime.attachWindow(1) const snapshot = makeSplitSnapshot() @@ -601,7 +608,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const published: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) - runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') + await runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting') expect(session.terminalLayoutsByTabId.tab).toMatchObject({ root: { type: 'leaf', leafId: 'right' }, @@ -626,16 +633,18 @@ describe('OrcaRuntimeService terminal surface retirement', () => { unsubscribe() }) - it('de-persists an exact surface even when there is no mobile snapshot', () => { + it('de-persists an exact surface even when there is no mobile snapshot', async () => { const session = makePersistedSplitSession() const setWorkspaceSession = vi.fn() const flushOrThrow = vi.fn() const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow - }) + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow + }) + ) ) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -664,7 +673,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(setWorkspaceSession).toHaveBeenCalledWith( expect.objectContaining({ @@ -686,13 +695,15 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const session = makePersistedSplitSession() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession: vi.fn(), - flushOrThrow: vi.fn(() => { - throw new Error('disk unavailable') + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) }) - }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -704,7 +715,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { const events: unknown[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => events.push(event)) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([ expect.objectContaining({ id: 'tab::left' }), @@ -726,13 +737,15 @@ describe('OrcaRuntimeService terminal surface retirement', () => { session = next }) const runtime = new OrcaRuntimeService( - runtimeStore({ - getWorkspaceSession: () => session, - setWorkspaceSession, - flushOrThrow: vi.fn(() => { - throw new Error('disk unavailable') + runtimeStore( + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession, + flushOrThrow: vi.fn(() => { + throw new Error('disk unavailable') + }) }) - }) + ) ) runtime.attachWindow(1) syncSplit(runtime) @@ -742,7 +755,7 @@ describe('OrcaRuntimeService terminal surface retirement', () => { incarnationId: 'incarnation-a' }) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') expect(session).toEqual(original) expect(setWorkspaceSession).toHaveBeenLastCalledWith(original, LOCAL_EXECUTION_HOST_ID) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index c72bde039b9..78a6089e3ed 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { expect, vi } from 'vitest' import { createHash } from 'node:crypto' import { HeadlessEmulator } from '../daemon/headless-emulator' @@ -25,6 +26,7 @@ import type { import { InMemoryOrchestrationMessages } from './orca-runtime-test-orchestration-messages.spec' import type { OrchestrationDb } from './orchestration/db' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { Store } from '../persistence' type RuntimeService = InstanceType type HeadlessTerminal = InstanceType @@ -558,9 +560,9 @@ function makeRuntimeStoreWithWorkspaceSession( ): { runtimeStore: typeof store & { getWorkspaceSession: (hostId?: string) => WorkspaceSessionState - setWorkspaceSession: ReturnType - flushOrThrow: ReturnType - persistPtyBinding: ReturnType + setWorkspaceSession: ReturnType> + flushOrThrow: ReturnType void>> + persistPtyBinding: ReturnType> } getSession: () => WorkspaceSessionState setSession: (next: WorkspaceSessionState) => void @@ -569,7 +571,7 @@ function makeRuntimeStoreWithWorkspaceSession( const setSession = (next: WorkspaceSessionState): void => { session = next } - const runtimeStore = { + const runtimeStore = withDurableRuntimeStore({ ...store, getWorkspaceSession: (hostId?: string) => hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), @@ -577,36 +579,38 @@ function makeRuntimeStoreWithWorkspaceSession( // Headless close is a durable transaction; keep the in-memory fixture's // persistence contract equivalent to the production store. flushOrThrow: vi.fn(), - persistPtyBinding: vi.fn( - (args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => { - const tabs = session.tabsByWorktree[args.worktreeId] ?? [] - session = { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [args.worktreeId]: tabs.map((tab) => - tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab - ) - }, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...(session.terminalLayoutsByTabId[args.tabId] ?? { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null - }), - ptyIdsByLeafId: { - ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } + persistPtyBinding: vi.fn(async (input) => { + const args = typeof input === 'function' ? input() : input + if (!args) { + return false + } + const tabs = session.tabsByWorktree[args.worktreeId] ?? [] + session = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [args.worktreeId]: tabs.map((tab) => + tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab + ) + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + ...(session.terminalLayoutsByTabId[args.tabId] ?? { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null + }), + ptyIdsByLeafId: { + ...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId, + [args.leafId]: args.ptyId } } } - return true } - ) - } + return true + }) + }) return { runtimeStore, getSession: () => session, setSession } } diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index 1f87d901c81..63dc6d8c94e 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import * as mocks from './orca-runtime-test-mocks.spec' import type { Mock } from 'vitest' @@ -211,7 +212,7 @@ function makePostRevealWorkerRecoveryHarness( } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts index 6f5410e82a8..8b6806aec2b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-04.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -177,13 +178,15 @@ describe('OrcaRuntimeService', () => { const getWorkspaceSession = vi.fn((hostId?: string | null) => hostId === 'ssh:ssh-1' ? sshSession : localSession ) - const runtime = new OrcaRuntimeService({ - ...store, - flushOrThrow: vi.fn(), - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), - getWorkspaceSession - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...store, + flushOrThrow: vi.fn(), + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession + }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -229,15 +232,17 @@ describe('OrcaRuntimeService', () => { sshSession = session }) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ - ...store, - getRepos: () => [remoteRepo], - getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), - getWorkspaceSession: (hostId?: string | null) => - hostId === 'ssh:ssh-1' ? sshSession : localSession, - setWorkspaceSession, - flushOrThrow: vi.fn() - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getWorkspaceSession: (hostId?: string | null) => + hostId === 'ssh:ssh-1' ? sshSession : localSession, + setWorkspaceSession, + flushOrThrow: vi.fn() + }) + ) runtime.setPtyController({ write: () => true, kill, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts index eb2af5c0a1b..d2861c21400 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-05.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import type { RuntimeMobileSessionTabsResult } from '../orca-runtime-test-mocks.spec' @@ -181,7 +182,7 @@ describe('OrcaRuntimeService', () => { }) events.length = 0 - runtime.onPtyExit('laptop-created-pty', 0) + await runtime.onPtyExit('laptop-created-pty', 0) expect(events).toEqual([ expect.objectContaining({ @@ -374,7 +375,9 @@ describe('OrcaRuntimeService', () => { const acknowledged = makeDeferred() const closeTerminalTab = vi.fn(() => acknowledged.promise) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) runtime.setPtyController({ write: () => true, @@ -510,7 +513,9 @@ describe('OrcaRuntimeService', () => { .mockResolvedValueOnce({ id: 'headless-left' }) .mockResolvedValueOnce({ id: 'headless-right' }) const kill = vi.fn(() => true) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }) + ) runtime.setPtyController({ spawn, write: () => true, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts index 518d6805c5b..4ac0faf4d58 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' @@ -442,7 +443,9 @@ describe('OrcaRuntimeService', () => { }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const closeTerminalTab = vi.fn(async () => {}) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }) + ) runtime.setPtyController({ write: () => true, kill, diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts index 81d78d4e308..88c988929f7 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-11.spec.ts @@ -409,7 +409,7 @@ describe('OrcaRuntimeService', () => { // republish would re-add the dead leaf on the echoing client and feed a // refuse→republish→re-echo loop. const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime() - runtime.onPtyExit('serve-right', 0) + await runtime.onPtyExit('serve-right', 0) const events: { worktree: string }[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts index 591f4e7ca97..2aba44299f2 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts @@ -333,7 +333,7 @@ describe('OrcaRuntimeService', () => { await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) setSession(getDefaultWorkspaceSession()) - runtimeStore.persistPtyBinding.mockReturnValue(false) + runtimeStore.persistPtyBinding.mockResolvedValue(false) resolveSpawn({ id: 'rejected-split-pty' }) await expect(split).rejects.toThrow('terminal_split_source_not_found') diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index e85aeb461c6..65757797f70 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -20,7 +21,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) let process = { id: 'reused-pty-id', incarnationId: 'inc-old', @@ -66,7 +69,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.syncWindowGraph(1, { tabs: [ { @@ -156,7 +161,9 @@ describe('OrcaRuntimeService', () => { [`duplicate-b:${HEADLESS_SECOND_LEAF_ID}`]: 'inc-duplicate' } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index ecf2ab53678..1ebf15cbc24 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -76,7 +77,9 @@ describe('OrcaRuntimeService', () => { ['pty-setup', 'inc-setup', 'term_setup', 'Setup'], ['pty-shell', 'inc-shell', 'term_shell', 'Shell'] ] as const - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const listProcesses = vi.fn(async () => processes.map(([id, incarnationId, terminalHandle, title]) => ({ id, @@ -247,13 +250,15 @@ describe('OrcaRuntimeService', () => { const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) const durableWrite = deferred() const durableWriteStarted = deferred() - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - flushPendingOrThrowAsync: vi.fn(() => { - durableWriteStarted.resolve() - return durableWrite.promise + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushPendingOrThrowAsync: vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) }) - } as never) + ) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -332,10 +337,12 @@ describe('OrcaRuntimeService', () => { wslDistro: null } ]) - const runtime = new OrcaRuntimeService({ - ...runtimeStore, - flushPendingOrThrowAsync - } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushPendingOrThrowAsync + }) + ) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -447,13 +454,17 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const flushOrThrow = vi.fn() - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true, - attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash }) => - paneKey === workerPaneKey && launchTokenHash === RESTORED_AUTHORITY_TOKEN_HASH - ? { paneKey, source: 'hydrated_commitment' } - : null - }) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow }), + undefined, + { + canRecoverPersistentLocalPtys: () => true, + attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash }) => + paneKey === workerPaneKey && launchTokenHash === RESTORED_AUTHORITY_TOKEN_HASH + ? { paneKey, source: 'hydrated_commitment' } + : null + } + ) runtime.setOrchestrationDb({ getActiveDispatchForTerminal: () => undefined, listLegacyWorkerTerminalRecoveryRows: () => [ diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts index 00a08310877..51edcad7373 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' @@ -40,7 +41,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -451,7 +452,7 @@ describe('OrcaRuntimeService', () => { return durableWrite.promise }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts index ea70b730388..6c439089396 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -50,7 +51,7 @@ describe('OrcaRuntimeService', () => { throw new Error('synchronous persistence must not run') }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -160,7 +161,7 @@ describe('OrcaRuntimeService', () => { return retryDurableWrite.promise }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushPendingOrThrowAsync } as never, + withDurableRuntimeStore({ ...runtimeStore, flushPendingOrThrowAsync }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -272,7 +273,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -383,7 +384,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 389c70d4f42..756f87611f6 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -74,7 +75,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -187,12 +188,12 @@ describe('OrcaRuntimeService', () => { const folderWorkspace = makeFolderWorkspace({ folderPath }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getFolderWorkspaces: () => [folderWorkspace], getProjectGroups: () => [projectGroup], flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -313,14 +314,14 @@ describe('OrcaRuntimeService', () => { const folderWorkspace = makeFolderWorkspace({ folderPath, connectionId }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getFolderWorkspaces: () => [folderWorkspace], getProjectGroups: () => [projectGroup], getWorkspaceSession, setWorkspaceSession, flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 1907b2bb450..2100c708043 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -83,14 +84,14 @@ describe('OrcaRuntimeService', () => { rows: 24 }) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), getWorkspaceSession, setWorkspaceSession, flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -220,7 +221,7 @@ describe('OrcaRuntimeService', () => { } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) const runtime = new OrcaRuntimeService( - { + withDurableRuntimeStore({ ...runtimeStore, getProjects: () => [ { @@ -228,17 +229,17 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + localWindowsRuntimePreference: { kind: 'wsl' as const, distro: 'Ubuntu' }, createdAt: 0, updatedAt: 0 } ], getSettings: () => ({ ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } + localWindowsRuntimeDefault: { kind: 'windows-host' as const } }), flushOrThrow: vi.fn() - } as never, + }), undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -353,7 +354,9 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -524,7 +527,9 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 79bdf13909e..8980dc53f85 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' import { @@ -108,7 +109,9 @@ describe('OrcaRuntimeService', () => { terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 } } const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) runtime.setPtyController({ write: () => true, kill: () => true, @@ -148,7 +151,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const writes: [string, string][] = [] runtime.setPtyController({ write: (ptyId: string, data: string) => { @@ -190,7 +195,9 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - return new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + return new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) } const ownerMismatch = makeRuntime() ownerMismatch.registerPty('pty-wrong-owner', TEST_WORKTREE_ID, 'ssh-other-host') @@ -282,25 +289,27 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) - const wsl = new OrcaRuntimeService({ - ...runtimeStore, - flushOrThrow: vi.fn(), - getProjects: () => [ - { - id: 'project-wsl', - displayName: 'WSL', - badgeColor: 'blue', - sourceRepoIds: [TEST_REPO_ID], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, - createdAt: 1, - updatedAt: 1 - } - ], - getSettings: () => ({ - ...store.getSettings(), - localWindowsRuntimeDefault: { kind: 'windows-host' } + const wsl = new OrcaRuntimeService( + withDurableRuntimeStore({ + ...runtimeStore, + flushOrThrow: vi.fn(), + getProjects: () => [ + { + id: 'project-wsl', + displayName: 'WSL', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl' as const, distro: 'Ubuntu' }, + createdAt: 1, + updatedAt: 1 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' as const } + }) }) - } as never) + ) wsl.registerPty('pty-wsl', TEST_WORKTREE_ID, null, undefined, true) wsl.onPtySpawned('pty-wsl', 'inc-wsl', { awaitsRegistration: false }) wsl.setPtyController({ @@ -356,7 +365,9 @@ describe('OrcaRuntimeService', () => { tabsByWorktree: { [TEST_WORKTREE_ID]: [] } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow: vi.fn() } as never) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) const processes = [ ['pty-left', 'inc-left', 'term_left'], ['pty-right', 'inc-right', 'term_right'], diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index bb64f6dd98b..a305afedf40 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -32,7 +32,7 @@ export async function listAvailableWorkerTerminals( runtime: CoordinatorRuntime, coordinatorHandle: string, worktree: string | undefined -): Promise { +): Promise { try { const result = await runtime.listTerminals(worktree, undefined, { includeVisualLayouts: false @@ -55,7 +55,8 @@ export async function listAvailableWorkerTerminals( ) .map((t) => t.handle) } catch { - return [] + // A failed census cannot authorize creating another worker. + return null } } diff --git a/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts b/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts new file mode 100644 index 00000000000..49b34814144 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Coordinator } from './coordinator' +import type { CoordinatorRuntime } from './coordinator-runtime-contract' +import { OrchestrationDb } from './db' + +afterEach(() => vi.useRealTimers()) + +describe('coordinator terminal census availability', () => { + it.each(['terminal_surface_ownership_unavailable', 'terminal_liveness_unavailable'])( + 'defers dispatch on %s and reuses the existing worker after recovery', + async (error) => { + vi.useFakeTimers() + const db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'implement the feature' }) + const listTerminals = vi + .fn() + .mockRejectedValueOnce(new Error(error)) + .mockResolvedValue({ + terminals: [ + { handle: 'term_existing', worktreeId: 'wt1', connected: true, writable: true } + ] + }) + const createTerminal = vi.fn(async () => ({ + handle: 'term_unnecessary', + worktreeId: 'wt1' + })) + const sendTerminalAgentPrompt = vi.fn( + async () => ({ accepted: true }) + ) + const runtime: CoordinatorRuntime = { + listTerminals, + createTerminal, + sendTerminalAgentPrompt, + waitForTerminal: async (handle) => ({ handle, condition: 'exit' }), + probeWorktreeDrift: async () => null + } + const coordinator = new Coordinator(db, runtime, { + spec: 'go', + coordinatorHandle: 'coord', + pollIntervalMs: 1000, + worktree: 'wt1' + }) + const run = coordinator.run() + try { + await vi.advanceTimersByTimeAsync(0) + expect(listTerminals).toHaveBeenCalledTimes(1) + expect(createTerminal).not.toHaveBeenCalled() + expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.listTasks({ status: 'dispatched' })).toEqual([]) + + await vi.advanceTimersByTimeAsync(1000) + expect(listTerminals).toHaveBeenCalledTimes(2) + expect(createTerminal).not.toHaveBeenCalled() + expect(sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + const dispatch = db.getDispatchContext(task.id) + expect(dispatch?.assignee_handle).toBe('term_existing') + expect(db.getTask(task.id)?.status).toBe('dispatched') + db.insertMessage({ + runId: 'run_legacy_local', + from: 'term_existing', + to: 'coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch?.id, + outcome: 'succeeded' + }) + }) + await vi.advanceTimersByTimeAsync(1000) + await expect(run).resolves.toMatchObject({ status: 'completed', completedTasks: [task.id] }) + } finally { + coordinator.stop() + try { + await vi.runOnlyPendingTimersAsync() + await run + } finally { + db.close() + } + } + } + ) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 252c9f89ea9..915dc9e16a4 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -245,6 +245,9 @@ export class Coordinator { this.opts.coordinatorHandle, this.opts.worktree ) + if (terminals === null) { + return + } if (terminals.length === 0 && slotsAvailable > 0) { // Why: create at most one terminal per tick to avoid spawning many at once. try { diff --git a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts index 640c54a753c..35fb16d55ea 100644 --- a/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts +++ b/src/main/runtime/paired-close-retirement-proof-publication-order.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { @@ -88,14 +89,17 @@ function createHost(): { } { let session = makePersistedSession() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store stub carries the four members this publication-order suite drives; the rest of Store is unreached. - const runtime = new OrcaRuntimeService({ - getRepos: () => [LIVE_REPO], - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - flushOrThrow: vi.fn() - } as never) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + withDurableRuntimeStore({ + getRepos: () => [LIVE_REPO], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: vi.fn() + }) as never + ) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ @@ -159,7 +163,7 @@ describe('retirement proof publication vs. renderer republication order', () => it('publishes the proof when the exit lands before the renderer drops the surface', async () => { const { runtime, handle } = createHost() - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') republishWithoutTheSurface(runtime) const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) @@ -174,7 +178,7 @@ describe('retirement proof publication vs. renderer republication order', () => retirePersistedSurface() republishWithoutTheSurface(runtime) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(published.tabs).toEqual([]) @@ -185,7 +189,7 @@ describe('retirement proof publication vs. renderer republication order', () => // Why a subscriber and not just the stored snapshot: a mirror only ever sees frames. A proof // that lands in state without a frame to carry it is the same silence from the client's side. - it('fans the proof out to a paired subscriber, not just into stored state', () => { + it('fans the proof out to a paired subscriber, not just into stored state', async () => { const { runtime, handle, retirePersistedSurface } = createHost() const frames: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged( @@ -196,7 +200,7 @@ describe('retirement proof publication vs. renderer republication order', () => try { retirePersistedSurface() republishWithoutTheSurface(runtime) - runtime.onPtyExit('pty-left', 0, 'incarnation-a') + await runtime.onPtyExit('pty-left', 0, 'incarnation-a') } finally { unsubscribe() } diff --git a/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts b/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts index 22dc70e42c0..cf5618ab2ff 100644 --- a/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts +++ b/src/main/runtime/pty-inventory-partial-relay-liveness.test.ts @@ -1,3 +1,4 @@ +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { makePaneKey } from '../../shared/stable-pane-id' @@ -50,7 +51,8 @@ function createRuntime(options: { sessions?: unknown[]; vouchesForRetainedPty?: calls: ListCall[] } { const meta: Record> = { [WORKSPACE]: { hostId: 'local' } } - const store = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This runtime fixture supplies the persistence and graph methods exercised by the test. + const store = withDurableRuntimeStore({ getRepos: () => [REPO], getRepo: (id: string) => (id === REPO_ID ? REPO : undefined), getAllWorktreeMeta: () => meta, @@ -62,7 +64,7 @@ function createRuntime(options: { sessions?: unknown[]; vouchesForRetainedPty?: getWorkspaceSession: () => getDefaultWorkspaceSession(), setWorkspaceSession: () => {}, flushOrThrow: () => {} - } as never + }) as never const calls: ListCall[] = [] const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/runtime-automation-controller.ts b/src/main/runtime/runtime-automation-controller.ts index d3ac55df438..20d3725c34e 100644 --- a/src/main/runtime/runtime-automation-controller.ts +++ b/src/main/runtime/runtime-automation-controller.ts @@ -113,7 +113,7 @@ export class RuntimeAutomationController { if (input.reuseSession && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.createAutomation( + const automation = this.store.createAutomation( { creationKey: input.creationKey, name: input.name, @@ -138,6 +138,8 @@ export class RuntimeAutomationController { ? { destination: destination ?? input.destination } : undefined ) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) + return automation } async update( @@ -179,18 +181,21 @@ export class RuntimeAutomationController { if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } - return this.store.updateAutomation(id, patch, options) + const automation = this.store.updateAutomation(id, patch, options) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) + return automation } - delete( + async delete( id: string, expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { + ): Promise<{ removed: boolean; id: string }> { if (!this.store?.deleteAutomation) { throw new Error('runtime_unavailable') } this.show(id) this.store.deleteAutomation(id, expectedOwner ? { expectedOwner } : undefined) + await this.store.flushPendingOrThrowAsync?.({ drainToStableGeneration: false }) return { removed: true, id } } diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts new file mode 100644 index 00000000000..a5f2dc82793 --- /dev/null +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -0,0 +1,40 @@ +import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' +import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors' + +/** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */ +export function withDurableRuntimeStore< + T extends { + flushOrThrow?: () => void + flushPendingOrThrowAsync?: (options?: { drainToStableGeneration?: boolean }) => Promise + } +>(store: T) { + let pending = Promise.resolve() + return Object.assign(store, { + runDurableMutation(mutate: () => DurableProfileStateMutation): Promise { + const write = pending.then(async () => { + const mutation = mutate() + if (mutation.persist === false) { + return mutation.value + } + try { + if (store.flushPendingOrThrowAsync) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + } else { + store.flushOrThrow?.() + } + } catch (error) { + if (profileStateWriterFailureOutcome(error) !== 'indeterminate') { + mutation.rollback?.() + } + throw error + } + return mutation.value + }) + pending = write.then( + () => {}, + () => {} + ) + return write + } + }) +} diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index df794567737..345e2c08377 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -12,7 +12,8 @@ import type { LegacyWorkerRecoveryResolution } from './runtime-legacy-worker-terminal-recovery-types' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { cloneWorkspaceSessionState } from '../persistence/restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' export class RuntimeLegacyWorkerTerminalRecoveryPersistence { constructor( @@ -29,66 +30,68 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { resolutions: readonly LegacyWorkerRecoveryResolution[] ): Promise> { const store = this.getStore() - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { + if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { return new Set() } + const getWorkspaceSession = store.getWorkspaceSession.bind(store) + const setWorkspaceSession = store.setWorkspaceSession.bind(store) const originals = new Map() const staged = new Map() const dispatchIds = new Set() try { - for (const { candidate, resolution } of resolutions) { - const hostId = this.getHostId(candidate.worktreeId) - const session = hostId ? store.getWorkspaceSession(hostId) : null - if (!hostId || !session) { - continue + return await store.runDurableMutation(() => { + for (const { candidate, resolution } of resolutions) { + const hostId = this.getHostId(candidate.worktreeId) + const session = hostId ? getWorkspaceSession(hostId) : null + if (!hostId || !session) { + continue + } + originals.set(hostId, originals.get(hostId) ?? cloneWorkspaceSessionState(session)) + let next = + resolution === 'exited' + ? retireTerminalSurfaceFromPersistence(session, { + worktreeId: candidate.worktreeId, + parentTabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId + }) + : session + const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { + const sleeping = { ...next.sleepingAgentSessionsByPaneKey } + delete sleeping[candidate.paneKey] + next = { ...next, sleepingAgentSessionsByPaneKey: sleeping } + } + if (next !== session) { + setWorkspaceSession(next, hostId) + } + staged.set(hostId, cloneWorkspaceSessionState(getWorkspaceSession(hostId))) + dispatchIds.add(candidate.dispatchId) } - originals.set(hostId, originals.get(hostId) ?? session) - let next = - resolution === 'exited' - ? retireTerminalSurfaceFromPersistence(session, { - worktreeId: candidate.worktreeId, - parentTabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId - }) - : session - const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] - if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { - const sleeping = { ...next.sleepingAgentSessionsByPaneKey } - delete sleeping[candidate.paneKey] - next = { ...next, sleepingAgentSessionsByPaneKey: sleeping } + return { + value: dispatchIds, + persist: dispatchIds.size > 0, + rollback: () => { + for (const [hostId, original] of originals) { + const stagedSession = staged.get(hostId) + const current = getWorkspaceSession(hostId) + if (!stagedSession || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( + original, + stagedSession, + current + ) + if (rolledBack !== current) { + setWorkspaceSession(rolledBack, hostId) + } + } + } } - if (next !== session) { - store.setWorkspaceSession(next, hostId) - } - staged.set(hostId, store.getWorkspaceSession(hostId)) - dispatchIds.add(candidate.dispatchId) - } - if (dispatchIds.size > 0) { - await this.flush(store) - } - return dispatchIds + }) } catch (error) { - for (const [hostId, original] of originals) { - const stagedSession = staged.get(hostId) - const current = store.getWorkspaceSession(hostId) - if (!stagedSession || !current) { - continue - } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - stagedSession, - current - ) - if (rolledBack !== current) { - store.setWorkspaceSession(rolledBack, hostId) - } - } console.warn('[orchestration] failed to persist legacy worker recovery batch', { dispatchIds: [...dispatchIds], error @@ -124,16 +127,4 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { return null } } - - private async flush(store: RuntimeStore): Promise { - if (store.flushPendingOrThrowAsync) { - await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - return - } - if (store.flushOrThrow) { - store.flushOrThrow() - return - } - throw new Error('workspace_session_persistence_unavailable') - } } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 6c1436395f1..86af6d3e15f 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -48,6 +48,7 @@ export type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] setWorkspaceSession?: Store['setWorkspaceSession'] + runDurableMutation?: Store['runDurableMutation'] flushOrThrow?: Store['flushOrThrow'] flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] persistPtyBinding?: Store['persistPtyBinding'] diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 59fbf76b857..24df1cc1cbf 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -13,7 +13,7 @@ import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-ses import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../persistence/restoring-sessions/workspace-session-write-rollback' type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null diff --git a/src/main/runtime/terminal-list-pending-pty-registration.test.ts b/src/main/runtime/terminal-list-pending-pty-registration.test.ts new file mode 100644 index 00000000000..62a5daf78e3 --- /dev/null +++ b/src/main/runtime/terminal-list-pending-pty-registration.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createInventoryRuntime, + deferred, + processRow, + PREDECESSOR, + PTY, + WORKTREE +} from './pty-inventory-lifecycle-fixture' + +const TAB = '40000000-0000-4000-8000-000000000001' +const LEAF = '40000000-0000-4000-8000-000000000002' + +afterEach(() => vi.restoreAllMocks()) + +describe('terminal listing while a spawn binding is being persisted', () => { + it.each([ + { host: 'local', ptyId: PTY, connectionId: null, phase: 'binding', incarnationId: PREDECESSOR }, + { + host: 'SSH', + ptyId: 'ssh:host-a@@pty2:pending-spawn:1', + connectionId: 'host-a', + phase: 'binding' + }, + { host: 'local', ptyId: PTY, connectionId: null, phase: 'provider' }, + { + host: 'SSH', + ptyId: 'ssh:host-a@@pty2:pending-spawn:1', + connectionId: 'host-a', + phase: 'provider' + } + ])( + 'does not authorize orphan adoption during $host $phase admission', + async ({ ptyId, connectionId, phase, incarnationId }) => { + const bindingPersisted = deferred() + const { runtime } = createInventoryRuntime(async () => [processRow(ptyId)]) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: TAB, worktreeId: WORKTREE, title: '', activeLeafId: LEAF, layout: null }], + leaves: [ + { + tabId: TAB, + worktreeId: WORKTREE, + leafId: LEAF, + paneRuntimeId: 1, + ptyId: null, + paneTitle: null, + title: '' + } + ] + }) + + const releaseSpawn = + phase === 'provider' ? await runtime.acquireWorktreeTerminalSpawn(WORKTREE) : undefined + if (phase === 'binding') { + runtime.beginPtyRegistration(ptyId, incarnationId) + } + // Spawn commit awaits durable binding persistence before publishing the runtime surface. + const commit = bindingPersisted.promise.then(() => { + runtime.registerPty(ptyId, WORKTREE, connectionId, { + tabId: TAB, + leafId: LEAF, + incarnationId: PREDECESSOR + }) + }) + try { + await expect( + runtime.listTerminals(`id:${WORKTREE}`, undefined, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + ).rejects.toThrow('terminal_surface_ownership_unavailable') + expect(runtime.capture(ptyId).verdict).toMatchObject({ status: 'live' }) + } finally { + bindingPersisted.resolve() + try { + await commit + } finally { + releaseSpawn?.() + } + } + + const committed = await runtime.listTerminals(`id:${WORKTREE}`, undefined, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + expect(committed.terminals).toEqual([ + expect.objectContaining({ + ptyId, + tabId: TAB, + leafId: LEAF, + connected: true, + incarnationId: PREDECESSOR, + orphaned: false + }) + ]) + } + ) + + it('continues reporting a committed surface while another spawn is pending', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + runtime.register() + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + try { + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: false }) + ]) + } finally { + releaseSpawn() + } + }) + + it('does not block orphan recovery in an unrelated workspace', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn('repo::/tmp/another-workspace') + try { + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: true }) + ]) + } finally { + releaseSpawn() + } + }) + + it('permits orphan recovery once the competing spawn admission has ended', async () => { + const { runtime } = createInventoryRuntime(async () => [processRow()]) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + runtime.beginPtyRegistration(PTY, PREDECESSOR) + try { + await expect(runtime.listTerminals(`id:${WORKTREE}`)).rejects.toThrow( + 'terminal_surface_ownership_unavailable' + ) + } finally { + runtime.cancelPendingPtyRegistration(PTY, PREDECESSOR) + releaseSpawn() + } + const listed = await runtime.listTerminals(`id:${WORKTREE}`) + expect(listed.terminals).toEqual([ + expect.objectContaining({ ptyId: PTY, connected: true, orphaned: true }) + ]) + }) +}) diff --git a/src/main/runtime/terminal-retirement-async-durability.test.ts b/src/main/runtime/terminal-retirement-async-durability.test.ts new file mode 100644 index 00000000000..038832782d7 --- /dev/null +++ b/src/main/runtime/terminal-retirement-async-durability.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { + ACK_INCARNATION, + ACK_LEAF, + ACK_TAB, + createAcknowledgedTabRetirementFixture +} from './acknowledged-terminal-tab-retirement-fixture' + +const fixtures: ReturnType[] = [] +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { + await fixture.dispose() + } +}) +function fixture() { + const result = createAcknowledgedTabRetirementFixture(true) + fixtures.push(result) + return result +} + +it('withholds the acknowledged close until its host retirement is durable', async () => { + const f = fixture() + let acknowledged = false + const closing = f.close().then((result) => { + acknowledged = true + return result + }) + await f.entered.promise + const gate = f.authority.pause() + f.acknowledgement.resolve() + await gate.started.promise + expect(acknowledged).toBe(false) + gate.finish.resolve() + await expect(closing).resolves.toEqual({ closed: true }) + expect(f.hasTab()).toBe(false) +}) + +it('publishes physical-exit retirement only after durability', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const published = vi.fn() + const unsubscribe = f.runtime.onMobileSessionTabsChanged(published) + const gate = f.authority.pause() + const exiting = f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await gate.started.promise + expect(published).not.toHaveBeenCalled() + gate.finish.resolve() + await exiting + expect(published).toHaveBeenCalled() + expect( + f.store.getWorkspaceSession().terminalLayoutsByTabId[ACK_TAB].ptyIdsByLeafId?.[ACK_LEAF] + ).toBeUndefined() + unsubscribe() +}) + +it('does not publish a delayed exit over a newly admitted incarnation', async () => { + const f = fixture() + await f.store.flushPendingOrThrowAsync() + const published = vi.fn() + const unsubscribe = f.runtime.onMobileSessionTabsChanged(published) + const gate = f.authority.pause() + const exiting = f.runtime.onPtyExit('pty-a', 0, ACK_INCARNATION, { providerExitObserved: true }) + await gate.started.promise + f.runtime.onPtySpawned('pty-a', 'new-incarnation') + published.mockClear() + gate.finish.resolve() + await exiting + expect(published).not.toHaveBeenCalled() + unsubscribe() +}) diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 0d265a0a5bb..562ab23ee3e 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -109,10 +109,8 @@ async function terminalWait( describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => { it('does not satisfy while the real process streams under a name-only title', async () => { const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000) - await new Promise((resolve) => setTimeout(resolve, 500)) - - // The OSC title really did reach the runtime as control bytes, not literal text. - expect(transcript.join('')).toContain(']0;Codex') + // Wait for real control bytes before measuring idle behavior. + await expect.poll(() => transcript.join(''), { timeout: 5_000 }).toContain(']0;Codex') const outcome = await terminalWait(runtime, handle, 8_000) expect(outcome.satisfied).toBe(false) diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts deleted file mode 100644 index 7234446cb9d..00000000000 --- a/src/main/runtime/workspace-session-failed-write-rollback.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { isDeepStrictEqual } from 'node:util' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -const MISSING = Symbol('missing') - -/** A JSON-shaped slot of persisted session state, or the absent-key sentinel. */ -type RollbackSlot = - | string - | number - | boolean - | null - | undefined - | typeof MISSING - | readonly RollbackSlot[] - | RollbackRecord - -type RollbackRecord = { readonly [key: string]: RollbackSlot } - -function isRecord(value: RollbackSlot): value is RollbackRecord { - return ( - value !== MISSING && - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ) -} - -function rollbackValue( - original: RollbackSlot, - staged: RollbackSlot, - current: RollbackSlot -): RollbackSlot { - if (isDeepStrictEqual(original, staged)) { - return current - } - if (isDeepStrictEqual(current, staged)) { - return original - } - if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { - return current - } - let changed = false - const next: Record = { ...current } - for (const key of new Set([ - ...Object.keys(original), - ...Object.keys(staged), - ...Object.keys(current) - ])) { - const value = rollbackValue( - Object.hasOwn(original, key) ? original[key] : MISSING, - Object.hasOwn(staged, key) ? staged[key] : MISSING, - Object.hasOwn(current, key) ? current[key] : MISSING - ) - if (value === MISSING) { - if (Object.hasOwn(next, key)) { - delete next[key] - changed = true - } - } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { - next[key] = value - changed = true - } - } - return changed ? next : current -} - -export function rollbackWorkspaceSessionAfterFailedAsyncWrite( - original: WorkspaceSessionState, - staged: WorkspaceSessionState, - current: WorkspaceSessionState -): WorkspaceSessionState { - return rollbackValue(original, staged, current) as WorkspaceSessionState -} diff --git a/src/main/runtime/worktree-terminal-mutation-lock.test.ts b/src/main/runtime/worktree-terminal-mutation-lock.test.ts index 822238034a8..b972bd02dec 100644 --- a/src/main/runtime/worktree-terminal-mutation-lock.test.ts +++ b/src/main/runtime/worktree-terminal-mutation-lock.test.ts @@ -16,9 +16,13 @@ describe('WorktreeTerminalMutationLock', () => { lock.acquire(KEY, 'shared') ]) expect(releases).toHaveLength(4) + expect(lock.hasActiveSpawns(KEY)).toBe(true) + expect(lock.hasActiveSpawns('other')).toBe(false) for (const release of releases) { + expect(lock.hasActiveSpawns(KEY)).toBe(true) release() } + expect(lock.hasActiveSpawns(KEY)).toBe(false) expect(lock.trackedKeyCount).toBe(0) }) diff --git a/src/main/runtime/worktree-terminal-mutation-lock.ts b/src/main/runtime/worktree-terminal-mutation-lock.ts index b0af98b2d3b..f76d8449826 100644 --- a/src/main/runtime/worktree-terminal-mutation-lock.ts +++ b/src/main/runtime/worktree-terminal-mutation-lock.ts @@ -35,6 +35,10 @@ export const WORKTREE_TERMINAL_SLEEP_TIMEOUT_ERROR = 'terminal_worktree_sleep_ti export class WorktreeTerminalMutationLock { private readonly entries = new Map() + hasActiveSpawns(key: string): boolean { + return (this.entries.get(key)?.activeSpawns ?? 0) > 0 + } + /** Why exposed: entry deletion is the only thing keeping this map from * becoming a per-worktree leak, so the tests assert on it directly. */ get trackedKeyCount(): number { diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 0bfa79e43f5..08f69b9cf99 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import type { DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite' +import type { backup, BackupOptions, DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite' type SqlitePath = ConstructorParameters[0] @@ -36,6 +36,15 @@ function loadDatabaseSync(): typeof DatabaseSync { .DatabaseSync } +function hasBackup(value: unknown): value is { backup: typeof backup } { + return ( + typeof value === 'object' && + value !== null && + 'backup' in value && + typeof value.backup === 'function' + ) +} + class SyncDatabase { private readonly db: DatabaseSync private readonly statementCache = new Map() @@ -100,6 +109,21 @@ class SyncDatabase { return this.db.isTransaction } + /** The source connection must remain open until the native backup settles. */ + async backup(path: string, options?: BackupOptions): Promise { + const sqlite: unknown = + typeof process.getBuiltinModule === 'function' + ? process.getBuiltinModule('node:sqlite') + : undefined + if (!hasBackup(sqlite)) { + throw new Error('Asynchronous SQLite backup is unavailable in this Node.js runtime') + } + if (this.db.isTransaction) { + throw new Error('Asynchronous SQLite backup requires an idle database connection') + } + return sqlite.backup(this.db, path, options ?? {}) + } + close(): void { this.statementCache.clear() this.db.close() diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index d7179703360..889f792abd4 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -66,11 +66,11 @@ function sessionAfterClose() { } /** What the relay's reattach bind does per PTY — see `restoreReattachedPtyRuntime`. */ -function relayReattachBinds( +async function relayReattachBinds( store: ReturnType, args: { tabId: string; leafId: string; ptyId: string; incarnationId?: string } -): boolean | null { - return store.persistPtyBinding({ +): Promise { + return await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, @@ -95,10 +95,10 @@ function relayReattachBinds( * production uses; a raw session write is reconciled back to the attached lease's PTY by binding * recovery, which would make the fixture disagree with the real flow. */ -function paneSpawnCommits( +async function paneSpawnCommits( store: ReturnType, args: { tabId: string; leafId: string; ptyId: string; leaseTabId?: string } -): void { +): Promise { store.upsertSshRemotePtyLease({ targetId: TARGET, ptyId: args.ptyId, @@ -107,7 +107,7 @@ function paneSpawnCommits( leafId: args.leafId, state: 'attached' }) - store.persistPtyBinding({ + await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, @@ -163,7 +163,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () // The user closes the tab; the remote kill never lands, so the lease survives untouched. store.setWorkspaceSession(sessionAfterClose()) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1', @@ -189,7 +189,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1) expect(resolvedTabId).toBe(OTHER_TAB) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: resolvedTabId!, leafId: TEST_LEAF_1, ptyId: 'pty-2', @@ -208,7 +208,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('still binds when the session is not yet authoritative for the worktree', async () => { const store = await createStore() - const bound = store.persistPtyBinding({ + const bound = await store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, @@ -250,7 +250,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () expect(store.getWorkspaceSession().terminalSurfaceTombstonesByPaneKey?.[paneKey]).toBeDefined() expect( - relayReattachBinds(store, { + await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1', @@ -267,7 +267,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })) - const bound = relayReattachBinds(store, { + const bound = await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_2, ptyId: 'pty-2', @@ -292,9 +292,9 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () const session = store.getWorkspaceSession() expect(session.terminalTopologyRevisionByRepoId?.repo1).toBeGreaterThan(0) expect(session.terminalSurfaceTombstonesByPaneKey ?? {}).toEqual({}) - expect(relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' })).toBe( - false - ) + expect( + await relayReattachBinds(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }) + ).toBe(false) }) }) @@ -314,7 +314,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) }) @@ -327,7 +327,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor?.state).toBe('expired') @@ -339,7 +339,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(liveLeasePtyIds(store)).toEqual(['pty-9']) @@ -362,7 +362,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { // The successor's lease names the tab the pane sits in NOW; the predecessor's still names the // one it was written in. Only the leaf is common, so keying on the tab would stop the two // competing and leave both live — the cardinality growth. - paneSpawnCommits(store, { + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2', @@ -401,7 +401,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store).sort()).toEqual(['pty-2', 'sibling-pty']) }) @@ -461,7 +461,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () it('never bulk-reattaches a superseded sibling', async () => { const store = await storeWithPane('pty-1') - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -474,7 +474,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(bulkReattachPtyIds(store)).toEqual(['pty-9']) @@ -500,7 +500,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.markSshRemotePtyLease(TARGET, 'pty-1', 'expired') const orphanUpdatedAt = store.getSshRemotePtyLeases(TARGET)[0].updatedAt - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -513,7 +513,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () // belongs to the lease that lost, never to whatever claims the id next. it('clears the supersession mark when the id is re-upserted as a live lease', async () => { const store = await storeWithPane('pty-1') - paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) + await paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // A restarted relay hands `pty-1` to a new shell for a different pane. store.upsertSshRemotePtyLease({ diff --git a/src/main/ssh/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 00000000000..8a00a1dfa03 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy-stop.ts @@ -0,0 +1,68 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { + parseOrcadStopOutcome, + stopOrcadCommand, + type OrcadStopOutcome +} from './orcad-remote-process-control' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot' + +export type OrcadOutgoingStopOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + nodePath: string + signal?: AbortSignal +} + +/** Stop the outgoing runtime and return its execution-host verdict. */ +export async function stopOutgoingOrcad( + options: OrcadOutgoingStopOptions, + outgoingVersion: string +): Promise { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + outgoingVersion + ) + const output = await execCommand( + options.conn, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return parseOrcadStopOutcome(output) +} + +/** The caller must confirm candidate exit before inspecting its shared state. */ +export async function rejectedOrcadStateRecoveryRefusal( + options: OrcadOutgoingStopOptions & { userDataDir: string }, + incumbentVersion: string, + snapshotDir: string | undefined +): Promise { + const unchanged = snapshotDir + ? orcadSnapshotIsUnchanged( + await execCommand( + options.conn, + compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir), + { wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal } + ).catch(() => '') + ) + : false + if (unchanged) { + return undefined + } + // RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes. + const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.' + return ( + 'The candidate is stopped, but profile state changed or could not be verified. ' + + `orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` + + 'Current state and daemon terminals are preserved; recovery requires a fresh host ' + + `terminal census before restoring the prelaunch snapshot${retainedSnapshot}` + ) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 1d9f87b009d..8fc387184f5 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -82,6 +82,9 @@ type HostScript = { /** Readiness content per version dir, keyed by the version in the path. */ readiness: Record log: string[] + snapshotResult?: string + comparisonResult?: string + candidateStopResult?: string } function scriptHost(script: HostScript): void { @@ -100,11 +103,15 @@ function scriptHost(script: HostScript): void { } if (text.includes('kill -TERM')) { script.log.push(`stop:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) - return 'STOPPED' + return text.includes(NEW_VERSION) ? (script.candidateStopResult ?? 'STOPPED') : 'STOPPED' } if (text.includes('tar -C') && text.includes('-cf')) { script.log.push('snapshot') - return 'CAPTURED' + return script.snapshotResult ?? 'CAPTURED' + } + if (text.includes('verdict=UNCHANGED')) { + script.log.push('compare-state') + return script.comparisonResult ?? 'UNCHANGED' } return '' }) @@ -218,7 +225,7 @@ describe('deployOrcad', () => { }) }) - it('snapshots the shared data root before the candidate ever runs', async () => { + it('stops the incumbent before snapshotting, so SQLite WAL files are quiescent', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({}) }, @@ -228,6 +235,7 @@ describe('deployOrcad', () => { await deployOrcad(options()) expect(script.log.indexOf('snapshot')).toBeGreaterThan(-1) expect(script.log.indexOf('snapshot')).toBeLessThan(script.log.indexOf(`launch:${NEW_VERSION}`)) + expect(script.log.indexOf(`stop:${OLD_VERSION}`)).toBeLessThan(script.log.indexOf('snapshot')) }) it('installs but does not activate when terminals are running', async () => { @@ -278,10 +286,11 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-not-activated' }) expect(script.log).toEqual([ - 'snapshot', `stop:${OLD_VERSION}`, + 'snapshot', `launch:${NEW_VERSION}`, `stop:${NEW_VERSION}`, + 'compare-state', `launch:${OLD_VERSION}` ]) expect(result.outcome === 'installed-not-activated' && result.reason).toContain( @@ -289,6 +298,74 @@ describe('deployOrcad', () => { ) }) + it.each(['CHANGED', 'UNKNOWN', ''])( + 'preserves rejected candidate state when comparison is %s', + async (comparisonResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) }, + log: [], + comparisonResult + } + scriptHost(script) + + const result = await deployOrcad(options()) + + expect(result).toMatchObject({ outcome: 'installed-not-activated' }) + expect(script.log).toEqual([ + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}`, + 'compare-state' + ]) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + 'recovery requires a fresh host terminal census' + ) + expect(result.outcome === 'installed-not-activated' && result.reason).toContain( + '/home/u/.orca-remote/orcad-state-snapshots/' + ) + expect(mockExec.mock.calls.some(([, command]) => command.includes('echo RESTORED'))).toBe( + false + ) + } + ) + + it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [OLD_VERSION]: readyLine({}) }, + log: [], + snapshotResult: 'tar: write failed' + } + scriptHost(script) + + await expect(deployOrcad(options())).rejects.toThrow('incumbent was stopped') + expect(script.log).toEqual([`stop:${OLD_VERSION}`, 'snapshot', `launch:${OLD_VERSION}`]) + }) + + it.each(['NO_PID', 'STILL_RUNNING', 'SIGNAL_FAILED', ''])( + 'does not inspect or replace state without confirmed candidate exit: %s', + async (candidateStopResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }) }, + log: [], + candidateStopResult + } + scriptHost(script) + + await deployOrcad(options()) + + expect(script.log).toEqual([ + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${NEW_VERSION}`, + `stop:${NEW_VERSION}` + ]) + } + ) + it('refuses to activate when a different build answered the port', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 88a4d923c84..be9837ff31e 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -1,16 +1,7 @@ /** - * Installing orcad on a host and, only if it proves itself, making it the active one. - * - * The install half is the relay's transaction, parameterized: the same per-version lock, - * staged SFTP write, `.install-complete` sentinel and stale-lock recovery, under - * `orcad-/` instead of `relay-/`. That is what §02 marks reusable. - * - * The activation half has no relay equivalent, because the relay has no notion of a version - * being *selected*. Bytes landing in a versioned directory neither picks a version nor rolls - * one back; the activation record does, and it is written only after the candidate publishes - * a health payload that survives `evaluateOrcadActivation`. A rejected candidate leaves the - * previous version running and its own bytes on disk — nothing is lost, and a retry costs no - * upload. + * Activate installed bytes only after the candidate proves healthy. A rejected candidate + * allows restarting the incumbent only when profile state is provably unchanged; otherwise + * preserve current state and the prelaunch snapshot for explicit recovery. */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -40,9 +31,9 @@ import { ORCAD_LOG_FILENAME, orcadLaunchCommand, parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec + readOrcadReadinessCommand } from './orcad-remote-launch' +import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' import { captureOrcadStateSnapshotCommand, orcadSnapshotDirName, @@ -88,14 +79,10 @@ const DEFAULT_READINESS_TIMEOUT_MS = 90_000 const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 -function exec( - options: OrcadDeployOptions, - command: string, - signal = options.signal -): Promise { +function exec(options: OrcadDeployOptions, command: string): Promise { return execCommand(options.conn, command, { wrapCommand: options.host.commandDialect !== 'powershell', - signal + signal: options.signal }) } @@ -156,6 +143,8 @@ async function captureSnapshot( outgoingVersion: string | null, takenAt: Date ): Promise { + // The caller has already stopped the outgoing runtime. This is required once profile state + // includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup. const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) const snapshotDir = joinRemotePath( options.host, @@ -176,9 +165,8 @@ async function captureSnapshot( 'way back. Refusing to activate.' ) } + // Empty profiles need no rollback snapshot. if (capture === 'empty') { - // Nothing on the host to lose: a first deployment. Rollback will correctly report that - // it has no snapshot, rather than restoring an archive of nothing over a populated root. return null } return { @@ -191,16 +179,20 @@ async function captureSnapshot( async function launchAndAwaitReadiness( options: OrcadDeployOptions, - spec: OrcadLaunchSpec + remoteInstallDir: string, + fullVersion: string ): Promise> { - await exec(options, orcadLaunchCommand(options.host, spec)) + await exec( + options, + orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) + ) const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) let last = parseOrcadReadinessOutput('') while (Date.now() < deadline) { options.signal?.throwIfAborted() last = parseOrcadReadinessOutput( - await exec(options, readOrcadReadinessCommand(options.host, spec.remoteInstallDir)) + await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir)) ) if (last.state !== 'pending') { return last @@ -210,57 +202,51 @@ async function launchAndAwaitReadiness( return last } -/** - * Put the previous version back after a rejected candidate. - * - * Why this exists at all: activating means swapping which process owns the data root and the - * port, so the incumbent has to stop before the candidate can start. A gate that rejected - * and returned would leave the host with nothing running — a careful deploy causing the - * outage it was being careful about. The returned sentence goes into the caller's reason so - * the operator learns the host's actual state, not just why the candidate failed. - */ +/** Restart the incumbent only when the candidate left shared state unchanged. */ async function restoreIncumbent( options: OrcadDeployOptions, record: OrcadActivationRecord, - candidateDir: string + candidateDir?: string, + snapshot?: OrcadStateSnapshot | null ): Promise { - const stopped = parseOrcadStopOutcome( - await exec( - options, - stopOrcadCommand(options.host, candidateDir, { waitSeconds: STOP_WAIT_SECONDS }) + if (candidateDir) { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { + waitSeconds: STOP_WAIT_SECONDS, + justLaunched: true + }) + ) ) - ) - if (!orcadStopFreedTheHost(stopped)) { - return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } } if (!record.active) { return 'No previous version was active, so this host is now serving nothing.' } + if (candidateDir) { + const snapshotDir = snapshot + ? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName) + : undefined + const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir) + if (refusal) { + return refusal + } + } const incumbentDir = computeRemoteInstallDir( ORCAD_INSTALL_MODEL, options.remoteHome, record.active ) - const parsed = await launchAndAwaitReadiness(options, { - remoteInstallDir: incumbentDir, - nodePath: options.nodePath, - fullVersion: record.active, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) + const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active) return parsed.state === 'ready' ? `orcad ${record.active} was restarted and is serving again.` : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` } -/** - * Install, then activate only on a green cross-process health verdict. - * - * Every early return past the install leaves the bytes on disk and the previous version - * serving, which is why they all report `installed-not-activated` rather than throwing: a - * refusal to switch is a successful outcome of a deploy that was asked to be careful. - */ +/** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(options: OrcadDeployOptions): Promise { const now = options.now ?? ((): Date => new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) @@ -287,52 +273,49 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise + `The incumbent could not be restarted: ${ + restartError instanceof Error ? restartError.message : String(restartError) + }` + ) + throw new Error( + `${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` + + `before snapshotting; ${restored}` + ) + } + } + + const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion) const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), fullVersion }) if (verdict.decision === 'reject') { - const restored = await restoreIncumbent(options, record, remoteDir) + const restored = await restoreIncumbent(options, record, remoteDir, snapshot) return { outcome: 'installed-not-activated', fullVersion, diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts index dfcb0f4b6e4..7ef8548ecdf 100644 --- a/src/main/ssh/orcad-remote-host-support.ts +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -42,9 +42,17 @@ export const ORCAD_PID_FILENAME = '.orcad-pid' * A host without `ps` yields an empty state, which falls through to "alive" — the safe * direction for both callers. */ -export function posixProcessAliveShellFunction(): string { +export function posixProcessAliveShellFunction( + options: { refuseUnverifiable?: boolean } = {} +): string { + // Destructive lifecycle steps need explicit absence; permission failures cannot prove exit. + const probe = options.refuseUnverifiable + ? 'probe_error=$(LC_ALL=C kill -0 "$1" 2>&1) || { ' + + 'case "$probe_error" in *"No such process"*) return 1;; ' + + '*) echo UNKNOWN; exit 0;; esac; }; ' + : 'kill -0 "$1" 2>/dev/null || return 1; ' return ( - 'orcad_alive() { kill -0 "$1" 2>/dev/null || return 1; ' + + `orcad_alive() { ${probe}` + 'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };' ) } diff --git a/src/main/ssh/orcad-remote-launch.test.ts b/src/main/ssh/orcad-remote-launch.test.ts index 068ae588f65..0be9ddc76c1 100644 --- a/src/main/ssh/orcad-remote-launch.test.ts +++ b/src/main/ssh/orcad-remote-launch.test.ts @@ -100,7 +100,10 @@ describe('liveness', () => { describe('stopping a running orcad', () => { it('sends SIGTERM and never SIGKILL', () => { - const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { waitSeconds: 20 }) + const command = stopOrcadCommand(posix, SPEC.remoteInstallDir, { + waitSeconds: 20, + nodePath: SPEC.nodePath + }) expect(command).toContain('kill -TERM') for (const kill of ['kill -9', 'kill -KILL', 'kill -SIGKILL', 'pkill']) { expect(command).not.toContain(kill) @@ -110,9 +113,10 @@ describe('stopping a running orcad', () => { it.each([ ['STOPPED', 'stopped', true], ['ALREADY_EXITED', 'already-exited', true], - ['NO_PID', 'no-pid', true], + ['NO_PID', 'no-pid', false], ['STILL_RUNNING', 'still-running', false], ['SIGNAL_FAILED', 'signal-failed', false], + ['UNKNOWN', 'unknown', false], ['', 'unknown', false] ])('parses %s and frees the host = %s', (output, expected, frees) => { expect(parseOrcadStopOutcome(output)).toBe(expected) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index a76010ccbce..3abd43ed1d9 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -62,7 +62,8 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp 'umask 077 &&', `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, - `nohup ${shellEscape(spec.nodePath)} ${entry}`, + // Keep $! equal to the runtime PID rather than a waiting shell's PID. + `exec nohup ${shellEscape(spec.nodePath)} ${entry}`, `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, `echo $! > ${pidFile} && cat ${pidFile}` diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index 6a9038ea3d6..c7f78331bd5 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -10,6 +10,7 @@ */ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' import { assertPosixOrcadHost as assertPosixHost, ORCAD_PID_FILENAME, @@ -19,20 +20,37 @@ import { /** * Signal the orcad recorded in a version dir and wait for it to go. * - * `escalate` sends the second SIGTERM orcad reads as "exit immediately". Callers use it only - * after the first deadline elapses, so the two signals are never in the same command. + * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Incumbents need their own readiness PID to corroborate the launcher's PID before any signal. */ export function stopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number } + options: { waitSeconds: number } & ( + | { justLaunched: true } + | { justLaunched?: false; nodePath: string } + ) ): string { assertPosixHost(host) const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) + const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + const readRuntimePid = [ + `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, + `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, + `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, + `process.stdout.write(String(pid));` + ].join(' ') return [ - posixProcessAliveShellFunction(), + posixProcessAliveShellFunction({ refuseUnverifiable: true }), `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', + // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. + ...(options.justLaunched + ? [] + : [ + `runtime_pid=$(${shellEscape(options.nodePath)} -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, + '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' + ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, @@ -69,5 +87,5 @@ export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { /** True when the port is free and a successor may bind. */ export function orcadStopFreedTheHost(outcome: OrcadStopOutcome): boolean { - return outcome === 'stopped' || outcome === 'already-exited' || outcome === 'no-pid' + return outcome === 'stopped' || outcome === 'already-exited' } diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 03df9479e4a..985bc05a7b3 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -142,7 +142,10 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise() function sh(command: string): string { return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) @@ -52,10 +75,141 @@ beforeEach(() => { }) afterEach(() => { + for (const pid of launchedPids) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // Successful stops have already removed their test processes. + } + } + launchedPids.clear() rmSync(root, { recursive: true, force: true }) }) +async function launchTestRuntime(legacyWrapper = false): Promise<{ + runtimePid: number + recordedPid: number + terminatedFile: string +}> { + const terminatedFile = join(versionDir, 'terminated') + writeFileSync( + join(versionDir, 'orcad.js'), + [ + `process.on('SIGTERM', () => {`, + ` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, + ` process.exit(0);`, + `});`, + `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`, + `setTimeout(() => process.exit(1), 10_000);` + ].join('\n') + ) + let command = orcadLaunchCommand(host, { + remoteInstallDir: versionDir, + nodePath: process.execPath, + fullVersion: '0.2.0+bb01', + userDataDir: dataDir, + bindHost: '127.0.0.1', + port: 0 + }) + if (legacyWrapper) { + // The trailing command retains the old macOS waiting-shell behavior on every POSIX shell. + command = command + .replace('exec nohup ', 'nohup ') + .replace('< /dev/null &', '< /dev/null && : &') + } + execFileSync('/bin/sh', ['-c', command], { stdio: 'ignore', timeout: 5_000 }) + const recordedPid = Number(readFileSync(join(versionDir, ORCAD_PID_FILENAME), 'utf8').trim()) + expect(recordedPid).toBeGreaterThan(1) + launchedPids.add(recordedPid) + const readRuntimePid = (): number => { + const parsed = parseOrcadReadinessOutput( + readFileSync(join(versionDir, ORCAD_READINESS_FILENAME), 'utf8') + ) + return parsed.state === 'ready' ? (parsed.readiness.health?.pid ?? 0) : 0 + } + await expect.poll(readRuntimePid, { timeout: 2_000, interval: 20 }).toBeGreaterThan(1) + const runtimePid = readRuntimePid() + launchedPids.add(runtimePid) + return { runtimePid, recordedPid, terminatedFile } +} + +function stopTestRuntime(justLaunched = false): ReturnType { + return parseOrcadStopOutcome( + execFileSync( + '/bin/sh', + [ + '-c', + stopOrcadCommand(host, versionDir, { + waitSeconds: 3, + ...(justLaunched ? { justLaunched: true as const } : { nodePath: process.execPath }) + }) + ], + { encoding: 'utf8', timeout: 5_000 } + ) + ) +} + describe('state snapshot commands, run for real', () => { + it('detects candidate SQLite migration without modifying current state or the snapshot', () => { + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + const archive = readFileSync(join(snapshotDir, 'state.tar')) + const compare = (): boolean => + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + expect(compare()).toBe(true) + writeFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'live-daemon-after-launch') + expect(compare()).toBe(true) + + const databasePath = join(dataDir, 'profiles', 'p1', 'profile-state.db') + const candidate = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson(candidate.db, '{"settings":{"theme":"dark"}}') + } finally { + candidate.db.close() + } + const databaseBytes = readFileSync(databasePath) + + expect(compare()).toBe(false) + expect(readFileSync(databasePath)).toEqual(databaseBytes) + expect(readFileSync(join(snapshotDir, 'state.tar'))).toEqual(archive) + expect(readFileSync(join(dataDir, 'daemon', 'daemon.sock.token'), 'utf8')).toBe( + 'live-daemon-after-launch' + ) + }) + + it('requires an intact comparison snapshot before an older build can restart', () => { + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) + writeFileSync(join(snapshotDir, 'state.tar'), 'not an archive') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + expect(readFileSync(join(dataDir, 'profiles', 'p1', 'orca-data.json'), 'utf8')).toBe( + '{"repos":"before"}' + ) + }) + + it('rejects symlinked profile state that a tar snapshot does not preserve', () => { + const external = join(root, 'external-profile') + mkdirSync(external) + writeFileSync(join(external, 'orca-data.json'), '{"before":true}') + const profile = join(dataDir, 'profiles', 'linked') + symlinkSync(external, profile) + + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('failed') + + mkdirSync(snapshotDir, { recursive: true }) + execFileSync('tar', ['-C', dataDir, '-cf', join(snapshotDir, 'state.tar'), 'profiles']) + writeFileSync(join(external, 'orca-data.json'), '{"candidate":true}') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe(false) + expect(readFileSync(join(external, 'orca-data.json'), 'utf8')).toBe('{"candidate":true}') + }) + it('captures, then restores state the newer build overwrote', () => { expect( parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) @@ -74,6 +228,49 @@ describe('state snapshot commands, run for real', () => { expect(() => readFileSync(join(dataDir, 'profiles', 'p1', 'new-build-only.json'))).toThrow() }) + it('round-trips a quiescent SQLite profile database with its WAL sidecars', () => { + const profileDirectory = join(dataDir, 'profiles', 'p1') + const databasePath = join(profileDirectory, 'profile-state.db') + const opened = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson( + opened.db, + JSON.stringify({ settings: { theme: 'dark' }, snapshotMarker: 'before' }) + ) + // The connection remains open, so WAL/SHM are still part of the archive boundary while + // the generated command reads the now-quiescent files. + expect(existsSync(`${databasePath}-wal`)).toBe(true) + expect( + parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('captured') + } finally { + opened.db.close() + } + + const changed = openProfileStateDatabase(databasePath, 'p1') + try { + importProfileStateJson( + changed.db, + JSON.stringify({ settings: { theme: 'light' }, snapshotMarker: 'after' }) + ) + } finally { + changed.db.close() + } + expect( + parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, dataDir, snapshotDir))) + ).toBe('restored') + + const restored = openProfileStateDatabase(databasePath, 'p1') + try { + expect(JSON.parse(exportProfileStateJson(restored.db))).toEqual({ + settings: { theme: 'dark' }, + snapshotMarker: 'before' + }) + } finally { + restored.db.close() + } + }) + it('leaves the live daemon runtime dir untouched through capture and restore', () => { sh(captureOrcadStateSnapshotCommand(host, dataDir, snapshotDir)) // The daemon is running across the rollback and rewrites its token; a restore that @@ -115,7 +312,13 @@ describe('state snapshot commands, run for real', () => { expect( parseOrcadSnapshotCapture(sh(captureOrcadStateSnapshotCommand(host, nasty, snapshotDir))) ).toBe('captured') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe(true) writeFileSync(join(nasty, 'orca-profile-index.json'), '{"v":"changed"}') + expect( + orcadSnapshotIsUnchanged(sh(compareOrcadStateSnapshotCommand(host, nasty, snapshotDir))) + ).toBe(false) expect( parseOrcadSnapshotRestore(sh(restoreOrcadStateSnapshotCommand(host, nasty, snapshotDir))) ).toBe('restored') @@ -124,6 +327,85 @@ describe('state snapshot commands, run for real', () => { }) describe('liveness and stop commands, run for real', () => { + it('records the runtime PID and waits for that runtime to exit when stopped', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() + expect(recordedPid).toBe(runtimePid) + expect(stopTestRuntime()).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + // An unreaped zombie has exited even though kill -0 still succeeds. + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + expect(existsSync(join(versionDir, ORCAD_PID_FILENAME))).toBe(true) + expect(stopTestRuntime()).toBe('already-exited') + }) + + it('refuses a legacy wrapper PID both before and after its shell exits', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true) + expect(recordedPid).not.toBe(runtimePid) + const beforeWrapperExit = stopTestRuntime() + expect(beforeWrapperExit).toBe('unknown') + expect(orcadStopFreedTheHost(beforeWrapperExit)).toBe(false) + expect(() => process.kill(recordedPid, 0)).not.toThrow() + expect(() => process.kill(runtimePid, 0)).not.toThrow() + + process.kill(recordedPid, 'SIGTERM') + await expect + .poll(() => sh(`ps -o stat= -p ${recordedPid} || true`).trim(), { timeout: 2_000 }) + .toMatch(/^(?:Z.*)?$/) + const afterWrapperExit = stopTestRuntime() + expect(afterWrapperExit).toBe('unknown') + expect(orcadStopFreedTheHost(afterWrapperExit)).toBe(false) + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + + it.each(['missing', 'malformed', 'without-health'])( + 'refuses an incumbent with %s readiness proof', + async (proof) => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + const readinessFile = join(versionDir, ORCAD_READINESS_FILENAME) + if (proof === 'missing') { + rmSync(readinessFile) + } else { + writeFileSync(readinessFile, proof === 'malformed' ? '{' : '{"type":"orca_server_ready"}') + } + expect(stopTestRuntime()).toBe('unknown') + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + } + ) + + it('can stop a candidate just launched with exec without readiness', async () => { + const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() + expect(recordedPid).toBe(runtimePid) + rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) + expect(stopTestRuntime(true)).toBe('stopped') + expect(readFileSync(terminatedFile, 'utf8')).toBe('terminated') + expect(sh(`ps -o stat= -p ${runtimePid} || true`).trim()).toMatch(/^(?:Z.*)?$/) + }) + + it.each(['before', 'after'])('refuses a permission-denied probe %s SIGTERM', async (phase) => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + const deniedProbe = [ + 'term_sent=0; kill() {', + 'if [ "$1" = -TERM ]; then term_sent=1; return 0; fi;', + `if [ '${phase}' = before ] || [ "$term_sent" = 1 ]; then`, + 'echo "kill: Operation not permitted" >&2; return 1; fi;', + 'command kill "$@"; };' + ].join(' ') + const outcome = parseOrcadStopOutcome( + sh( + `${deniedProbe} ${stopOrcadCommand(host, versionDir, { + waitSeconds: 1, + nodePath: process.execPath + })}` + ) + ) + expect(outcome).toBe('unknown') + expect(orcadStopFreedTheHost(outcome)).toBe(false) + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + it('reports UNKNOWN with no pid file, and DEAD for a pid that has exited', () => { expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('UNKNOWN') writeFileSync(join(versionDir, ORCAD_PID_FILENAME), 'not-a-pid') @@ -144,7 +426,9 @@ describe('liveness and stop commands, run for real', () => { child.once('exit', (_code, signal) => resolve(signal)) ) expect( - parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 }))) + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + ) ).toBe('stopped') expect(await exited).toBe('SIGTERM') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') @@ -166,7 +450,9 @@ describe('liveness and stop commands, run for real', () => { expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') expect( - parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 }))) + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + ) ).toBe('already-exited') } finally { child.unref() @@ -176,14 +462,18 @@ describe('liveness and stop commands, run for real', () => { it('reports ALREADY_EXITED for a stale pid file rather than signalling a stranger', () => { const exited = Number(sh('sh -c "echo $$"').trim()) writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) - expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( - 'already-exited' - ) + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + ) + ).toBe('already-exited') }) it('reports NO_PID when the version dir was never launched', () => { - expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1 })))).toBe( - 'no-pid' - ) + expect( + parseOrcadStopOutcome( + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, nodePath: process.execPath })) + ) + ).toBe('no-pid') }) }) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index 0c50dc55fdb..f660e95e5a4 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -4,6 +4,7 @@ import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS, captureOrcadStateSnapshotCommand, + compareOrcadStateSnapshotCommand, newestStateMtimeCommand, orcadSnapshotDirName, parseNewestStateMtimeSeconds, @@ -93,6 +94,7 @@ describe('Windows hosts', () => { it.each([ ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], + ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['mtime', () => newestStateMtimeCommand(windows, ROOT)] ])('refuses %s rather than emitting a POSIX command', (_label, build) => { expect(build).toThrow('orcad to a Windows host is not implemented') diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index 78ad8b47d2a..7d062275442 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -27,7 +27,9 @@ export const ORCAD_SNAPSHOT_MEMBERS = [ 'orca-profile-index.json', // Pre-profiles layout; still read as a migration source. 'orca-data.json', - 'profiles' + 'profiles', + // Cross-profile SQLite moves must survive an orcad rollback too. + 'profile-move-intents' ] as const /** Never captured and never restored — see the module comment. */ @@ -45,6 +47,10 @@ function assertPlainMemberName(member: string): string { return member } +function noSymlinkedStateCommand(path: string): string { + return `links=$(find ${path} -type l -print) && [ -z "$links" ]` +} + export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string { // Why the version and the timestamp: two activations of one version (a re-deploy after a // rejected activation) must not overwrite each other's snapshot. @@ -72,8 +78,14 @@ export function captureOrcadStateSnapshotCommand( // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. // `assertPlainMemberName` is what makes leaving them bare safe. - (member) => - `[ -e ${root}/${shellEscape(member)} ] && members="$members ${assertPlainMemberName(member)}";` + (member) => { + const path = `${root}/${shellEscape(member)}` + return ( + `if [ -e ${path} ] || [ -L ${path} ]; then ` + + `${noSymlinkedStateCommand(path)} || { echo FAILED; exit 0; }; ` + + `members="$members ${assertPlainMemberName(member)}"; fi;` + ) + } ).join(' ') return [ `members=;`, @@ -145,6 +157,42 @@ export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore return value === 'MISSING' ? 'missing' : 'failed' } +/** Compare after stopping the candidate; a changed root cannot be handed to an older build. */ +export function compareOrcadStateSnapshotCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotDir: string +): string { + assertPosixHost(host) + const root = shellEscape(userDataDir) + const dir = shellEscape(snapshotDir) + const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) + const comparisons = ORCAD_SNAPSHOT_MEMBERS.map((member) => { + const name = shellEscape(member) + return [ + `if [ -e ${root}/${name} ] || [ -L ${root}/${name} ]; then`, + `${noSymlinkedStateCommand(`${root}/${name}`)} || { echo UNKNOWN; exit 0; };`, + `diff -r ${root}/${name} "$comparison"/${name} >/dev/null 2>&1 || verdict=CHANGED;`, + `elif [ -e "$comparison"/${name} ] || [ -L "$comparison"/${name} ]; then verdict=CHANGED; fi;` + ].join(' ') + }).join(' ') + return [ + `test -d ${root} && test -r ${root} && test -x ${root} || { echo UNKNOWN; exit 0; };`, + `test -f ${archive} || { echo UNKNOWN; exit 0; };`, + `comparison=$(mktemp -d ${dir}/compare.XXXXXX) || { echo UNKNOWN; exit 0; };`, + `trap 'rm -rf "$comparison"' EXIT HUP INT TERM;`, + `tar -C "$comparison" -xf ${archive} || { echo UNKNOWN; exit 0; };`, + `${noSymlinkedStateCommand('"$comparison"')} || { echo UNKNOWN; exit 0; };`, + 'verdict=UNCHANGED;', + comparisons, + 'echo "$verdict"' + ].join(' ') +} + +export function orcadSnapshotIsUnchanged(output: string): boolean { + return output.trim().split('\n').pop()?.trim() === 'UNCHANGED' +} + /** * Has the shared store been written since `activatedAt`? * diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts index afb365c075c..7a503f51867 100644 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts @@ -214,7 +214,7 @@ describe('sweepOrphanedRelayPtys', () => { clearBindingsForTarget: () => {}, clearBindingsForLeases: () => false, flush: () => {}, - flushDurableStateOrThrowAsync: async () => {} + runDurableMutation: async (mutate) => mutate().value } // The same pane re-leases under a new relay id; pty-1 is expired, never terminated. upsertSshRemotePtyLease(operations, { diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts index db49a45c399..727f293b572 100644 --- a/src/main/ssh/ssh-pty-consumer-recovery.ts +++ b/src/main/ssh/ssh-pty-consumer-recovery.ts @@ -96,10 +96,7 @@ export async function removeSshPtyConsumerOwnerRecovery( clientInstanceId: string, store: Store ): Promise { - const persisted = store.getSshPtyConsumerRecovery(targetId) - if (persisted?.clientInstanceId === clientInstanceId) { - await store.removeSshPtyConsumerRecovery(targetId) - } + await store.removeSshPtyConsumerRecovery(targetId, clientInstanceId) } export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void { diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index e5683c0949a..da528f7f95f 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -344,7 +344,8 @@ describe('SshRelaySession data delivery', () => { }) ) session.dispose() - expect(mockStore.removeSshPtyConsumerRecovery).toHaveBeenCalledWith(targetId) + const removeRecovery = mockStore.removeSshPtyConsumerRecovery + expect(removeRecovery).toHaveBeenCalledWith(targetId, 'persisted-client') }) it('voids checkpoints for a fresh claim without a second owner request', async () => { diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 6760a27821c..3c405cd5dff 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto' import type * as NodeCrypto from 'node:crypto' import { SshRelaySession } from './ssh-relay-session' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import { + createMockDeps, + mockDeploySuccess, + recordedPtyBindings +} from './ssh-relay-session-test-fixtures' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { SshRemotePtyLease } from '../../shared/ssh-types' @@ -439,7 +443,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId }) expect(runtime.onPtySpawned).not.toHaveBeenCalled() - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith({ + expect(recordedPtyBindings(mockStore)).toContainEqual({ worktreeId: 'worktree-1', tabId: 'tab-1', leafId: INCARNATION_LEAF_ID, @@ -643,7 +647,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { leafId: INCARNATION_LEAF_ID, incarnationId }) - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect(recordedPtyBindings(mockStore)).toContainEqual( expect.objectContaining({ tabId: movedTabId, ptyId: APP_PTY_ID, incarnationId }) ) expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( @@ -763,7 +767,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId: currentIncarnationId }) expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'target-1') - expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( + expect(recordedPtyBindings(mockStore)).toContainEqual( expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) ) expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 26dd798ac5a..f4600bb23e6 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -140,17 +140,21 @@ describe('SshRelaySession recovery race fencing', () => { mockDeploySuccess() }) - function emitSourceFrame(args: { - targetId: string - token: string - clientGeneration: number - ownerGeneration: number - sourceStartSu: number - sourceEndSu: number - }): void { - ptyDataHandlerRef.current?.({ + function emitSourceFrame( + args: { + targetId: string + token: string + clientGeneration: number + ownerGeneration: number + sourceStartSu: number + sourceEndSu: number + data?: string + }, + sink = ptyDataHandlerRef.current + ): void { + sink?.({ id: `ssh:${args.targetId}@@pty-1`, - data: 'late', + data: args.data ?? 'late', providerGeneration: 23, ptyIncarnation: 'incarnation-1', sequenceChars: args.sourceEndSu - args.sourceStartSu, @@ -227,6 +231,66 @@ describe('SshRelaySession recovery race fencing', () => { return { session, deps } } + it('keeps source output contiguous while the recovered pane binding waits for disk', async () => { + const targetId = 'recovery-binding-disk-wait' + const { session, deps } = await prepareRecovery(targetId) + const binding = Promise.withResolvers() + vi.mocked(deps.mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId, + ptyId: 'pty-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ]) + vi.mocked(deps.mockStore.persistPtyBinding).mockReturnValue(binding.promise) + const emit = (sourceStartSu: number, sink = ptyDataHandlerRef.current): void => + emitSourceFrame( + { + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu, + sourceEndSu: sourceStartSu + 4 + }, + sink + ) + let recoverySink: typeof ptyDataHandlerRef.current + const recoveryActivationLease = { commit: vi.fn(), retire: vi.fn() } + attachForReconnectMock.mockResolvedValue({ + incarnationId: 'incarnation-1', + sourceRecovery: pendingRecovery(8), + sourceActivationLease: { + commit: vi.fn(), + rollback: vi.fn(async () => true), + transferToRecovery: (sink: (payload: unknown) => void) => { + recoverySink = sink + emit(4, sink) + completeRecovery({ id: 'pty-1', ...pendingRecovery(8) }) + return recoveryActivationLease + } + } + }) + + const reconnect = session.reconnect(deps.mockConn) + await vi.waitFor(() => expect(deps.mockStore.persistPtyBinding).toHaveBeenCalledOnce()) + emit(8, recoverySink) + expect(recoveryActivationLease.commit).not.toHaveBeenCalled() + binding.resolve(true) + await reconnect + emit(12) + + expect( + acceptOutputDataMock.mock.calls.map(([payload]) => payload.source.sourceStartSu) + ).toEqual([4, 8, 12]) + expect(recoveryActivationLease.commit).toHaveBeenCalledOnce() + }) + it('publishes held recovery data before an exact exit without waiting for completion', async () => { const targetId = 'exit-with-complete-private-body' const { session, deps } = await prepareRecovery(targetId) @@ -235,22 +299,18 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(async () => true), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'held', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:8', + emitSourceFrame( + { + targetId, + token: 'new-token', clientGeneration: 2, ownerGeneration: 2, - deliveryToken: 'new-token', sourceStartSu: 4, - sourceEndSu: 8 - } - }) + sourceEndSu: 8, + data: 'held' + }, + sink + ) return recoveryActivationLease }) } @@ -301,22 +361,18 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(async () => true), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'partial', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 2, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:6', + emitSourceFrame( + { + targetId, + token: 'new-token', clientGeneration: 2, ownerGeneration: 2, - deliveryToken: 'new-token', sourceStartSu: 4, - sourceEndSu: 6 - } - }) + sourceEndSu: 6, + data: 'partial' + }, + sink + ) return recoveryActivationLease }) } @@ -501,38 +557,23 @@ describe('SshRelaySession recovery race fencing', () => { commit: vi.fn(), rollback: vi.fn(), transferToRecovery: vi.fn((sink: (payload: unknown) => void) => { - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'x'.repeat(2 * 1024 * 1024 + 1), - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:4:8', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu: 4, - sourceEndSu: 8 - } - }) - sink({ - id: `ssh:${targetId}@@pty-1`, - data: 'later', - providerGeneration: 23, - ptyIncarnation: 'incarnation-1', - sequenceChars: 4, - source: { - relayPtyId: 'pty-1', - spanId: 'new-token:8:12', - clientGeneration: 2, - ownerGeneration: 2, - deliveryToken: 'new-token', - sourceStartSu: 8, - sourceEndSu: 12 - } - }) + for (const [data, sourceStartSu] of [ + ['x'.repeat(2 * 1024 * 1024 + 1), 4], + ['later', 8] + ] as const) { + emitSourceFrame( + { + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu, + sourceEndSu: sourceStartSu + 4, + data + }, + sink + ) + } return recoveryActivationLease }) } diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index ba0782b3a20..c186e922e93 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -1,6 +1,7 @@ import { vi, type Mock } from 'vitest' import type { BrowserWindow } from 'electron' import type { SshConnection } from './ssh-connection' +import type { PersistPtyBindingArgs } from '../persistence/loading-store/pty-binding-persistence' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' import { deployAndLaunchRelay } from './ssh-relay-deploy' @@ -13,8 +14,16 @@ type SshRelaySessionTestDeps = { mockWindow: BrowserWindow } +const persistedBindings = new WeakMap() + +export function recordedPtyBindings(store: Store): readonly PersistPtyBindingArgs[] { + return persistedBindings.get(store) ?? [] +} + export function createMockDeps(): SshRelaySessionTestDeps { + const bindings: PersistPtyBindingArgs[] = [] const mockConn = {} as SshConnection + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The relay fixture implements the Store methods exercised by session establishment and teardown. const mockStore = { getRepos: vi.fn().mockReturnValue([]), getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), @@ -33,8 +42,16 @@ export function createMockDeps(): SshRelaySessionTestDeps { recordSshRemotePtyKillIntent: vi.fn(), clearSshRemotePtyKillIntent: vi.fn(), noteSshRemotePtyKillReplayAttempt: vi.fn(), - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(async (input: Parameters[0]) => { + const binding = typeof input === 'function' ? input() : input + if (!binding) { + return false + } + bindings.push(binding) + return true + }) } as unknown as Store + persistedBindings.set(mockStore, bindings) const mockPortForward = { removeAllForwards: vi.fn() } as unknown as SshPortForwardManager diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19e102b08ed..b739fbd3d8c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1799,10 +1799,6 @@ export class SshRelaySession { } const pending = this.pendingPtyReattaches.get(payload.id) if (pending && this.activePtyConsumerOwner()?.outputFlowControl) { - if (pending.livePassthrough) { - void this.acceptPtyData(payload).catch(() => {}) - return - } this.quarantineReattachData(pending, payload) return } @@ -2074,6 +2070,12 @@ export class SshRelaySession { } private quarantineReattachData(pending: PendingPtyReattach, payload: SshPtyDataPayload): void { + if (pending.livePassthrough) { + if (this.ownsPtyRecoveryAttempt(payload.id, pending)) { + void this.acceptPtyData(payload).catch(() => {}) + } + return + } this.observePrivateRecoveryFrame(pending, payload) if (pending.restoreRequired) { return @@ -2575,11 +2577,15 @@ export class SshRelaySession { return } if (attachResult.incarnationId) { - const restoreResult = this.restoreReattachedPtyRuntime( + const restoreResult = await this.restoreReattachedPtyRuntime( appPtyId, attachResult.incarnationId, - activeLease + activeLease, + () => shouldContinue() && this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach) ) + if (!shouldContinue() || !this.ownsPtyRecoveryAttempt(appPtyId, pendingReattach)) { + return + } if (restoreResult !== 'restored') { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) @@ -2742,45 +2748,56 @@ export class SshRelaySession { deletePtyOwnership(appPtyId) } - private restoreReattachedPtyRuntime( + private async restoreReattachedPtyRuntime( appPtyId: string, incarnationId: string, - lease: SshPtyLease | undefined - ): ReattachedPtyRuntimeRestore { + lease: SshPtyLease | undefined, + shouldContinue: () => boolean + ): Promise { if (lease?.worktreeId && lease.tabId && lease.leafId) { - const session = this.store.getWorkspaceSession?.() - // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so - // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that - // merely moved. Leaf is the identity, the tab is only where it currently sits. - // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a - // fence that consulted only one partition would read "no pane" for a pane the other holds. - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) - const tabId = - findTerminalTabIdForLeaf(session, lease.leafId) ?? - findTerminalTabIdForLeaf(hostSession, lease.leafId) ?? - lease.tabId - // Absence of the pane only means "the user closed it" once the persisted membership - // speaks for this worktree. Before that it means the renderer has not published its - // layout yet, and refusing there drops a tab the user still has — the regression that - // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an - // unauthoritative session still gets the creating write. - // Authority is read from `local` because that is the partition this write lands in — it - // is local's absence we would be interpreting. But a pane the other partition still holds - // is not gone, so it keeps its creating write: refusing there would strand a live pane - // behind a binding reattach can no longer reach. - const mayCreate = - !hasHostAuthoritativeTerminalMembership(session, lease.worktreeId) || - findTerminalTabIdForLeaf(hostSession, lease.leafId) !== undefined - const bound = this.store.persistPtyBinding({ - worktreeId: lease.worktreeId, - tabId, - leafId: lease.leafId, - ptyId: appPtyId, - incarnationId, - ...(mayCreate ? {} : { mayCreate: false }), - mayReviveRetiredSurface: false, - origin: 'relay_reattach' + const { worktreeId, leafId, tabId: leaseTabId } = lease + let tabId = lease.tabId + const bound = await this.store.persistPtyBinding(() => { + if (!shouldContinue()) { + return null + } + const session = this.store.getWorkspaceSession?.() + // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so + // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that + // merely moved. Leaf is the identity, the tab is only where it currently sits. + // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a + // fence that consulted only one partition would read "no pane" for a pane the other holds. + const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) + tabId = + findTerminalTabIdForLeaf(session, leafId) ?? + findTerminalTabIdForLeaf(hostSession, leafId) ?? + leaseTabId + // Absence of the pane only means "the user closed it" once the persisted membership + // speaks for this worktree. Before that it means the renderer has not published its + // layout yet, and refusing there drops a tab the user still has — the regression that + // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an + // unauthoritative session still gets the creating write. + // Authority is read from `local` because that is the partition this write lands in — it + // is local's absence we would be interpreting. But a pane the other partition still holds + // is not gone, so it keeps its creating write: refusing there would strand a live pane + // behind a binding reattach can no longer reach. + const mayCreate = + !hasHostAuthoritativeTerminalMembership(session, worktreeId) || + findTerminalTabIdForLeaf(hostSession, leafId) !== undefined + return { + worktreeId: worktreeId, + tabId, + leafId: leafId, + ptyId: appPtyId, + incarnationId, + ...(mayCreate ? {} : { mayCreate: false }), + mayReviveRetiredSurface: false, + origin: 'relay_reattach' as const + } }) + if (!shouldContinue()) { + return 'missing-surface' + } if (bound === false) { // Topology absence alone is not authority to kill a process, but neither refusal may // publish or replay into a missing pane. diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 5913890b5cf..ee14f1a8d3e 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -26,19 +26,36 @@ const mocks = vi.hoisted(() => { events.push('is-ready') return false }), + whenReady: vi.fn(() => Promise.resolve()), setName: vi.fn((name: string) => { events.push(`set-name:${name}`) }) } - return { app, events, userAgent: () => userAgent } + return { + app, + events, + userAgent: () => userAgent, + showErrorBox: vi.fn(), + backgroundLaunch: vi.fn(() => true), + admission: vi.fn(), + lock: vi.fn(() => true), + afterIdentity: vi.fn((): void => { + throw new Error('preflight-test-stop') + }), + recoverMoves: vi.fn() + } }) vi.mock('electron', () => ({ app: mocks.app, + dialog: { showErrorBox: mocks.showErrorBox }, ipcMain: {}, powerMonitor: {}, session: { defaultSession: {} } })) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: mocks.backgroundLaunch +})) vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } })) vi.mock('./cli-launch-redirect', () => ({ maybeRedirectCliLaunch: () => ({ redirected: false, status: 0 }) @@ -83,10 +100,7 @@ vi.mock('./dev-instance-identity', () => ({ })) vi.mock('./renderer-heap-headroom') vi.mock('./startup-diagnostics', () => ({ - isStartupDiagnosticsEnabled: () => { - mocks.events.push('continued-after-browser-identity') - throw new Error('preflight-test-stop') - }, + isStartupDiagnosticsEnabled: () => false, logStartupDiagnostic: vi.fn() })) vi.mock('./event-loop-stall-probe') @@ -100,8 +114,11 @@ vi.mock('./serve-desktop-activation', () => ({ })) vi.mock('./single-instance-lock', () => ({ shouldBypassSingleInstanceLock: () => false, - shouldSkipSingleInstanceLock: () => true, - acquireSingleInstanceLock: vi.fn(), + shouldSkipSingleInstanceLock: () => false, + acquireSingleInstanceLock: () => { + mocks.events.push('single-instance-lock') + return mocks.lock() + }, logSingleInstanceLockBypass: vi.fn(), logSingleInstanceLockFailure: vi.fn(), SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE: 1 @@ -109,7 +126,12 @@ vi.mock('./single-instance-lock', () => ({ vi.mock('../../shared/app-environment', () => ({ setAppEnvironment: vi.fn() })) vi.mock('../host/electron-app-environment', () => ({ ElectronAppEnvironment: class {} })) vi.mock('../own-chromium-tree-kill-guard') -vi.mock('../../shared/secret-store') +vi.mock('../../shared/secret-store', () => ({ + setSecretStore: () => { + mocks.events.push('continued-after-browser-identity') + mocks.afterIdentity() + } +})) vi.mock('../host/electron-secret-store') vi.mock('../ipc/pty-host-bindings') vi.mock('../host/electron-runtime-desktop-surface') @@ -127,9 +149,24 @@ vi.mock('../persistence', () => ({ initDataPath: () => mocks.events.push('init-data-path'), getCanonicalUserDataPath: () => '/canonical-user-data' })) +vi.mock('../persistence/profile-state/profile-state-access', () => ({ + acquireProfileStateRuntimeAdmission: (root: string) => { + mocks.events.push(`admission:${root}`) + return mocks.admission() + } +})) vi.mock('../macos-press-and-hold-default') vi.mock('../ai-vault/session-parse-cache-persistence') -vi.mock('../orca-profiles/profile-index-store') +vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths: vi.fn() })) +vi.mock('../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => '/canonical-user-data' +})) +vi.mock('../orca-profiles/profile-project-move-intent', () => ({ + recoverPendingProfileProjectMoves: mocks.recoverMoves +})) +vi.mock('../persistence/profile-state/profile-state-active-location', () => ({ + getActiveProfileStateLocation: () => ({ profileId: 'active-profile' }) +})) vi.mock('../stats/collector') vi.mock('../claude-usage/store') vi.mock('../codex-usage/store') @@ -163,23 +200,65 @@ vi.mock('../browser/browser-identity-mode-store', () => ({ })) describe('browser process user-agent startup ordering', () => { + it('explains admission refusal before a desktop launch exits', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mocks.backgroundLaunch.mockReturnValueOnce(false) + mocks.admission.mockImplementationOnce(() => { + throw new Error('Stop Orca and orcad before retrying profile recovery') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.showErrorBox).toHaveBeenCalledWith( + 'Orca could not start', + expect.stringContaining('Stop Orca and orcad before retrying profile recovery') + ) + expect(mocks.app.isReady).not.toHaveBeenCalled() + } finally { + error.mockRestore() + platform.mockRestore() + mocks.showErrorBox.mockClear() + mocks.events.length = 0 + } + }) + + it('does not acquire profile admission for a duplicate launch', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.events.length = 0 + mocks.lock.mockReturnValueOnce(false) + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.events).not.toContain('admission:/canonical-user-data') + expect(mocks.events).not.toContain('read-mode:/canonical-user-data') + expect(mocks.app.exit).toHaveBeenCalledWith(1) + mocks.events.length = 0 + }) + it('executes after the dev app name and before later preflight work', async () => { const { getBrowserProcessUserAgentIdentity } = await import('../browser/browser-process-user-agent') const { runMainProcessPreflight } = await import('./main-process-preflight') - expect(() => + expect( runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) - ).toThrow('preflight-test-stop') + ).toBe(false) const nameIndex = mocks.events.indexOf('set-name:Orca Development') const modeIndex = mocks.events.indexOf('read-mode:/canonical-user-data') const writeIndex = mocks.events.indexOf('write-user-agent') const continuationIndex = mocks.events.indexOf('continued-after-browser-identity') expect(mocks.events.indexOf('init-data-path')).toBeLessThan(nameIndex) + expect(mocks.events.indexOf('init-data-path')).toBeLessThan( + mocks.events.indexOf('admission:/canonical-user-data') + ) + expect(mocks.events.indexOf('admission:/canonical-user-data')).toBeLessThan(modeIndex) expect(nameIndex).toBeLessThan(modeIndex) expect(modeIndex).toBeLessThan(writeIndex) expect(writeIndex).toBeLessThan(continuationIndex) @@ -191,4 +270,193 @@ describe('browser process user-agent startup ordering', () => { expect(mocks.userAgent()).not.toMatch(/Electron/) expect(mocks.userAgent()).not.toMatch(/Orca|Development/) }) + + it('exits without reading profile state or revealing a window when recovery holds admission', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + mocks.events.length = 0 + mocks.admission.mockImplementationOnce(() => { + throw new Error('Profile recovery is in progress') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const focusExistingWindow = vi.fn() + const requestDesktopActivation = vi.fn() + try { + expect(runMainProcessPreflight({ focusExistingWindow, requestDesktopActivation })).toBe(false) + expect(mocks.app.exit).toHaveBeenCalledWith(1) + expect(mocks.events).toEqual([ + 'init-data-path', + 'set-name:Orca Development', + 'single-instance-lock', + 'admission:/canonical-user-data' + ]) + expect(focusExistingWindow).not.toHaveBeenCalled() + expect(requestDesktopActivation).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + } finally { + error.mockRestore() + } + }) +}) + +it('exits and releases admission after pending profile move recovery fails', async () => { + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => undefined) + mocks.recoverMoves.mockImplementationOnce(() => { + throw new Error('unreadable move journal') + }) + const { runMainProcessPreflight } = await import('./main-process-preflight') + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(mocks.recoverMoves).toHaveBeenCalledWith('/canonical-user-data', 'active-profile') + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.exit).toHaveBeenCalledWith(1) +}) + +it('defers a Linux desktop startup failure until Electron is ready', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux pre-ready failure') + }) + mocks.app.whenReady.mockReturnValueOnce(ready) + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).toHaveBeenCalledOnce() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).not.toHaveBeenCalled() + + resolveReady?.() + await ready + await Promise.resolve() + expect(mocks.showErrorBox).toHaveBeenCalledWith( + 'Orca could not start', + expect.stringContaining('Linux pre-ready failure') + ) + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + mocks.app.whenReady.mockClear() + } +}) + +it('exits after a Linux desktop readiness rejection without showing a dialog', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + let rejectReady!: (error: Error) => void + const ready = new Promise((_resolve, reject) => { + rejectReady = reject + }) + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux pre-ready failure') + }) + mocks.app.whenReady.mockReturnValueOnce(ready) + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + rejectReady(new Error('Electron readiness failed')) + await ready.catch(() => undefined) + await Promise.resolve() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + mocks.app.whenReady.mockClear() + } +}) + +it('keeps Linux background startup failures console-only and immediate', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux background failure') + }) + mocks.app.whenReady.mockClear() + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(true) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + } +}) + +it('keeps Linux serve startup failures console-only and immediate', async () => { + const { runMainProcessPreflight } = await import('./main-process-preflight') + const { resetBrowserProcessUserAgentForTests } = + await import('../browser/browser-process-user-agent') + resetBrowserProcessUserAgentForTests() + const release = vi.fn() + const originalArgv = process.argv + process.argv = originalArgv.includes('--serve') ? [...originalArgv] : [...originalArgv, '--serve'] + mocks.admission.mockReturnValueOnce({ release }) + mocks.afterIdentity.mockImplementationOnce(() => { + throw new Error('Linux serve failure') + }) + mocks.app.whenReady.mockClear() + mocks.app.exit.mockClear() + mocks.showErrorBox.mockClear() + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + mocks.backgroundLaunch.mockReturnValueOnce(false) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect( + runMainProcessPreflight({ focusExistingWindow: vi.fn(), requestDesktopActivation: vi.fn() }) + ).toBe(false) + expect(release).toHaveBeenCalledOnce() + expect(mocks.app.whenReady).not.toHaveBeenCalled() + expect(mocks.showErrorBox).not.toHaveBeenCalled() + expect(mocks.app.exit).toHaveBeenCalledWith(1) + } finally { + error.mockRestore() + platform.mockRestore() + process.argv = originalArgv + } }) diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 0c001b74f44..22d90947ced 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -48,6 +48,7 @@ export const CLI_COMMAND_NAMES = [ 'open-url', 'orchestration', 'pdf', + 'profile', 'project', 'reload', 'repo', diff --git a/src/main/startup/configure-process-profile-state.test.ts b/src/main/startup/configure-process-profile-state.test.ts new file mode 100644 index 00000000000..ed9a6176af5 --- /dev/null +++ b/src/main/startup/configure-process-profile-state.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => ''), + quit: vi.fn(), + exit: vi.fn(), + isPackaged: false, + disableHardwareAcceleration: vi.fn(), + commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn(() => '') } + } +})) + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('pre-ready profile-state recovery boundary', () => { + it('ignores a matching marker when SQLite is missing but an export remains', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const { profileStateJsonExportPath } = + await import('../persistence/profile-state/profile-state-export-path') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + temporaryDirectories.push(userDataPath) + const profileDirectory = join(userDataPath, 'profiles', 'profile-b') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: 'profile-b', profiles: [{ id: 'profile-b' }] }) + ) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync(dataFile, JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } })) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-b') + writeFileSync(profileStateJsonExportPath(dataFile, 7), '{}') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) +}) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d7e2b0cc1a..fd4379354c4 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -420,6 +420,22 @@ describe('configureElectronNetworkCompatibility', () => { return userDataPath } + function createProfileState( + userDataPath: string, + profileId: string, + settings: Record + ): string { + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }), + 'utf-8' + ) + writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify({ settings }), 'utf-8') + return profileDirectory + } + afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) @@ -504,6 +520,40 @@ describe('configureElectronNetworkCompatibility', () => { ).toBe(false) }) + it('scopes a profile marker to the active profile before trusting it', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + createProfileState(userDataPath, 'profile-b', { electronHttp1CompatibilityMode: false }) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-a') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + + it('uses a matching profile marker even when the legacy JSON is stale', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const { writeHttp1CompatibilityMarker } = await import('./http1-compatibility-marker') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + createProfileState(userDataPath, 'profile-b', { electronHttp1CompatibilityMode: false }) + writeHttp1CompatibilityMarker(userDataPath, true, 'profile-b') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(true) + }) + + it('fails closed when a profile database exists without a trusted marker', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-profile-')) + tempDirs.push(userDataPath) + const profileDirectory = createProfileState(userDataPath, 'profile-b', { + electronHttp1CompatibilityMode: true + }) + writeFileSync(join(profileDirectory, 'profile-state.db'), 'sqlite-present', 'utf-8') + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + it('appends Electron disable-http2 before sessions are created', async () => { const { app } = await import('electron') const { configureElectronNetworkCompatibility } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 13dbd70c2c3..95e1df91337 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,11 +1,16 @@ import { app } from 'electron' -import { existsSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' +import { + hasMissingProfileStateDatabaseWithRetainedExport, + readActiveProfileId, + readPersistedHttp1CompatibilityMode +} from './http1-compatibility-profile-state' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' @@ -32,22 +37,6 @@ function parseBooleanEnvFlag(value: string | undefined): boolean | null { return null } -function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { - const dataFile = join(userDataPath, 'orca-data.json') - if (!existsSync(dataFile)) { - return false - } - - try { - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as { - settings?: { electronHttp1CompatibilityMode?: unknown } - } - return parsed.settings?.electronHttp1CompatibilityMode === true - } catch { - return false - } -} - export function shouldDisableHttp2ForElectronNetworking( options: NetworkCompatibilityOptions = {} ): boolean { @@ -56,11 +45,22 @@ export function shouldDisableHttp2ForElectronNetworking( return envValue } const userDataPath = options.userDataPath ?? app.getPath('userData') + const activeProfileId = readActiveProfileId(userDataPath) // Why the marker first: this runs before app.whenReady(), and the settings file is the multi-MB - // orca-data.json the Store parses again moments later. The marker is refreshed whenever settings - // change, so the full read only happens on a profile that has never written one. + // profile document the Store parses again moments later. The marker is refreshed whenever + // settings change; an untrusted SQLite profile fails closed rather than falling back to JSON. + if ( + activeProfileId !== undefined && + activeProfileId !== null && + hasMissingProfileStateDatabaseWithRetainedExport(userDataPath, activeProfileId) + ) { + return false + } return ( - readHttp1CompatibilityMarker(userDataPath) ?? readPersistedHttp1CompatibilityMode(userDataPath) + (activeProfileId === null + ? null + : readHttp1CompatibilityMarker(userDataPath, activeProfileId)) ?? + readPersistedHttp1CompatibilityMode(userDataPath) ) } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 5e4d4cfe428..40d20d73b03 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -104,6 +104,30 @@ describe('startup ordering', () => { expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) + it('fails closed with offline recovery guidance when profile state is unreadable', () => { + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + expect(entrySource).toContain('formatProfileStateStartupFailure') + expect(entrySource).toContain('formatProfileStateStartupFailure(error) ??') + expect(entrySource).toContain('presentProfileStateStartupRecoveryDialog') + expect(entrySource).toContain('!state.isServeMode && !isBackgroundLaunch()') + expect(entrySource).toContain( + "console.warn('[profile-state] Recovery dialog failed; exiting safely:'" + ) + expect(entrySource).toContain('app.exit(1)') + }) + + it('initializes telemetry before publishing profile-state authority selection', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-observers.ts'), + 'utf8' + ) + const telemetryInit = source.indexOf('initTelemetry(store)') + const authoritySelection = source.indexOf("track('profile_state_authority_selected'") + expect(telemetryInit).toBeGreaterThanOrEqual(0) + expect(authoritySelection).toBeGreaterThan(telemetryInit) + }) + it('requires daemon authority before restored-subagent liveness runs', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 3b661dede99..c761ccf0409 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -40,7 +40,7 @@ describe('headless PTY registry hydration ordering', () => { it('hydrates orcad after Store and daemon readiness but before RPC and publication', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') - const store = source.indexOf('const store = new Store(') + const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const daemon = source.indexOf('await startOrcadDaemon()', store) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', daemon) const rpc = source.indexOf('await rpc.start()', handlersAndHydration) @@ -57,7 +57,7 @@ describe('headless PTY registry hydration ordering', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') const cleanup = source.indexOf('registerCleanup(async () => {') const hookStop = source.indexOf('agentHookServer.stop()', cleanup) - const store = source.indexOf('const store = new Store(') + const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const hookStart = source.indexOf('await agentHookServer.start(', store) const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const hookEnv = source.indexOf('buildAgentHookPtyEnv:', daemon) diff --git a/src/main/startup/http1-compatibility-marker.ts b/src/main/startup/http1-compatibility-marker.ts index 9e85a83be66..88de78ab808 100644 --- a/src/main/startup/http1-compatibility-marker.ts +++ b/src/main/startup/http1-compatibility-marker.ts @@ -1,8 +1,12 @@ -import { readFileSync, writeFileSync } from 'node:fs' +import { readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import { bestEffortFsyncDirectorySync } from '../../shared/secure-file' /** * Cached copy of `settings.electronHttp1CompatibilityMode` for pre-`ready` startup. + * Version 2 carries the active profile ID so a profile switch cannot reuse the + * previous profile's network compatibility choice. * * Why a standalone file (not the Store): app.commandLine.appendSwitch('disable-http2') must run * before the first Electron session exists, which is before the settings Store is constructed. @@ -12,11 +16,13 @@ import { join } from 'node:path' */ export const HTTP1_COMPATIBILITY_MARKER_FILE = 'http1-compatibility.json' -const MARKER_SCHEME_VERSION = 1 +const LEGACY_MARKER_SCHEME_VERSION = 1 +const MARKER_SCHEME_VERSION = 2 type Http1CompatibilityMarker = { schemeVersion: number enabled: boolean + profileId?: string } function markerPath(userDataPath: string): string { @@ -24,12 +30,30 @@ function markerPath(userDataPath: string): string { } /** Returns null when the marker is missing or unreadable, so callers fall back to the settings file. */ -export function readHttp1CompatibilityMarker(userDataPath: string): boolean | null { +export function readHttp1CompatibilityMarker( + userDataPath: string, + expectedProfileId?: string +): boolean | null { try { const parsed = JSON.parse( readFileSync(markerPath(userDataPath), 'utf-8') ) as Partial - if (parsed.schemeVersion !== MARKER_SCHEME_VERSION || typeof parsed.enabled !== 'boolean') { + if (typeof parsed.enabled !== 'boolean') { + return null + } + if (parsed.schemeVersion === LEGACY_MARKER_SCHEME_VERSION) { + // A v1 marker predates profile-scoped state. It remains useful for a + // legacy install with no profile index, but cannot be trusted once the + // active profile is known. + return expectedProfileId === undefined ? parsed.enabled : null + } + if ( + parsed.schemeVersion !== MARKER_SCHEME_VERSION || + typeof parsed.profileId !== 'string' || + parsed.profileId.length === 0 || + expectedProfileId === undefined || + parsed.profileId !== expectedProfileId + ) { return null } return parsed.enabled @@ -38,14 +62,28 @@ export function readHttp1CompatibilityMarker(userDataPath: string): boolean | nu } } -export function writeHttp1CompatibilityMarker(userDataPath: string, enabled: boolean): void { - if (readHttp1CompatibilityMarker(userDataPath) === enabled) { +export function writeHttp1CompatibilityMarker( + userDataPath: string, + enabled: boolean, + profileId?: string +): void { + if (readHttp1CompatibilityMarker(userDataPath, profileId) === enabled) { return } - const marker: Http1CompatibilityMarker = { schemeVersion: MARKER_SCHEME_VERSION, enabled } + const marker: Http1CompatibilityMarker = + profileId === undefined + ? { schemeVersion: LEGACY_MARKER_SCHEME_VERSION, enabled } + : { schemeVersion: MARKER_SCHEME_VERSION, enabled, profileId } try { - writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) + const targetPath = markerPath(userDataPath) + writeFileDurableSync(durableWriteTempPath(targetPath), targetPath, JSON.stringify(marker)) } catch { - // Best effort: a missing marker just costs the next launch the settings-file fallback. + // Best effort: a missing marker makes the next launch fail closed or read legacy JSON. } } + +/** Recovery must discard the old authority's cached setting before publishing JSON authority. */ +export function invalidateHttp1CompatibilityMarker(userDataPath: string): void { + rmSync(markerPath(userDataPath), { force: true }) + bestEffortFsyncDirectorySync(userDataPath) +} diff --git a/src/main/startup/http1-compatibility-profile-state.test.ts b/src/main/startup/http1-compatibility-profile-state.test.ts new file mode 100644 index 00000000000..27fe1c30a6a --- /dev/null +++ b/src/main/startup/http1-compatibility-profile-state.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasMissingProfileStateDatabaseWithRetainedExport, + readActiveProfileId, + readPersistedHttp1CompatibilityMode +} from './http1-compatibility-profile-state' +import { profileStateJsonExportPath } from '../persistence/profile-state/profile-state-export-path' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createUserData(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-http1-profile-state-')) + temporaryDirectories.push(directory) + return directory +} + +function writeIndex(userDataPath: string, activeProfileId: string): void { + writeFileSync( + join(userDataPath, 'orca-profile-index.json'), + JSON.stringify({ + schemaVersion: 1, + activeProfileId, + profiles: [{ id: activeProfileId }] + }) + ) +} + +describe('pre-ready profile-state compatibility lookup', () => { + it('uses the legacy install-level JSON before a profile index exists', () => { + const userDataPath = createUserData() + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + + expect(readActiveProfileId(userDataPath)).toBeUndefined() + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(true) + }) + + it('reads only the active profile JSON once the index is valid', () => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + rmSync(join(userDataPath, 'orca-data.json')) + writeFileSync( + join(profileDirectory, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + + expect(readActiveProfileId(userDataPath)).toBe('work') + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(true) + }) + + it.each(['-wal', '-shm', '-journal'])('fails closed when only SQLite %s remains', (suffix) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync(join(profileDirectory, `profile-state.db${suffix}`), 'orphaned') + + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + + writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{ malformed') + writeFileSync( + join(userDataPath, 'orca-data.json'), + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + expect(readActiveProfileId(userDataPath)).toBe(null) + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + }) + + it.each(['json-export', 'database-backup'])( + 'fails closed when SQLite is missing but a retained %s remains', + (artifact) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync( + dataFile, + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync( + artifact === 'json-export' + ? profileStateJsonExportPath(dataFile, 7) + : join( + profileDirectory, + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db' + ), + readFileSync(dataFile) + ) + + expect(readPersistedHttp1CompatibilityMode(userDataPath)).toBe(false) + } + ) + + it.each(['json-export', 'database-backup'])( + 'detects a missing profile database with a retained %s', + (artifact) => { + const userDataPath = createUserData() + writeIndex(userDataPath, 'work') + const profileDirectory = join(userDataPath, 'profiles', 'work') + mkdirSync(profileDirectory, { recursive: true }) + const dataFile = join(profileDirectory, 'orca-data.json') + writeFileSync( + dataFile, + JSON.stringify({ settings: { electronHttp1CompatibilityMode: true } }) + ) + writeFileSync( + artifact === 'json-export' + ? profileStateJsonExportPath(dataFile, 7) + : join( + profileDirectory, + 'profile-state.db.backup.1789999999999-00000000-0000-4000-8000-000000000000.db' + ), + readFileSync(dataFile) + ) + + expect(hasMissingProfileStateDatabaseWithRetainedExport(userDataPath, 'work')).toBe(true) + } + ) +}) diff --git a/src/main/startup/http1-compatibility-profile-state.ts b/src/main/startup/http1-compatibility-profile-state.ts new file mode 100644 index 00000000000..f41569a73c8 --- /dev/null +++ b/src/main/startup/http1-compatibility-profile-state.ts @@ -0,0 +1,122 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { profileStateJsonExportPaths } from '../persistence/profile-state/profile-state-export-path' +import { profileStateDatabaseBackups } from '../persistence/profile-state/profile-state-backup-path' +import { hasProfileStateDatabaseFiles } from '../persistence/profile-state/profile-state-storage-classification' + +// Keep the pre-ready graph small; this stable ID mirrors DEFAULT_LOCAL_ORCA_PROFILE_ID. +const DEFAULT_LOCAL_PROFILE_ID = 'local-default' + +/** `null` means malformed index; `undefined` means a pre-profile legacy install. */ +export function readActiveProfileId(userDataPath: string): string | null | undefined { + const indexPath = join(userDataPath, 'orca-profile-index.json') + const candidates = [indexPath, `${indexPath}.bak`].filter(existsSync) + if (candidates.length === 0) { + return undefined + } + for (const candidate of candidates) { + try { + const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8')) + if (!isRecord(parsed) || typeof parsed.activeProfileId !== 'string') { + continue + } + if ( + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(parsed.activeProfileId) && + Array.isArray(parsed.profiles) && + parsed.profiles.some( + (profile) => isRecord(profile) && profile.id === parsed.activeProfileId + ) + ) { + return parsed.activeProfileId + } + } catch { + // A torn primary can still have a valid recovery index. + } + } + return null +} + +/** Read JSON only when no profile database is present; pre-ready cannot open SQLite safely. */ +export function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { + const activeProfileId = readActiveProfileId(userDataPath) + if (activeProfileId === null) { + // A malformed profile index leaves the active profile unknowable. + return false + } + + const profileDataFile = + activeProfileId === undefined + ? undefined + : join(userDataPath, 'profiles', activeProfileId, 'orca-data.json') + const profileDatabaseFile = + activeProfileId === undefined + ? undefined + : join(userDataPath, 'profiles', activeProfileId, 'profile-state.db') + // SQLite is authoritative once present; a missing marker therefore fails closed. + if (profileDatabaseFile !== undefined && hasProfileStateDatabaseFiles(profileDatabaseFile)) { + return false + } + + if ( + activeProfileId !== undefined && + activeProfileId !== DEFAULT_LOCAL_PROFILE_ID && + (profileDataFile === undefined || !existsSync(profileDataFile)) + ) { + // A known but unseeded non-default profile has default settings. The + // install-level legacy file belongs to another profile and must not leak. + return false + } + const dataFile = profileDataFile ?? join(userDataPath, 'orca-data.json') + // A retained migration export proves SQLite was established. Do not let the + // pre-ready path read a stale JSON mirror while recovery is required. + try { + if ( + profileStateJsonExportPaths(dataFile).length > 0 || + profileStateDatabaseBackups( + profileDatabaseFile ?? join(dirname(dataFile), 'profile-state.db') + ).length > 0 + ) { + return false + } + } catch { + return false + } + if (!existsSync(dataFile)) { + return false + } + + try { + const parsed: unknown = JSON.parse(readFileSync(dataFile, 'utf-8')) + if (!isRecord(parsed) || !isRecord(parsed.settings)) { + return false + } + return parsed.settings.electronHttp1CompatibilityMode === true + } catch { + return false + } +} + +/** Return whether a retained SQLite export makes pre-ready JSON/marker state untrusted. */ +export function hasMissingProfileStateDatabaseWithRetainedExport( + userDataPath: string, + profileId: string +): boolean { + const profileDirectory = join(userDataPath, 'profiles', profileId) + const databaseFile = join(profileDirectory, 'profile-state.db') + if (hasProfileStateDatabaseFiles(databaseFile)) { + return false + } + const dataFile = join(profileDirectory, 'orca-data.json') + try { + return ( + profileStateJsonExportPaths(dataFile).length > 0 || + profileStateDatabaseBackups(databaseFile).length > 0 + ) + } catch { + return true + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index b0ee60f88c2..a4000449250 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -57,6 +57,16 @@ export function initializeMainProcessObservers(): void { } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) + const profileStateStartup = state.profileStateStartup + if (profileStateStartup) { + track('profile_state_authority_selected', { + backend: profileStateStartup.backend, + classification: profileStateStartup.classification, + authority_mode: profileStateStartup.authorityMode, + runtime: profileStateStartup.runtime, + migrated: profileStateStartup.migrated + }) + } // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not // a crash (the app is force-quit, so no report is ever generated). Without this the incidence // number the watchdog exists to produce would sit unread on the user's disk. Must run after diff --git a/src/main/startup/main-process-preflight-failure.ts b/src/main/startup/main-process-preflight-failure.ts new file mode 100644 index 00000000000..0fcd47935cb --- /dev/null +++ b/src/main/startup/main-process-preflight-failure.ts @@ -0,0 +1,41 @@ +import { app, dialog } from 'electron' +import { formatProfileStateStartupFailure } from '../persistence/profile-state/profile-state-startup-failure' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainProcessState as state } from './main-process-state' + +/** Ends a failed preflight without showing a Linux dialog before Electron is ready. */ +export function handleMainProcessPreflightFailure(error: unknown): void { + const message = + formatProfileStateStartupFailure(error) ?? + (error instanceof Error ? error.message : String(error)) + const shouldShowDialog = !state.isServeMode && !isBackgroundLaunch() + state.desktopActivationGate = null + const admission = state.profileStateAdmission + state.profileStateAdmission = undefined + try { + admission?.release() + } catch (releaseError) { + console.warn('[startup] Could not release profile state admission:', releaseError) + } + + const showDialogAndExit = (): void => { + try { + dialog.showErrorBox('Orca could not start', message) + } catch (dialogError) { + console.warn('[startup] Could not show startup failure:', dialogError) + } finally { + app.exit(1) + } + } + if (process.platform === 'linux' && shouldShowDialog) { + try { + void app.whenReady().then(showDialogAndExit, () => app.exit(1)) + } catch { + app.exit(1) + } + } else if (shouldShowDialog) { + showDialogAndExit() + } else { + app.exit(1) + } +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 98d41093c5f..2aa02ece192 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -3,6 +3,7 @@ import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' import { maybeRedirectCliLaunch } from './cli-launch-redirect' +import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' import { configureDevUserDataPath, @@ -67,6 +68,8 @@ import { initDataPath, getCanonicalUserDataPath } from '../persistence' import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { recoverPendingProfileProjectMoves } from '../orca-profiles/profile-project-move-intent' import { initStatsPath } from '../stats/collector' import { initClaudeUsagePath } from '../claude-usage/store' import { initCodexUsagePath } from '../codex-usage/store' @@ -89,6 +92,9 @@ import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-store' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location' +import { handleMainProcessPreflightFailure } from './main-process-preflight-failure' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -97,6 +103,19 @@ export type MainProcessPreflightOptions = { /** Performs all module-scope work that must happen before Electron's ready event. */ export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + try { + return initializeMainProcessPreflight(options) + } catch (error) { + console.error('[startup] Preflight failed:', error) + handleMainProcessPreflightFailure(error) + return false + } +} + +function initializeMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + if (runProfileStateRecoveryPreflight()) { + return false + } // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim. // Direct serve stays in-process so its signal handlers own all children. @@ -186,10 +205,6 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { app.setName(state.devInstanceIdentity.appName) } - // Why: renderer and worker defaults are process-global and must be fixed before any session exists. - initializeBrowserProcessUserAgent( - initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode - ) state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() if (state.startupDiagnosticsEnabled) { logStartupDiagnostic('before-single-instance-lock', { @@ -229,6 +244,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false } + state.profileStateAdmission = acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath()) + // Renderer and worker defaults must be fixed before any session exists. + initializeBrowserProcessUserAgent( + initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode + ) // Why first in this block: the accessor throws until installed and everything below may read a // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so // installing here changes no timing, in particular not the pre-ready Keychain service-name @@ -282,6 +302,13 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b appVersion: app.getVersion() }) initOrcaProfilePaths() + // A crash can leave a cross-profile SQLite move between its two commits. Resolve + // that journal before any Store opens a profile, so no reader observes a half-move. + const profileUserDataPath = getProfileUserDataPath() + recoverPendingProfileProjectMoves( + profileUserDataPath, + getActiveProfileStateLocation(profileUserDataPath)?.profileId + ) // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. initStatsPath() initClaudeUsagePath() diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 073791f6d3c..ad4548db7bd 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -195,12 +195,25 @@ function installWillQuitHandler(): void { browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every + // Why immediately before the final store flush with no await in between: beginSshShutdown() marks every // active SSH lease detached in memory synchronously, and that flush is what persists it. const sshShutdown = beginSshShutdown() killAllPty() const watcherShutdown = shutdownWatchersOnce() - const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + const finalStore = state.store + const storeFlush = (async () => { + if (!finalStore) { + return + } + try { + await finalStore.flushFinalOrThrowAsync({ exportJsonCompatibility: true }) + await finalStore.freezeWritesAsync() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } catch (error) { + console.error('[persistence] Failed to finalize profile state:', error) + } + })() // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. const usageCacheFlush = Promise.all([ diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3c0e01fe09e..b62bdec19f7 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -11,7 +11,11 @@ import { } from '../hang-watchdog/hang-detection-marker' import { browserCertificateTrustController } from '../browser/browser-manager' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' -import { Store, getCanonicalUserDataPath } from '../persistence' +import { getCanonicalUserDataPath } from '../persistence' +import { + createProfileStateStoreForStartup, + desktopProfileStateAuthorityMode +} from '../persistence/profile-state/profile-state-startup-authority' import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' @@ -49,6 +53,7 @@ import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { reportProfileStateWriteFailure } from './profile-state-write-failure' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -134,10 +139,24 @@ export async function initializeReadyFoundation(): Promise { // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(profile.profileDirectory) - const store = new Store({ + const profileStateAuthorityMode = desktopProfileStateAuthorityMode() + const profileState = await createProfileStateStoreForStartup({ dataFile: profile.dataFile, - storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + databaseFile: profile.stateDatabaseFile, + profileId: profile.profile.id, + runtime: 'desktop', + authorityMode: profileStateAuthorityMode, + storageAuthority: state.isServeMode ? 'runtime' : 'desktop', + onPersistenceFailure: reportProfileStateWriteFailure }) + state.profileStateStartup = { + backend: profileState.backend, + classification: profileState.classification, + authorityMode: profileStateAuthorityMode, + runtime: 'desktop', + migrated: profileState.migrated + } + const store = profileState.store state.store = store // Why: create pending readiness before the guard can observe the default session. // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell @@ -197,7 +216,8 @@ export async function initializeReadyFoundation(): Promise { // Why: pre-`ready` startup reads this flag from a marker so it never has to parse orca-data.json. writeHttp1CompatibilityMarker( canonicalUserDataPath, - store.getSettings().electronHttp1CompatibilityMode === true + store.getSettings().electronHttp1CompatibilityMode === true, + profile.profile.id ) // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) @@ -205,7 +225,8 @@ export async function initializeReadyFoundation(): Promise { if ('electronHttp1CompatibilityMode' in updates) { writeHttp1CompatibilityMarker( canonicalUserDataPath, - settings.electronHttp1CompatibilityMode === true + settings.electronHttp1CompatibilityMode === true, + profile.profile.id ) } if ('terminalWindowsWslDistro' in updates) { diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 13011ccfb6b..eb6c29c6555 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -82,6 +82,17 @@ vi.mock('../persistence', () => ({ }, getCanonicalUserDataPath: () => mocks.userDataPath })) +vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ + desktopProfileStateAuthorityMode: () => 'legacy', + createProfileStateStoreForStartup: () => ({ + store: { + getSettings: () => ({}), + onSettingsChanged: () => {}, + getClaudeLivePtySessionIds: () => [], + getSshTargets: () => [] + } + }) +})) // The registry reads the canonical path from this module, not from '../persistence'. vi.mock('../persistence/loading-store/user-data-path', () => ({ getCanonicalUserDataPath: () => mocks.userDataPath diff --git a/src/main/startup/main-process-ready-persistence-cleanup.test.ts b/src/main/startup/main-process-ready-persistence-cleanup.test.ts new file mode 100644 index 00000000000..988c78f1c12 --- /dev/null +++ b/src/main/startup/main-process-ready-persistence-cleanup.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { state, foundation, runtime, i18n, launch } = vi.hoisted(() => ({ + state: { + store: { freezeWritesAsync: vi.fn(async () => {}) }, + profileStateAdmission: initialAdmission(), + mainProcessI18nReady: Promise.resolve() + }, + foundation: vi.fn(async () => {}), + runtime: vi.fn(async () => {}), + i18n: vi.fn(async () => {}), + launch: vi.fn(async () => {}) +})) + +function initialAdmission(): { release(): void } | undefined { + return undefined +} + +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('./main-process-ready-foundation', () => ({ initializeReadyFoundation: foundation })) +vi.mock('./main-process-ready-runtime', () => ({ initializeReadyRuntimeServices: runtime })) +vi.mock('./main-process-i18n-menu', () => ({ initializeMainProcessI18nAndMenu: i18n })) +vi.mock('./main-process-runtime-launch', () => ({ initializeMainProcessRuntimeLaunch: launch })) + +import { initializeMainProcessReady } from './main-process-ready' + +const options = { + openMainWindow: (): never => { + throw new Error('Unexpected window creation in startup cleanup test') + }, + handleMacAppActivation: () => {} +} + +beforeEach(() => { + vi.clearAllMocks() + state.profileStateAdmission = { release: vi.fn() } +}) + +describe('startup persistence lifetime', () => { + it('awaits writer release after a later startup phase fails', async () => { + const failure = new Error('runtime startup failed') + const admission = state.profileStateAdmission + runtime.mockRejectedValueOnce(failure) + let release = () => {} + state.store.freezeWritesAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const ready = initializeMainProcessReady(options) + const rejected = expect(ready).rejects.toBe(failure) + await vi.waitFor(() => expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce()) + expect(admission?.release).not.toHaveBeenCalled() + release() + await rejected + expect(admission?.release).toHaveBeenCalledOnce() + expect(state.profileStateAdmission).toBeUndefined() + expect(launch).not.toHaveBeenCalled() + }) + + it('joins concurrent startup branches before closing their Store', async () => { + const failure = new Error('translations failed') + i18n.mockRejectedValueOnce(failure) + let release = () => {} + launch.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const ready = initializeMainProcessReady(options) + const rejected = expect(ready).rejects.toBe(failure) + await vi.waitFor(() => expect(launch).toHaveBeenCalledOnce()) + expect(state.store.freezeWritesAsync).not.toHaveBeenCalled() + release() + await rejected + expect(state.store.freezeWritesAsync).toHaveBeenCalledOnce() + }) + + it('keeps the original startup failure when cleanup also fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('foundation failed') + foundation.mockRejectedValueOnce(failure) + state.store.freezeWritesAsync.mockRejectedValueOnce(new Error('close failed')) + try { + await expect(initializeMainProcessReady(options)).rejects.toBe(failure) + expect(log).toHaveBeenCalledOnce() + expect(state.profileStateAdmission?.release).not.toHaveBeenCalled() + } finally { + log.mockRestore() + } + }) +}) diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index 835c1f1dd13..a6dcf83a430 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -11,12 +11,33 @@ import { export async function initializeMainProcessReady( options: MainProcessRuntimeLaunchOptions ): Promise { - await initializeReadyFoundation() - await initializeReadyRuntimeServices() - // Why concurrent: window creation reads no translated string and no menu item, and both the - // native menu and the tray only become reachable once the window shows — so serializing them - // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). - const i18nAndMenuReady = initializeMainProcessI18nAndMenu() - state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) - await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)]) + try { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + // Window creation can proceed while translations and the native menu initialize. + const i18nAndMenuReady = initializeMainProcessI18nAndMenu() + state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) + // Join both branches before cleanup can close the profile writer. + const results = await Promise.allSettled([ + i18nAndMenuReady, + initializeMainProcessRuntimeLaunch(options) + ]) + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason + } + } + } catch (error) { + try { + await state.store?.freezeWritesAsync() + state.profileStateAdmission?.release() + state.profileStateAdmission = undefined + } catch (closeError) { + console.error( + '[persistence] Failed to close profile persistence after startup failure:', + closeError + ) + } + throw error + } } diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 49f86136e89..81919505fd6 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -45,6 +45,25 @@ import { } from '../crash-reporting/gpu-crash-fallback-decision' import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' import { createWebContentsTimedFlag } from './web-contents-timed-flag' +import type { ProfileStateStorageClassification } from '../persistence/profile-state/profile-state-storage-classification' +import type { ProfileStateStoreAuthorityMode } from '../persistence/profile-state/profile-state-store-factory' +import type { ProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' + +export type ProfileStateStartupMetadata = { + backend: 'json' | 'sqlite' + classification: ProfileStateStorageClassification + authorityMode: ProfileStateStoreAuthorityMode + runtime: 'desktop' | 'orcad' + migrated: boolean +} + +function createInitialProfileStateStartup(): ProfileStateStartupMetadata | null { + return null +} + +function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | undefined { + return undefined +} /** Mutable composition-root state shared by startup, window, serve, and quit phases. */ export const mainProcessState = { @@ -52,6 +71,8 @@ export const mainProcessState = { /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ isQuitting: false, store: null as Store | null, + profileStateStartup: createInitialProfileStateStartup(), + profileStateAdmission: createInitialProfileStateAdmission(), stats: null as StatsCollector | null, claudeUsage: null as ClaudeUsageStore | null, codexUsage: null as CodexUsageStore | null, diff --git a/src/main/startup/main-window-core-services.test.ts b/src/main/startup/main-window-core-services.test.ts new file mode 100644 index 00000000000..50d0bd49db5 --- /dev/null +++ b/src/main/startup/main-window-core-services.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + attachMainWindowServicesMock, + initTccPromptNoticeMock, + preserveAgentAuthBeforeRestartMock, + registerCoreHandlersMock, + state, + store +} = vi.hoisted(() => { + const store = { + writeLatestProfileStateJsonCompatibilityExportAsync: vi.fn(async () => {}), + writeLatestProfileStateJsonExportAsync: vi.fn(async () => {}), + getSettings: vi.fn(() => ({})) + } + return { + attachMainWindowServicesMock: vi.fn(), + initTccPromptNoticeMock: vi.fn(), + preserveAgentAuthBeforeRestartMock: vi.fn(() => Promise.resolve()), + registerCoreHandlersMock: vi.fn(), + state: { + store, + runtime: {}, + stats: {}, + claudeUsage: {}, + codexUsage: {}, + openCodeUsage: {}, + museUsage: {}, + codexAccounts: {}, + claudeAccounts: {}, + rateLimits: { attach: vi.fn(), start: vi.fn() }, + automations: { setWebContents: vi.fn(), start: vi.fn() }, + keybindings: {}, + codexRuntimeHome: {}, + claudeRuntimeAuth: { prepareForClaudeLaunch: vi.fn() }, + agentAwakeService: null, + crashReports: null, + pluginService: null, + pluginMarketplaceService: null, + pluginMarketplaceInstaller: null, + desktopRelayService: null, + isServeMode: false, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve() + }, + store + } +}) + +vi.mock('../ipc/register-core-handlers/register-core-handlers', () => ({ + registerCoreHandlers: registerCoreHandlersMock +})) +vi.mock('../window/attach-main-window-services', () => ({ + attachMainWindowServices: attachMainWindowServicesMock +})) +vi.mock('../macos-tcc-prompt-notice', () => ({ initTccPromptNotice: initTccPromptNoticeMock })) +vi.mock('../updater', () => ({ resolveUpdateInstallMode: vi.fn(() => 'interactive') })) +vi.mock('./main-process-state', () => ({ mainProcessState: state })) +vi.mock('../agent-auth-restart-preservation', () => ({ + preserveAgentAuthBeforeRestart: preserveAgentAuthBeforeRestartMock +})) +vi.mock('../codex/codex-ai-vault-session-resume', () => ({ + prepareCodexAiVaultSessionResume: vi.fn() +})) +vi.mock('../codex/codex-session-source-home', () => ({ + resolveHostCodexSessionSourceHome: vi.fn() +})) +vi.mock('./main-process-pty-startup', () => ({ + emitPluginWorktreeLifecycle: vi.fn(), + handleCodexHomePtySpawned: vi.fn(), + handlePtyExit: vi.fn() +})) +vi.mock('./codex-launch-preparation', () => ({ prepareCodexRuntimeHomeForLaunch: vi.fn() })) +vi.mock('./codex-session-resume-launch', () => ({ prepareCodexSessionResumeForLaunch: vi.fn() })) +vi.mock('./main-window-lifecycle-flags', () => ({ isRecoveryReloadInFlight: vi.fn() })) + +const { attachMainWindowCoreServices } = await import('./main-window-core-services') + +describe('main window profile-state update preparation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('publishes both recovery forms with one profile checkpoint before an update quit', async () => { + const window = { webContents: { id: 17 } } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: mocked BrowserWindow only needs webContents for this composition-root wiring test. + attachMainWindowCoreServices(window as never, { + markExpectedRendererReload: vi.fn(), + recordRendererReload: vi.fn() + }) + + const options = attachMainWindowServicesMock.mock.calls[0]?.[5] + if ( + typeof options !== 'object' || + options === null || + !('onBeforeUpdateQuit' in options) || + typeof options.onBeforeUpdateQuit !== 'function' + ) { + throw new Error('Expected update quit cleanup to be wired') + } + + await options.onBeforeUpdateQuit() + + expect(preserveAgentAuthBeforeRestartMock).toHaveBeenCalledWith({ + codexRuntimeHome: state.codexRuntimeHome, + claudeRuntimeAuth: state.claudeRuntimeAuth, + store + }) + expect(store.writeLatestProfileStateJsonExportAsync).not.toHaveBeenCalled() + expect(store.writeLatestProfileStateJsonCompatibilityExportAsync).toHaveBeenCalledOnce() + expect(options).toHaveProperty('onBeforeUpdateQuitFailure', 'abort') + }) +}) diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f9ef2118df..c292a4455c7 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -126,8 +126,11 @@ export function attachMainWindowCoreServices( isRecoveryReloadInFlight, onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + onBeforeUpdateQuit: async () => { + await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) + await store.writeLatestProfileStateJsonCompatibilityExportAsync() + }, + onBeforeUpdateQuitFailure: 'abort', updateInstallMode: resolveUpdateInstallMode(state.isServeMode), onWorktreeLifecycle: emitPluginWorktreeLifecycle } diff --git a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts index 2a8e008c0b3..292645f874f 100644 --- a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts +++ b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts @@ -44,6 +44,8 @@ describe('pre-gone crash sampling startup wiring', () => { expect(readySource).toContain( "import { initializeReadyRuntimeServices } from './main-process-ready-runtime'" ) - expect(readySource).toContain('\n await initializeReadyRuntimeServices()') + expect(readySource).toContain( + 'try {\n await initializeReadyFoundation()\n await initializeReadyRuntimeServices()' + ) }) }) diff --git a/src/main/startup/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts new file mode 100644 index 00000000000..4d120a4170e --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -0,0 +1,280 @@ +import type * as NodeFs from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX +} from '../../shared/profile-state-recovery-command' +import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +import { profileStateJsonExportPath } from '../persistence/profile-state/profile-state-export-path' +import { + createProfileStateDatabaseBackupId, + profileStateDatabaseBackupPath +} from '../persistence/profile-state/profile-state-backup-path' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from '../persistence/profile-state/profile-state-database' +import { + importProfileStateJson, + readProfileStateSnapshot +} from '../persistence/profile-state/profile-state-documents' +import { writeProfileStateDatabaseSnapshotAsync } from '../persistence/profile-state/profile-state-database-snapshot' +import * as marker from './http1-compatibility-marker' +import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight' + +const mocks = vi.hoisted(() => ({ + setPath: vi.fn(), + requestSingleInstanceLock: vi.fn(), + on: vi.fn(), + exit: vi.fn(), + background: vi.fn(), + output: vi.fn() +})) +vi.mock('electron', () => ({ app: mocks })) +vi.mock('../window/foreground-activation-policy', () => ({ + applyBackgroundActivationPolicy: mocks.background +})) +vi.mock('node:fs', async (original) => { + const fs = await original() + return { + ...fs, + writeFileSync: (...args: Parameters) => { + if (args[0] === 1) { + mocks.output(args[1]) + return + } + return fs.writeFileSync(...args) + } + } +}) + +const roots: string[] = [] +beforeEach(() => { + vi.clearAllMocks() + mocks.requestSingleInstanceLock.mockReturnValue(true) + vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/inherited/root') + vi.stubEnv('ORCA_BYPASS_SINGLE_INSTANCE_LOCK', '1') + vi.stubEnv('ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK', '0') +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-recovery-bridge-'))) + roots.push(root) + const profileId = 'bridge-profile' + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const exportFile = profileStateJsonExportPath(dataFile, 1) + const restored = { + settings: { electronHttp1CompatibilityMode: true }, + unknown: { sealed: 'unchanged', missing: null } + } + writeFileSync(dataFile, JSON.stringify({ old: true })) + writeFileSync(databaseFile, 'broken database') + writeFileSync(exportFile, JSON.stringify(restored)) + const argv = [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: root, selector: { kind: 'json', revision: 1 } }) + ] + return { root, profileId, dataFile, databaseFile, exportFile, restored, argv } +} + +function response(): unknown { + const output: unknown = mocks.output.mock.calls[0]?.[0] + expect(typeof output).toBe('string') + if (typeof output !== 'string') { + throw new Error('Missing response') + } + return JSON.parse(output.slice(PROFILE_STATE_RECOVERY_RESULT_PREFIX.length)) +} + +describe('Electron recovery preflight', () => { + it('runs the recovery branch before CLI redirect or ordinary startup admission', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('export function runMainProcessPreflight(') + const recovery = source.indexOf('if (runProfileStateRecoveryPreflight())', start) + const redirect = source.indexOf('const cliLaunchRedirect = maybeRedirectCliLaunch(', start) + const admission = source.indexOf('acquireProfileStateRuntimeAdmission(', start) + expect(start).toBeGreaterThanOrEqual(0) + expect(recovery).toBeGreaterThan(start) + expect(redirect).toBeGreaterThan(recovery) + expect(admission).toBeGreaterThan(redirect) + expect(source.slice(recovery, redirect)).toContain('return false') + }) + + it('leaves ordinary startup untouched', () => { + expect(runProfileStateRecoveryPreflight(['Orca', '--serve'])).toBe(false) + expect(mocks.background).not.toHaveBeenCalled() + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.exit).not.toHaveBeenCalled() + }) + + it.each(['json', 'current-json'] as const)( + 'restores %s under both locks despite ordinary singleton bypasses', + (kind) => { + const item = fixture() + if (kind === 'current-json') { + writeFileSync(item.dataFile, JSON.stringify(item.restored)) + item.argv[3] = JSON.stringify({ userDataPath: item.root, selector: { kind } }) + } + mocks.requestSingleInstanceLock.mockImplementation(() => { + expect(mocks.setPath).toHaveBeenCalledWith('userData', item.root) + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + return true + }) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(process.env.ORCA_USER_DATA_PATH).toBe(item.root) + expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(mocks.background).toHaveBeenCalledOnce() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + expect(response()).toMatchObject({ + ok: true, + result: { + storage: 'json', + revision: kind === 'json' ? 1 : null, + restoredPath: item.dataFile + } + }) + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(existsSync(item.databaseFile)).toBe(false) + expect(existsSync(item.exportFile)).toBe(false) + expect(mocks.exit).toHaveBeenCalledWith(0) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + } + ) + + it('refuses an old native singleton owner without modifying authority or exports', () => { + const item = fixture() + mocks.requestSingleInstanceLock.mockReturnValue(false) + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(response()).toMatchObject({ + ok: false, + code: 'runtime_error', + message: expect.stringContaining('Stop Orca') + }) + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual({ old: true }) + expect(existsSync(item.exportFile)).toBe(true) + expect(mocks.exit).toHaveBeenCalledWith(1) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('exits quietly when the CLI closes its response pipe after a successful restore', () => { + const item = fixture() + mocks.output.mockImplementationOnce(() => { + throw new Error('EPIPE') + }) + expect(() => runProfileStateRecoveryPreflight(item.argv)).not.toThrow() + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(mocks.exit).toHaveBeenCalledWith(1) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() + }) + + it('restores a real SQLite backup and retains root exclusion through marker publication', async () => { + const item = fixture() + rmSync(item.databaseFile) + const source = openProfileStateDatabase(item.databaseFile, item.profileId) + const backupId = createProfileStateDatabaseBackupId() + const backupFile = profileStateDatabaseBackupPath(item.databaseFile, backupId) + try { + importProfileStateJson(source.db, JSON.stringify(item.restored)) + await writeProfileStateDatabaseSnapshotAsync(source.db, backupFile) + importProfileStateJson(source.db, JSON.stringify({ newer: true }), { expectedRevision: 1 }) + } finally { + source.db.close() + } + const markerWrite = marker.writeHttp1CompatibilityMarker + const write = vi + .spyOn(marker, 'writeHttp1CompatibilityMarker') + .mockImplementation((...args) => { + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + expect(mocks.requestSingleInstanceLock).toHaveBeenCalledOnce() + markerWrite(...args) + }) + const argv = [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: item.root, selector: { kind: 'sqlite', backupId } }) + ] + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + expect(response()).toMatchObject({ + ok: true, + result: { storage: 'sqlite', backupId, revision: 1 } + }) + expect(write).toHaveBeenCalledWith(item.root, true, item.profileId) + expect(existsSync(item.dataFile)).toBe(false) + expect(existsSync(backupFile)).toBe(true) + const restored = openProfileStateDatabaseReadOnly(item.databaseFile, item.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(restored.db).json)).toEqual(item.restored) + } finally { + restored.db.close() + } + expect(mocks.exit).toHaveBeenCalledWith(0) + }) + + it('refuses a participating Node runtime before asking for the Electron lock', () => { + const item = fixture() + const runtime = acquireProfileStateRuntimeAdmission(item.root) + try { + expect(runProfileStateRecoveryPreflight(item.argv)).toBe(true) + expect(response()).toMatchObject({ ok: false, code: 'runtime_error' }) + expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(existsSync(item.exportFile)).toBe(true) + } finally { + runtime.release() + } + }) + + it.each([ + ['Orca', PROFILE_STATE_RECOVERY_FLAG, '{}'], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG, '{}'], + [ + 'Orca', + '--serve', + PROFILE_STATE_RECOVERY_FLAG, + JSON.stringify({ userDataPath: 'relative', selector: { kind: 'json', revision: 1 } }) + ], + ['Orca', '--serve', PROFILE_STATE_RECOVERY_FLAG, '{}', PROFILE_STATE_RECOVERY_FLAG, '{}'] + ])('fails closed for malformed launch %j', (...argv) => { + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + expect(response()).toMatchObject({ ok: false }) + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(mocks.exit).toHaveBeenCalledWith(1) + }) +}) diff --git a/src/main/startup/profile-state-recovery-preflight.ts b/src/main/startup/profile-state-recovery-preflight.ts new file mode 100644 index 00000000000..68ccbe68a13 --- /dev/null +++ b/src/main/startup/profile-state-recovery-preflight.ts @@ -0,0 +1,76 @@ +import { writeFileSync, realpathSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { app } from 'electron' +import { + PROFILE_STATE_RECOVERY_FLAG, + PROFILE_STATE_RECOVERY_RESULT_PREFIX, + ProfileStateRecoveryCommandError, + isProfileStateRecoveryCommandError, + profileStateRecoveryRequestSchema, + type ProfileStateRecoveryResponse +} from '../../shared/profile-state-recovery-command' +import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access' +import { rollbackProfileState } from '../persistence/profile-state/profile-state-recovery-command' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { acquireSingleInstanceLock } from './single-instance-lock' + +/** The process owning both locks performs recovery before Electron can initialize a runtime. */ +export function runProfileStateRecoveryPreflight(argv: readonly string[] = process.argv): boolean { + const index = argv.indexOf(PROFILE_STATE_RECOVERY_FLAG) + if (index === -1) { + return false + } + process.env.ORCA_BACKGROUND_LAUNCH = '1' + applyBackgroundActivationPolicy() + let response: ProfileStateRecoveryResponse + try { + if (!argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Invalid profile-state recovery launch.' + ) + } + const raw: unknown = JSON.parse(argv[index + 1] ?? '') + const parsed = profileStateRecoveryRequestSchema.safeParse(raw) + if (!parsed.success || !isAbsolute(parsed.data.userDataPath)) { + throw new ProfileStateRecoveryCommandError( + 'invalid_argument', + 'Invalid profile-state recovery request.' + ) + } + const userDataPath = realpathSync(parsed.data.userDataPath) + app.setPath('userData', userDataPath) + process.env.ORCA_USER_DATA_PATH = userDataPath + const maintenance = acquireProfileStateMaintenance(userDataPath) + try { + // Force Electron's lock even when ordinary dev or diagnostic launches would bypass it. + if (!acquireSingleInstanceLock(app, () => {})) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'Stop Orca before profile-state rollback so no process can write the SQLite database.' + ) + } + response = { + ok: true, + result: rollbackProfileState(userDataPath, parsed.data.selector, maintenance) + } + } finally { + maintenance.release() + } + } catch (error) { + response = { + ok: false, + code: isProfileStateRecoveryCommandError(error) ? error.code : 'runtime_error', + message: error instanceof Error ? error.message : String(error) + } + } + let exitCode = response.ok ? 0 : 1 + try { + writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`) + } catch { + // The CLI may have exited while recovery held the locks; never open an Electron error dialog. + exitCode = 1 + } + app.exit(exitCode) + return true +} diff --git a/src/main/startup/profile-state-write-failure.test.ts b/src/main/startup/profile-state-write-failure.test.ts new file mode 100644 index 00000000000..b434b3ca573 --- /dev/null +++ b/src/main/startup/profile-state-write-failure.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { reportProfileStateWriteFailure } from './profile-state-write-failure' + +const fixture = vi.hoisted(() => ({ + show: vi.fn(), + background: false, + state: { isServeMode: false } +})) +vi.mock('electron', () => ({ dialog: { showMessageBox: fixture.show } })) +vi.mock('../window/foreground-activation-policy', () => ({ + isBackgroundLaunch: () => fixture.background +})) +vi.mock('./main-process-state', () => ({ mainProcessState: fixture.state })) + +beforeEach(() => { + fixture.background = false + fixture.state.isServeMode = false + fixture.show.mockResolvedValue({ response: 0 }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +it('tells desktop users saving stopped without silently restarting the writer', () => { + reportProfileStateWriteFailure(new Error('worker exited')) + expect(fixture.show).toHaveBeenCalledExactlyOnceWith({ + type: 'error', + title: 'Saving stopped', + message: 'Orca has stopped saving this profile.', + detail: 'Recent changes may not be saved. Restart Orca before continuing.', + buttons: ['OK'] + }) +}) + +it.each(['background', 'serve'])('keeps %s runs free of native dialogs', (mode) => { + fixture.background = mode === 'background' + fixture.state.isServeMode = mode === 'serve' + reportProfileStateWriteFailure(new Error('worker exited')) + expect(fixture.show).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('stopped saving'), + expect.any(Error) + ) +}) + +it('handles a failed dialog without an unhandled rejection', async () => { + fixture.show.mockRejectedValue(new Error('window system unavailable')) + reportProfileStateWriteFailure(new Error('worker exited')) + await vi.waitFor(() => expect(console.warn).toHaveBeenCalled()) +}) diff --git a/src/main/startup/profile-state-write-failure.ts b/src/main/startup/profile-state-write-failure.ts new file mode 100644 index 00000000000..81668513ba6 --- /dev/null +++ b/src/main/startup/profile-state-write-failure.ts @@ -0,0 +1,18 @@ +import { dialog } from 'electron' +import { isBackgroundLaunch } from '../window/foreground-activation-policy' +import { mainProcessState } from './main-process-state' + +/** Report a retired writer without treating unacknowledged state as safe to overwrite. */ +export function reportProfileStateWriteFailure(error: Error): void { + const message = 'Orca has stopped saving this profile.' + const detail = 'Recent changes may not be saved. Restart Orca before continuing.' + console.error(`[persistence] ${message} ${detail}`, error) + if (mainProcessState.isServeMode || isBackgroundLaunch()) { + return + } + void dialog + .showMessageBox({ type: 'error', title: 'Saving stopped', message, detail, buttons: ['OK'] }) + .catch((dialogError) => + console.warn('[persistence] Could not show saving failure:', dialogError) + ) +} diff --git a/src/main/startup/startup-diagnostics.test.ts b/src/main/startup/startup-diagnostics.test.ts index dcf7a4237b3..4ba75269d6c 100644 --- a/src/main/startup/startup-diagnostics.test.ts +++ b/src/main/startup/startup-diagnostics.test.ts @@ -1,11 +1,24 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { isStartupDiagnosticsEnabled, logStartupDiagnostic, + logStartupMilestone, STARTUP_DIAGNOSTICS_ENV, writeStartupDiagnosticLine } from './startup-diagnostics' +afterEach(() => vi.unstubAllEnvs()) + +it('does not compute lazy milestone details unless diagnostics are enabled', () => { + vi.stubEnv(STARTUP_DIAGNOSTICS_ENV, '0') + const details = vi.fn(() => ({ bytes: 123 })) + logStartupMilestone('persistence-load-done', details) + expect(details).not.toHaveBeenCalled() + vi.stubEnv(STARTUP_DIAGNOSTICS_ENV, '1') + logStartupMilestone('persistence-load-done', details) + expect(details).toHaveBeenCalledOnce() +}) + describe('writeStartupDiagnosticLine', () => { it('writes directly to stderr fd 2 with a newline', () => { const write = vi.fn() diff --git a/src/main/startup/startup-diagnostics.ts b/src/main/startup/startup-diagnostics.ts index d5cd7718d88..dff09612711 100644 --- a/src/main/startup/startup-diagnostics.ts +++ b/src/main/startup/startup-diagnostics.ts @@ -32,8 +32,12 @@ export function logStartupDiagnostic( // Why: startup benchmarking needs in-process timestamps — harness-side stderr // arrival times include pipe buffering jitter. `t` is ms since process start. -export function logStartupMilestone(event: string, details: Record = {}): void { +export function logStartupMilestone( + event: string, + details: Record | (() => Record) = {} +): void { if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + const t = Math.round(performance.now()) + logStartupDiagnostic(event, { t, ...(typeof details === 'function' ? details() : details) }) } } diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 0080db58991..d5ba376e02e 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -183,6 +183,98 @@ describe('updater', () => { ) }) + it('aborts native install when required pre-quit cleanup fails', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('profile state export failed')) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not restart to install the update') + }) + ) + }) + + it('keeps optional pre-quit cleanup fail-and-continue behavior by default', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('optional cleanup failed')) + const mainWindow = { webContents: { send: vi.fn() } } + const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { onBeforeQuit }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + }) + + it('allows a required profile export to finish beyond the optional cleanup budget', async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 5_000))) + const mainWindow = { webContents: { send: vi.fn() } } + const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: updater only reads the mocked webContents.send in this lifecycle test. + setupAutoUpdater(mainWindow as never, { onBeforeQuit, onBeforeQuitFailure: 'abort' }) + quitAndInstall() + await vi.advanceTimersByTimeAsync(2_600) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(2_500) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + }) + + it('aborts native install when required pre-quit cleanup times out', async () => { + vi.useFakeTimers() + + const onBeforeQuit = vi.fn(() => new Promise(() => {})) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await loadUpdaterModule() + + setupAutoUpdater(mainWindow as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + quitAndInstall() + + await vi.advanceTimersByTimeAsync(100) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(90_000) + + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(sendMock).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not restart to install the update') + }) + ) + }) + it('ignores duplicate quitAndInstall requests while the shared delay is pending', async () => { vi.useFakeTimers() diff --git a/src/main/updater.ts b/src/main/updater.ts index cc9f0fc2c98..95c457b82c3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -12,12 +12,12 @@ import type { import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' import type { ReleaseBuildListOptions } from './updater-release-build-cache' import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' -import type { UpdateInstallMode } from './updater/updater-state' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from './updater/updater-state' // Keep one service instance so all public API calls share updater state and event listeners. const updater = new UpdaterSetup() -export type { UpdateInstallMode, UpdaterSetupOptions } +export type { PreQuitCleanupFailureMode, UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { return updater.resolveUpdateInstallMode(isServeMode) diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index 175362dad05..2da273e152e 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -10,7 +10,7 @@ import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog import { resetMacInstallState } from '../updater-mac-install' import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../shared/update-status-types' import { compareVersions } from '../updater-fallback' -import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS, REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' import { UpdaterCheckState } from './updater-check-state' export abstract class UpdaterInstallSupport extends UpdaterCheckState { @@ -137,6 +137,10 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { return } + const timeoutMs = + this.onBeforeQuitFailure === 'abort' + ? REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS + : PRE_QUIT_CLEANUP_TIMEOUT_MS let timeout: ReturnType | null = null const cleanup = Promise.resolve() .then(() => this.onBeforeQuitCleanup?.()) @@ -146,29 +150,42 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { { errorType: error instanceof Error ? error.name : typeof error }, { level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' + message: + this.onBeforeQuitFailure === 'abort' + ? 'Pre-quit cleanup failed; aborting update install' + : 'Pre-quit cleanup failed; continuing update install' } ) + if (this.onBeforeQuitFailure === 'abort') { + throw error + } }) const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + try { + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs }, + { + level: 'warn', + message: + this.onBeforeQuitFailure === 'abort' + ? `Pre-quit cleanup exceeded ${timeoutMs}ms; aborting update install` + : `Pre-quit cleanup exceeded ${timeoutMs}ms; continuing update install` + } + ) + if (this.onBeforeQuitFailure === 'abort') { + throw new Error(`Pre-quit cleanup exceeded ${timeoutMs}ms before update install`) } - ) - return - } - - if (timeout) { - clearTimeout(timeout) + } + } finally { + if (timeout) { + clearTimeout(timeout) + } } } diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index 8ebbc49b169..2b43964d5c0 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -20,7 +20,7 @@ import { getServeUpdateHandoffFailure } from '../serve-update-handoff' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' import { UpdaterDownloadInstall } from './updater-download-install' -import type { UpdateInstallMode } from './updater-state' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from './updater-state' export type UpdaterSetupOptions = { getLastUpdateCheckAt?: () => number | null @@ -32,6 +32,7 @@ export type UpdaterSetupOptions = { setDismissedUpdateNudgeId?: (id: string | null) => void getReleaseChannelOverride?: () => ReleaseChannel | null installMode?: UpdateInstallMode + onBeforeQuitFailure?: PreQuitCleanupFailureMode } /** Initializes electron-updater and attaches lifecycle/event bridges. */ @@ -113,6 +114,7 @@ export class UpdaterSetup extends UpdaterDownloadInstall { setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { this.mainWindowRef = mainWindow this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.onBeforeQuitFailure = opts?.onBeforeQuitFailure ?? 'continue' this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index d15ce42520c..32177e3183c 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -13,6 +13,8 @@ export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 export const QUIT_AND_INSTALL_DELAY_MS = 100 export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +// Required profile exports may each wait for a bounded writer request. +export const REQUIRED_PRE_QUIT_CLEANUP_TIMEOUT_MS = 90_000 export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 @@ -24,6 +26,7 @@ export type UpdateInstallMode = | 'interactive' | 'supervised-headless-serve' | 'unsupported-headless-serve' +export type PreQuitCleanupFailureMode = 'continue' | 'abort' // Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. export class ReleaseFeedPreflightError extends Error { @@ -42,6 +45,7 @@ export abstract class UpdaterState { protected currentStatus: UpdateStatus = { state: 'idle' } protected userInitiatedCheck = false protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected onBeforeQuitFailure: PreQuitCleanupFailureMode = 'continue' protected autoUpdaterInitialized = false // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. protected includePrereleaseActive = false diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index ae83f5b7712..9e5c5493f09 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' const { onMock, @@ -154,7 +155,7 @@ type MainWindowStub = { type RuntimeStub = { attachWindow: MockFn - setNotifier: MockFn + setNotifier: ReturnType void>> markRendererReloading: MockFn markRendererReloadCancelled: MockFn markGraphReloadFailed: MockFn @@ -195,7 +196,7 @@ function createStore(): Store & { flushPendingAsync: MockFn } { function createRuntime(): RuntimeStub { return { attachWindow: vi.fn(), - setNotifier: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), markRendererReloading: vi.fn(), markRendererReloadCancelled: vi.fn(), markGraphReloadFailed: vi.fn(), @@ -290,8 +291,7 @@ describe('attachMainWindowServices', () => { await providerStartup.promise await Promise.resolve() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledOnce() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store) + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledExactlyOnceWith(store) }) it('passes injected update quit cleanup to the auto-updater', async () => { @@ -305,17 +305,23 @@ describe('attachMainWindowServices', () => { createRuntime() as never, undefined, undefined, - { onBeforeUpdateQuit, updateInstallMode: 'supervised-headless-serve' } + { + onBeforeUpdateQuit, + onBeforeUpdateQuitFailure: 'abort', + updateInstallMode: 'supervised-headless-serve' + } ) // Deferred to first paint — must not be configured at attach time. expect(setupAutoUpdaterMock).not.toHaveBeenCalled() await fireReadyToShow(mainWindow) expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1) - expect(setupAutoUpdaterMock).toHaveBeenCalledWith( - mainWindow, - expect.objectContaining({ installMode: 'supervised-headless-serve' }) - ) + const [updaterWindow, updaterOptions] = setupAutoUpdaterMock.mock.calls[0] + expect(updaterWindow).toBe(mainWindow) + expect(updaterOptions).toMatchObject({ + installMode: 'supervised-headless-serve', + onBeforeQuitFailure: 'abort' + }) await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit() expect(onBeforeUpdateQuit).toHaveBeenCalledTimes(1) @@ -758,14 +764,9 @@ describe('attachMainWindowServices', () => { attachMainWindowServices(mainWindow as never, createStore(), runtime as never) expect(runtime.setNotifier).toHaveBeenCalledTimes(1) - const notifier = runtime.setNotifier.mock.calls[0][0] as { - worktreesChanged: (repoId: string) => void - reposChanged: () => void - activateWorktree: ( - repoId: string, - worktreeId: string, - setup?: { runnerScriptPath: string; envVars: Record } - ) => void + const notifier = runtime.setNotifier.mock.calls[0][0] + if (!notifier) { + throw new Error('Missing runtime notifier') } notifier.worktreesChanged('repo-1') diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 78496abc1fc..5a7aab6b145 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -24,7 +24,7 @@ import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' -import type { UpdateInstallMode } from '../updater' +import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -64,6 +64,7 @@ export function attachMainWindowServices( onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void onPtyExit?: (id: string, exitSequence: number) => void onBeforeUpdateQuit?: () => void | Promise + onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } diff --git a/src/main/window/history-gc-profile-worktree-ids.test.ts b/src/main/window/history-gc-profile-worktree-ids.test.ts index b3f0960eab9..3c01d0f2ea2 100644 --- a/src/main/window/history-gc-profile-worktree-ids.test.ts +++ b/src/main/window/history-gc-profile-worktree-ids.test.ts @@ -3,11 +3,14 @@ * segment, while the Store the GC consults holds one profile's ids. Without * these, switching profiles makes every other profile's history look orphaned. */ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents' +import { openProfileStateDatabase } from '../persistence/profile-state/profile-state-database' +import { getOrcaProfileStateDatabaseFile } from '../orca-profiles/profile-storage-paths' import { getOtherProfileWorktreeIdsForHistoryGc } from './history-gc-profile-worktree-ids' const roots: string[] = [] @@ -103,6 +106,153 @@ describe('getOtherProfileWorktreeIdsForHistoryGc', () => { expect(getOtherProfileWorktreeIdsForHistoryGc(root).unreadableProfiles).toBe(1) }) + it('prefers the profile database over a stale JSON export', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { + id: 'other', + state: { worktreeMeta: { 'repo::/json': {} }, folderWorkspaces: [] } + } + ]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + try { + importProfileStateJson( + database.db, + JSON.stringify({ + worktreeMeta: { 'repo::/database': {} }, + folderWorkspaces: [{ id: 'folder-database' }] + }) + ) + } finally { + database.db.close() + } + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/database', folderWorkspaceKey('folder-database')]) + }) + }) + + it('falls back to JSON without creating a database', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + const databaseFile = getOrcaProfileStateDatabaseFile('other', root) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/json']) + }) + expect(existsSync(databaseFile)).toBe(false) + }) + + it.each([true, false])( + 'refuses history pruning when SQLite is missing but a retained export exists (JSON: %s)', + (hasJson) => { + const state = { worktreeMeta: { 'repo::/stale-json': {} } } + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', ...(hasJson ? { state } : {}) } + ]) + const dataFile = join(root, 'profiles', 'other', 'orca-data.json') + const exportPath = `${dataFile}.sqlite-export.1.json` + const exportJson = JSON.stringify({ worktreeMeta: { 'repo::/export': {} } }) + writeFileSync(exportPath, exportJson) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + expect(existsSync(getOrcaProfileStateDatabaseFile('other', root))).toBe(false) + expect(existsSync(dataFile)).toBe(hasJson) + if (hasJson) { + expect(readFileSync(dataFile, 'utf8')).toBe(JSON.stringify(state)) + } + expect(readFileSync(exportPath, 'utf8')).toBe(exportJson) + } + ) + + it('reads SQLite-only profiles with retained exports without blocking history pruning', () => { + const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'other' }]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + try { + importProfileStateJson( + database.db, + JSON.stringify({ worktreeMeta: { 'repo::/database': {} } }) + ) + } finally { + database.db.close() + } + const dataFile = join(root, 'profiles', 'other', 'orca-data.json') + writeFileSync( + `${dataFile}.sqlite-export.1.json`, + JSON.stringify({ worktreeMeta: { 'repo::/stale-export': {} } }) + ) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 0, + ids: new Set(['repo::/database']) + }) + expect(existsSync(dataFile)).toBe(false) + }) + + it('does not fall back to JSON when an existing database is corrupt', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + writeFileSync(getOrcaProfileStateDatabaseFile('other', root), 'not a sqlite database') + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + }) + + it.each(['-wal', '-shm', '-journal'])( + 'does not fall back to JSON when only %s remains', + (suffix) => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + writeFileSync( + `${getOrcaProfileStateDatabaseFile('other', root)}${suffix}`, + 'orphaned evidence' + ) + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + } + ) + + it('refuses history pruning for a future profile database schema', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: {} }, + { id: 'other', state: { worktreeMeta: { 'repo::/json': {} } } } + ]) + const database = openProfileStateDatabase( + getOrcaProfileStateDatabaseFile('other', root), + 'other' + ) + database.db.pragma('user_version = 99') + database.db.close() + + expect(getOtherProfileWorktreeIdsForHistoryGc(root)).toEqual({ + unreadableProfiles: 1, + ids: new Set() + }) + }) + // A single-profile install must not pay for this, and no index at all is the // pre-profiles layout rather than an error. it('is empty and complete when there is no profile index', () => { diff --git a/src/main/window/history-gc-profile-worktree-ids.ts b/src/main/window/history-gc-profile-worktree-ids.ts index 6f82b96fc37..07a115b0ad8 100644 --- a/src/main/window/history-gc-profile-worktree-ids.ts +++ b/src/main/window/history-gc-profile-worktree-ids.ts @@ -1,10 +1,13 @@ -import { readFileSync } from 'node:fs' +import { lstatSync, readFileSync } from 'node:fs' import { folderWorkspaceKey } from '../../shared/workspace-scope' import { getOrcaProfileDataFile, + getOrcaProfileStateDatabaseFile, getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/profile-index-store' +import { readProfileStateDomains } from '../persistence/profile-state/profile-state-domain-reader' +import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' /** * Worktree ids owned by Orca profiles OTHER than the running one. @@ -16,7 +19,7 @@ import { getOrcaProfileIndexPath, readProfileIndex } from '../orca-profiles/prof * switch every other profile's history looks orphaned, and the GC deletes shell * history those profiles are still using. * - * Reading their data files directly is deliberate: a Store per profile would + * Reading their persisted state directly is deliberate: a Store per profile would * run migrations and normalization against state another profile owns. Only the * two id-bearing collections are read, and any unreadable profile is skipped — * a profile whose ids cannot be established must widen the live set's @@ -37,7 +40,7 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile if (profile.id === index.activeProfileId) { continue } - const collected = readProfileWorktreeIds(getOrcaProfileDataFile(profile.id, userDataPath)) + const collected = readProfileWorktreeIds(profile.id, userDataPath) if (!collected) { unreadableProfiles += 1 continue @@ -49,7 +52,99 @@ export function getOtherProfileWorktreeIdsForHistoryGc(userDataPath = getProfile return { ids, unreadableProfiles } } -function readProfileWorktreeIds(dataFile: string): Set | null { +function readProfileWorktreeIds(profileId: string, userDataPath: string): Set | null { + const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) + // A present database is authoritative. In particular, do not fall back to a + // stale JSON export after corruption or a future schema, because that could + // make live history look orphaned and delete it. + const databasePresence = profileStateDatabasePresence(databaseFile) + if (databasePresence === 'present') { + return readProfileWorktreeIdsFromDatabase(databaseFile, profileId) + } + if (databasePresence === 'unreadable') { + return null + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + try { + assertNoRetainedProfileStateExports({ dataFile, databaseFile, profileId }) + } catch { + return null + } + return readProfileWorktreeIdsFromJson(dataFile) +} + +function profileStateDatabasePresence(path: string): 'absent' | 'present' | 'unreadable' { + let mainDatabasePresent = false + try { + lstatSync(path) + mainDatabasePresent = true + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + mainDatabasePresent = false + } else { + return 'unreadable' + } + } + if (mainDatabasePresent) { + return 'present' + } + for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) { + try { + lstatSync(sidecar) + return 'unreadable' + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') { + return 'unreadable' + } + } + } + return 'absent' +} + +function readProfileWorktreeIdsFromDatabase( + databaseFile: string, + profileId: string +): Set | null { + const domains = readProfileStateDomains(databaseFile, profileId, [ + 'worktreeMeta', + 'folderWorkspaces' + ]) + if (domains.kind === 'unreadable') { + return null + } + + const ids = new Set() + const worktreeMeta = domains.values.get('worktreeMeta') + if (worktreeMeta !== undefined) { + if (worktreeMeta === null) { + // Explicit null is a valid legacy state value and means no metadata. + } else if (!isRecord(worktreeMeta)) { + return null + } else { + for (const id of Object.keys(worktreeMeta)) { + ids.add(id) + } + } + } + const folderWorkspaces = domains.values.get('folderWorkspaces') + if (folderWorkspaces !== undefined) { + if (folderWorkspaces === null) { + // Explicit null is a valid legacy state value and means no workspaces. + } else if (!Array.isArray(folderWorkspaces)) { + return null + } else { + for (const workspace of folderWorkspaces) { + const id = isRecord(workspace) ? workspace.id : undefined + if (typeof id === 'string' && id) { + ids.add(folderWorkspaceKey(id)) + } + } + } + } + return ids +} + +function readProfileWorktreeIdsFromJson(dataFile: string): Set | null { let parsed: unknown try { parsed = JSON.parse(readFileSync(dataFile, 'utf8')) @@ -78,3 +173,7 @@ function readProfileWorktreeIds(dataFile: string): Set | null { } return ids } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index f0298ed115d..7cdcdd8cd73 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -16,6 +16,7 @@ import { quitAndInstall, setupAutoUpdater, showLinuxPackage, + type PreQuitCleanupFailureMode, type UpdateInstallMode } from '../updater' @@ -33,6 +34,7 @@ export function scheduleMainWindowAutoUpdaterSetup( store: Store, options?: { onBeforeUpdateQuit?: () => void | Promise + onBeforeUpdateQuitFailure?: PreQuitCleanupFailureMode updateInstallMode?: UpdateInstallMode } ): void { @@ -69,6 +71,7 @@ export function scheduleMainWindowAutoUpdaterSetup( store.updateUI({ dismissedUpdateNudgeId: id }) }, getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, + onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, installMode: options?.updateInstallMode }) logStartupMilestone('updater-setup-done') diff --git a/src/main/worker-thread-entry-path.ts b/src/main/worker-thread-entry-path.ts index 9c9de40eb80..265c3048b3a 100644 --- a/src/main/worker-thread-entry-path.ts +++ b/src/main/worker-thread-entry-path.ts @@ -46,7 +46,10 @@ export function resolveWorkerThreadEntryPath( export function currentWorkerEntryLayout(moduleDir: string): WorkerEntryLayout { return { isPackaged: hasAppEnvironment() && getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, + resourcesPath: + 'resourcesPath' in process && typeof process.resourcesPath === 'string' + ? process.resourcesPath + : undefined, moduleDir } } diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index da58b2d9def..201b6be9799 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -1,6 +1,13 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' -import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles' +import { + ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, + type OrcaProfileListResult, + type SwitchOrcaProfileResult, + type TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import { prepareAndInvokeAppRestart } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint } from '../preload-runtime-support' export const orcaProfilesApi = { list: () => ipcRenderer.invoke('orcaProfiles:list'), @@ -12,8 +19,30 @@ export const orcaProfilesApi = { }, createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + switchProfile: (args) => + prepareAndInvokeAppRestart( + window, + (): Promise => ipcRenderer.invoke('orcaProfiles:switch', args), + awaitBeforeUnloadCheckpoint, + (result) => result.status === 'relaunching' + ), + transferProject: async (args) => { + const invoke = (): Promise => + ipcRenderer.invoke('orcaProfiles:transferProject', args) + if (args.mode !== 'move') { + return invoke() + } + const current: OrcaProfileListResult = await ipcRenderer.invoke('orcaProfiles:list') + if (args.sourceProfileId !== current.activeProfileId) { + return invoke() + } + return prepareAndInvokeAppRestart( + window, + invoke, + awaitBeforeUnloadCheckpoint, + (result) => result.status === 'transferred' && result.willRelaunch === true + ) + }, findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index b68f55989f5..2c44b9a5b7f 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -124,4 +124,99 @@ describe('native preload destructive app actions', () => { expect(onKeyboardLayoutChanged).toHaveBeenCalledExactlyOnceWith(payload) expect(removeListener).toHaveBeenCalledWith(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) }) + + it('awaits renderer durability before profile maintenance and preserves its result', async () => { + const api = await loadApi() + const started = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + let finishCheckpoint = (_result: { ok: boolean }): void => {} + const checkpoint = new Promise((resolve) => { + finishCheckpoint = resolve + }) + const result = { status: 'relaunching' } + invoke.mockImplementation((channel: string) => + channel === 'app:await-before-unload-checkpoint' ? checkpoint : Promise.resolve(result) + ) + + const switching = api.orcaProfiles.switchProfile({ profileId: 'target' }) + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + ) + expect(started).toHaveBeenCalledOnce() + expect(invoke).not.toHaveBeenCalledWith('orcaProfiles:switch', expect.anything()) + finishCheckpoint({ ok: true }) + await expect(switching).resolves.toBe(result) + expect(invoke).toHaveBeenLastCalledWith('orcaProfiles:switch', { profileId: 'target' }) + }) + + it.each(['checkpoint-failed', 'switch-failed', 'already-active'])( + 'resets restart preparation when profile switching returns %s', + async (outcome) => { + const api = await loadApi() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + invoke.mockImplementation(async (channel: string) => { + if (channel === 'app:await-before-unload-checkpoint') { + return { ok: outcome !== 'checkpoint-failed' } + } + if (outcome === 'switch-failed') { + throw new Error('switch failed') + } + return { status: 'already-active' } + }) + const switching = api.orcaProfiles.switchProfile({ profileId: 'target' }) + await (outcome === 'already-active' + ? expect(switching).resolves.toEqual({ status: 'already-active' }) + : expect(switching).rejects.toThrow()) + expect(aborted).toHaveBeenCalledOnce() + if (outcome === 'checkpoint-failed') { + expect(invoke).not.toHaveBeenCalledWith('orcaProfiles:switch', expect.anything()) + } + } + ) + + it.each(['move', 'copy', 'inactive', 'duplicate', 'recovery'] as const)( + 'prepares only a potentially relaunching project transfer: %s', + async (outcome) => { + const api = await loadApi() + const started = vi.fn() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + const args = { + sourceProfileId: outcome === 'inactive' ? 'inactive' : 'active', + targetProfileId: 'target', + repoId: 'repo', + mode: outcome === 'copy' ? ('copy' as const) : ('move' as const) + } + const result = + outcome === 'duplicate' + ? { status: 'duplicate-target' } + : { status: 'transferred', willRelaunch: outcome === 'move' } + invoke.mockImplementation(async (channel: string) => { + if (channel === 'orcaProfiles:list') { + return { activeProfileId: 'active' } + } + if (channel === 'app:await-before-unload-checkpoint') { + return { ok: true } + } + if (outcome === 'recovery') { + const listener = on.mock.calls.find(([name]) => name === 'app:restart-committed')?.[1] + expect(listener).toBeTypeOf('function') + listener() + throw new Error('move requires recovery') + } + return result + }) + + const transfer = api.orcaProfiles.transferProject(args) + await (outcome === 'recovery' + ? expect(transfer).rejects.toThrow('move requires recovery') + : expect(transfer).resolves.toBe(result)) + const needsPreparation = outcome !== 'copy' && outcome !== 'inactive' + expect(started).toHaveBeenCalledTimes(needsPreparation ? 1 : 0) + expect(aborted).toHaveBeenCalledTimes(outcome === 'duplicate' ? 1 : 0) + expect(invoke).toHaveBeenLastCalledWith('orcaProfiles:transferProject', args) + } + ) }) diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index bd896109b57..ba97195a5f5 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -1,38 +1,140 @@ import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' import { ORCA_APP_RESTART_ABORTED_EVENT, + ORCA_APP_RESTART_STARTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' import { + prepareAndInvokeAppRestart, prepareAndInvokeUpdaterInstall, registerRendererRestartIpcRelays } from './renderer-restart-wiring' +function restartIpc(eventTarget: EventTarget) { + const ipcRenderer = Object.assign(new EventEmitter(), { + invoke: vi.fn(async () => {}), + postMessage: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + sendToHost: vi.fn() + }) + const relay = { handleStatus: vi.fn(), abort: vi.fn() } + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, relay) + return { ipcRenderer, ...relay } +} + describe('renderer restart wiring', () => { + it.each(['no-op', 'failure'] as const)( + 'keeps a committed restart prepared after a later %s', + async (outcome) => { + const eventTarget = new EventTarget() + const { ipcRenderer } = restartIpc(eventTarget) + const aborted = vi.fn() + const started = vi.fn() + const checkpoint = vi.fn(async () => {}) + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + eventTarget.addEventListener(ORCA_APP_RESTART_STARTED_EVENT, started) + await prepareAndInvokeAppRestart( + eventTarget, + async () => { + ipcRenderer.emit('app:restart-committed') + return true + }, + checkpoint, + Boolean + ) + const subsequent = prepareAndInvokeAppRestart( + eventTarget, + async () => { + if (outcome === 'failure') { + throw new Error('already finalized') + } + return false + }, + checkpoint, + Boolean + ) + await (outcome === 'failure' + ? expect(subsequent).rejects.toThrow('already finalized') + : expect(subsequent).resolves.toBe(false)) + expect(checkpoint).toHaveBeenCalledOnce() + expect(started).toHaveBeenCalledOnce() + expect(aborted).not.toHaveBeenCalled() + } + ) + + it('refuses overlapping preparation without abandoning the accepted restart', async () => { + const eventTarget = new EventTarget() + const aborted = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, aborted) + const checkpoint = Promise.withResolvers() + const invoke = vi.fn(async () => true) + const first = prepareAndInvokeAppRestart(eventTarget, invoke, () => checkpoint.promise) + const refused = vi.fn(async () => false) + await expect( + prepareAndInvokeAppRestart(eventTarget, refused, async () => {}, Boolean) + ).rejects.toThrow('already in progress') + expect(refused).not.toHaveBeenCalled() + expect(aborted).not.toHaveBeenCalled() + checkpoint.resolve() + await expect(first).resolves.toBe(true) + expect(invoke).toHaveBeenCalledOnce() + }) + + it('retains late commitment across an unrelated unload veto', async () => { + const eventTarget = new EventTarget() + const abandoned = vi.fn() + eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, abandoned) + eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, abandoned) + const { ipcRenderer } = restartIpc(eventTarget) + const checkpoint = vi.fn(async () => {}) + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + ipcRenderer.emit('app:restart-committed') + await prepareAndInvokeAppRestart(eventTarget, async () => false, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + ipcRenderer.emit('window:unload-prevented') + await prepareAndInvokeAppRestart(eventTarget, async () => true, checkpoint, Boolean) + expect(checkpoint).toHaveBeenCalledOnce() + expect(abandoned).not.toHaveBeenCalled() + }) + + it('releases preparation ownership and its listener after checkpoint failure', async () => { + const eventTarget = new EventTarget() + const add = vi.spyOn(eventTarget, 'addEventListener') + const remove = vi.spyOn(eventTarget, 'removeEventListener') + await expect( + prepareAndInvokeAppRestart( + eventTarget, + async () => {}, + async () => { + throw new Error('checkpoint failed') + } + ) + ).rejects.toThrow('checkpoint failed') + await prepareAndInvokeAppRestart( + eventTarget, + async () => {}, + async () => {} + ) + expect(add.mock.calls).toHaveLength(2) + expect(remove.mock.calls).toEqual(add.mock.calls) + }) + it('relays updater status, aborted installs, and prevented unload events', () => { const eventTarget = new EventTarget() const unloadPrevented = vi.fn() const restartAborted = vi.fn() - const handleStatus = vi.fn() - const abort = vi.fn() - const listeners = new Map void>() - const ipcRenderer = { - on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { - listeners.set(channel, listener) - return ipcRenderer - }) - } as unknown as Parameters[0] + const { ipcRenderer, handleStatus, abort } = restartIpc(eventTarget) eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) eventTarget.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, restartAborted) - - registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort }) - listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + ipcRenderer.emit('updater:status', {}, { state: 'error', message: 'install failed' }) // Why: main abandons an install without any status when its verdict outlived the cycle. - listeners.get('updater:quitAndInstallAborted')?.({}) - listeners.get('window:unload-prevented')?.({}) + ipcRenderer.emit('updater:quitAndInstallAborted') + ipcRenderer.emit('window:unload-prevented') - expect(ipcRenderer.on).toHaveBeenCalledTimes(3) + expect(ipcRenderer.eventNames()).toHaveLength(4) expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) expect(abort).toHaveBeenCalledTimes(1) expect(unloadPrevented).toHaveBeenCalledTimes(1) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 2dd0ad316aa..38ea1882b9f 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -12,6 +12,18 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +type AppRestartState = { committed: boolean; pending: boolean } +const appRestartStates = new WeakMap() + +function appRestartState(eventTarget: EventTarget): AppRestartState { + let state = appRestartStates.get(eventTarget) + if (!state) { + state = { committed: false, pending: false } + appRestartStates.set(eventTarget, state) + } + return state +} + export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, @@ -24,7 +36,14 @@ export function registerRendererRestartIpcRelays( ipcRenderer.on('updater:quitAndInstallAborted', () => { relay.abort() }) + ipcRenderer.on('app:restart-committed', () => { + appRestartState(eventTarget).committed = true + }) ipcRenderer.on('window:unload-prevented', () => { + // A quit veto cannot reopen a profile whose maintenance has already committed. + if (appRestartState(eventTarget).committed) { + return + } eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) }) @@ -50,20 +69,39 @@ export async function prepareAndInvokeUpdaterInstall( } } -export async function prepareAndInvokeAppRestart( +export async function prepareAndInvokeAppRestart( eventTarget: EventTarget, - invoke: () => Promise, - awaitCheckpoint: () => Promise -): Promise { - await prepareRendererForAppRestart(eventTarget, { - startedEventName: ORCA_APP_RESTART_STARTED_EVENT, - abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, - awaitCheckpoint - }) + invoke: () => Promise, + awaitCheckpoint: () => Promise, + willRestart: (result: T) => boolean = () => true +): Promise { + const state = appRestartState(eventTarget) + if (state.pending) { + throw new Error('App restart preparation is already in progress') + } + state.pending = true try { - await invoke() - } catch (error) { - eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) - throw error + if (!state.committed) { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_APP_RESTART_STARTED_EVENT, + abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT, + awaitCheckpoint + }) + } + try { + const result = await invoke() + if (!state.committed && !willRestart(result)) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + return result + } catch (error) { + // A failed profile move can require recovery after its writer has already closed. + if (!state.committed) { + eventTarget.dispatchEvent(new Event(ORCA_APP_RESTART_ABORTED_EVENT)) + } + throw error + } + } finally { + state.pending = false } } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts index 7936300fb06..2c521261b73 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts @@ -10,7 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installIpcPtyWindow, restorePtySpecWindow, - type PtyExitPayload + type PtyExitPayload, + type PtyStreamPayload } from './pty-transport-test-harness' const RECYCLED_PTY_ID = 'ssh:target@@pty-1' @@ -20,11 +21,16 @@ const FRESH_INCARNATION_ID = 'incarnation-of-the-shell-now-attaching' describe('createIpcPtyTransport against a relay-recycled PTY id', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onExit: ((payload: PtyExitPayload) => void) | null = null + let onData: ((payload: PtyStreamPayload) => void) | null = null beforeEach(() => { vi.resetModules() onExit = null + onData = null installIpcPtyWindow(originalWindow, { + data: (callback) => { + onData = callback + }, exit: (callback) => { onExit = callback } @@ -82,6 +88,42 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => { expect(paneExit).toHaveBeenCalledWith(3) }) + it('delivers an authentication failure and exit after the main-side binding cleanup', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = vi.mocked(window.api.pty.spawn) + let release!: () => void + const cleanup = new Promise((resolve) => { + release = resolve + }) + spawn.mockImplementationOnce(async () => { + onData?.({ id: RECYCLED_PTY_ID, data: 'Authentication failed: sign in again.\r\n' }) + onExit?.({ id: RECYCLED_PTY_ID, code: 1, incarnationId: FRESH_INCARNATION_ID }) + await cleanup + return { id: RECYCLED_PTY_ID, incarnationId: FRESH_INCARNATION_ID } + }) + const output = vi.fn() + const exit = vi.fn() + const error = vi.fn() + const connected = vi.fn() + const transport = createIpcPtyTransport() + const pending = transport.connect({ + url: '', + callbacks: { + onData: output, + onExit: exit, + onError: error, + onConnect: connected + } + }) + release() + expect(await pending).toEqual({ id: RECYCLED_PTY_ID, exitedBeforeAttach: true }) + expect(output).toHaveBeenCalledWith('Authentication failed: sign in again.\r\n') + expect(exit).toHaveBeenCalledWith(1) + expect(output.mock.invocationCallOrder[0]).toBeLessThan(exit.mock.invocationCallOrder[0]) + expect(error).not.toHaveBeenCalled() + expect(connected).not.toHaveBeenCalled() + }) + // Absence is unknown, never a mismatch — so an SSH host predating the field, and the relay's own // unnamed `{ id, code: -1 }` drop, keep exactly the behaviour #16970 shipped. (`remote:` runtime // PTYs are not covered by this: their exits never traverse `pty:exit` at all.) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts new file mode 100644 index 00000000000..85023d75f4f --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + DirectSshAuthority, + SshConnectionState, + SshProviderEpoch +} from '../../../../shared/ssh-types' +import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' + +function connectedState( + targetId = 'target-a', + generation = 1 +): SshConnectionState & DirectSshAuthority { + return { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Tests issue a fixed opaque provider token for each target. + providerEpoch: `epoch-${targetId}` as SshProviderEpoch, + connectionGeneration: generation + } +} + +function authority(state: DirectSshAuthority): DirectSshAuthority { + return { + targetId: state.targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } +} + +async function settle(): Promise { + for (let index = 0; index < 60; index += 1) { + await Promise.resolve() + } +} + +const originalStore = useAppStore.getState() +const cleanups: (() => void)[] = [] + +async function createHarness( + initialStates: readonly SshConnectionState[] = [], + reconciledStates: readonly SshConnectionState[] = [] +) { + const states = new Map(initialStates.map((state) => [state.targetId, state])) + const reconciled = new Map(reconciledStates.map((state) => [state.targetId, state])) + let stateListener: ((event: { targetId: string; state: unknown }) => void) | undefined + const getState = vi.fn(async ({ targetId }: { targetId: string }) => { + const state = states.get(targetId) ?? null + const next = reconciled.get(targetId) + if (next) { + states.set(targetId, next) + } + return state + }) + const targets = ['target-a', 'target-b'].map((id) => ({ id, label: id })) + vi.stubGlobal('window', { + addEventListener: () => {}, + removeEventListener: () => {}, + api: { + ui: {}, + repos: {}, + worktrees: {}, + ssh: { + listTargets: async () => targets, + listRemovedTargetLabels: async () => ({}), + getState, + listPortForwards: async () => [], + listDetectedPorts: async () => [], + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {}, + onStateChanged: (listener: typeof stateListener) => { + stateListener = listener + return () => {} + } + } + } + }) + const store = useAppStore.getState() + const invalidate = vi.spyOn(store, 'invalidateStaleDirectSshTargetPtyBindings').mockReturnValue(1) + const retry = vi.spyOn(store, 'retryDirectSshTargetPanes').mockReturnValue(1) + const clearBindings = vi.spyOn(store, 'clearDirectSshTargetPtyBindings').mockReturnValue(1) + const runtime = createDirectSshBridgeRuntime() + const requestReconnect = vi.spyOn(runtime.reconnectCoordinator, 'requestReconnect') + const prepareAndSync = vi.spyOn(runtime, 'prepareAndSync') + const unsubs: (() => void)[] = [] + cleanups.push(() => { + for (const unsubscribe of unsubs) { + unsubscribe() + } + runtime.stop() + }) + registerDirectSshStateIpcBridge(unsubs, runtime) + await settle() + return { + runtime, + getState, + invalidate, + retry, + clearBindings, + requestReconnect, + prepareAndSync, + emit: (state: SshConnectionState) => { + if (!stateListener) { + throw new Error('SSH state listener was not registered') + } + stateListener({ targetId: state.targetId, state }) + }, + applyConnectReply: (state: SshConnectionState) => { + useAppStore.getState().setSshConnectionState(state.targetId, state) + } + } +} + +beforeEach(() => { + useAppStore.setState(originalStore, true) +}) + +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } + await settle() + vi.restoreAllMocks() + useAppStore.setState(originalStore, true) + vi.unstubAllGlobals() +}) + +describe('direct SSH connect reply routing', () => { + it.each(['reply-first', 'push-first'] as const)( + 'retries through the real coordinator once when connection ordering is %s', + async (ordering) => { + const harness = await createHarness() + const state = connectedState() + if (ordering === 'reply-first') { + harness.applyConnectReply(state) + } + harness.emit(state) + if (ordering === 'push-first') { + harness.applyConnectReply(state) + } + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( + authority(state) + ) + await settle() + + harness.retry.mockClear() + harness.emit({ ...state }) + + expect(harness.requestReconnect).toHaveBeenCalledOnce() + expect(harness.invalidate).toHaveBeenCalledOnce() + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.prepareAndSync).toHaveBeenLastCalledWith(authority(state), 'wake-refresh') + } + ) + + it('keeps hydration preparation-only and corrects its duplicate without requesting reconnect', async () => { + const state = connectedState() + const harness = await createHarness([state]) + + expect(harness.prepareAndSync).toHaveBeenCalledExactlyOnceWith( + authority(state), + 'initial-hydration' + ) + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.retry).not.toHaveBeenCalled() + + harness.emit({ ...state }) + + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + + const next = connectedState(state.targetId, 2) + harness.applyConnectReply(next) + harness.retry.mockClear() + harness.emit(next) + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(next)) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(next)) + }) + + it.each(['disconnected', 'error'] as const)( + 'forgets a routed authority after %s even when the next connect reply reaches the store first', + async (status) => { + const state = connectedState() + const harness = await createHarness([state]) + harness.emit({ ...state, status }) + + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) + harness.applyConnectReply(state) + harness.emit(state) + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( + authority(state) + ) + } + ) + + it.each(['providerEpoch', 'connectionGeneration'] as const)( + 'preserves initial hydration routing when %s must be reconciled', + async (missingField) => { + const state = connectedState() + const partial: SshConnectionState = { ...state } + delete partial[missingField] + const harness = await createHarness([partial], [state]) + + expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(state) + expect(harness.prepareAndSync).toHaveBeenCalledExactlyOnceWith( + authority(state), + 'initial-hydration' + ) + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).not.toHaveBeenCalled() + + harness.emit(state) + + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + } + ) + + it.each(['providerEpoch', 'connectionGeneration'] as const)( + 'waits for partial authority reconciliation when %s is missing', + async (missingField) => { + const harness = await createHarness() + const state = connectedState() + let resolveState!: (state: SshConnectionState) => void + harness.getState.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveState = resolve + }) + ) + harness.applyConnectReply(state) + const partial: SshConnectionState = { ...state } + delete partial[missingField] + harness.emit(partial) + + expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.retry).not.toHaveBeenCalled() + resolveState(state) + await settle() + + expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) + expect(harness.retry).toHaveBeenCalledWith(authority(state)) + harness.emit(state) + expect(harness.requestReconnect).toHaveBeenCalledOnce() + } + ) + + it('keeps routed authorities and disconnect cleanup separate for each target', async () => { + const harness = await createHarness() + const first = connectedState('target-a') + const second = connectedState('target-b') + for (const state of [first, second]) { + harness.applyConnectReply(state) + harness.emit(state) + } + await settle() + expect(harness.requestReconnect.mock.calls).toEqual([[authority(first)], [authority(second)]]) + + harness.emit({ ...first, status: 'disconnected' }) + harness.retry.mockClear() + harness.emit(second) + + expect(harness.requestReconnect).toHaveBeenCalledTimes(2) + expect(harness.retry).toHaveBeenCalledExactlyOnceWith(authority(second)) + expect(harness.runtime.reconnectAuthorityByTarget.get(second.targetId)).toEqual( + authority(second) + ) + expect(harness.runtime.reconnectAuthorityByTarget.has(first.targetId)).toBe(false) + + harness.applyConnectReply(first) + harness.emit(first) + expect(harness.requestReconnect).toHaveBeenNthCalledWith(3, authority(first)) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index 11ceff832fc..2efacc7765d 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -22,6 +22,8 @@ export function registerDirectSshStateIpcBridge( prepareAndSync } = runtime const sshStateWatermarkByTargetId = new Map() + // Connect replies can update the store before this bridge routes the matching push. + const routedAuthorityByTarget = new Map() const pendingPortHydrationByTargetId = new Map< string, { receivedForwardPush: boolean; receivedDetectedPush: boolean } @@ -152,10 +154,10 @@ export function registerDirectSshStateIpcBridge( origin: DirectSshConnectedStateOrigin ): void => { const store = useAppStore.getState() - const previous = store.sshConnectionStates?.get(targetId) store.setSshConnectionState(targetId, state) if (canConnectSshStatus(state.status)) { + routedAuthorityByTarget.delete(targetId) reconnectAuthorityByTarget.delete(targetId) reconnectCoordinator.invalidate(targetId) store.clearRemoteDetectedAgents(targetId) @@ -175,16 +177,8 @@ export function registerDirectSshStateIpcBridge( reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) return } - const previousAuthority = - previous?.status === 'connected' && - previous.providerEpoch && - previous.connectionGeneration !== undefined - ? { - targetId, - providerEpoch: previous.providerEpoch, - connectionGeneration: previous.connectionGeneration - } - : null + const previousAuthority = routedAuthorityByTarget.get(targetId) ?? null + routedAuthorityByTarget.set(targetId, authority) routeDirectSshConnectedState( { coordinator: reconnectCoordinator, @@ -235,6 +229,7 @@ export function registerDirectSshStateIpcBridge( } const latestStore = useAppStore.getState() if (!targets.some((target) => target.id === data.targetId)) { + routedAuthorityByTarget.delete(data.targetId) latestStore.clearRemovedSshTargetState(data.targetId) return } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 49d13f87f01..84b498f18f2 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -7,7 +7,10 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/termin import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import type { TerminalSlice } from '@/store/slices/terminals' import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' -import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners' +import { + indexLiveTerminalSurfaceOwners, + type LiveTerminalSurfaceOwnerIndex +} from './worktree-live-terminal-surface-owners' const WORKTREE_ID = 'repo::/worktree' const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale' @@ -212,7 +215,10 @@ function seedExistingSurface( describe('worktree agent activation gate', () => { it('uses immediately ready development restore inventory', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) const awaitReady = vi.fn(async () => true) await expect( @@ -226,7 +232,10 @@ describe('worktree agent activation gate', () => { it('waits for packaged restore hydration before reading daemon inventory', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) let releaseReady!: (ready: boolean) => void const awaitReady = vi.fn(() => new Promise((resolve) => (releaseReady = resolve))) @@ -270,7 +279,10 @@ describe('worktree agent activation gate', () => { it('adopts a live daemon PTY before activation can resume another agent', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') @@ -285,7 +297,10 @@ describe('worktree agent activation gate', () => { it('adopts a daemon PTY minted for a folder workspace', async () => { const folderWorkspaceId = 'folder:plain-workspace' const ptyId = `${folderWorkspaceId}@@live-pty` - const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const { deps, createTab, resume } = testDeps({ + sessions: [listed(ptyId)], + surfaceOwners: new Map([[ptyId, 'unowned']]) + }) await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') @@ -593,7 +608,10 @@ describe('worktree agent activation gate', () => { const unverifiablePtyId = `${WORKTREE_ID}@@ambiguous-agent` const { deps } = testDeps({ sessions: [listed(adoptedPtyId), listed(unverifiablePtyId)], - surfaceOwners: new Map([[unverifiablePtyId, null]]), + surfaceOwners: new Map([ + [adoptedPtyId, 'unowned'], + [unverifiablePtyId, null] + ]), resumeCount: 0 }) @@ -653,14 +671,14 @@ describe('worktree agent activation gate', () => { const livePtyId = `${WORKTREE_ID}@@live-agent` const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)], - surfaceOwners: new Map() + surfaceOwners: new Map([[livePtyId, 'unowned']]) }) const store = deps.getState() seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID }) // The pane mounts while the census is in flight, binding the PTY behind the sweep. deps.listSurfaceOwners.mockImplementation(async () => { store.ptyIdsByTabId['tab-live'] = [livePtyId] - return new Map() + return new Map([[livePtyId, 'unowned']]) }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') @@ -668,6 +686,38 @@ describe('worktree agent activation gate', () => { expect(createTab).not.toHaveBeenCalled() }) + it('does not adopt a predecessor after the relay restarts between activation inventories', async () => { + const predecessor = 'ssh:target@@pty2:old-relay:1' + const replacement = 'ssh:target@@pty2:new-relay:1' + const { deps, createTab } = testDeps({ resumeCount: 0 }) + const store = deps.getState() + seedExistingSurface(store, { + tabId: 'tab-live', + leafId: LIVE_LEAF_ID, + boundPtyId: predecessor + }) + let replyWithOldInventory!: (sessions: PtyListedSession[]) => void + deps.listSessions.mockImplementationOnce( + () => + new Promise((resolve) => { + replyWithOldInventory = resolve + }) + ) + const activation = runWorktreeAgentActivationGate(WORKTREE_ID, deps) + await vi.waitFor(() => expect(deps.listSessions).toHaveBeenCalledOnce()) + + // Recovery rebinds the same pane before the old relay's inventory response arrives. + store.terminalLayoutsByTabId['tab-live']!.ptyIdsByLeafId[LIVE_LEAF_ID] = replacement + store.ptyIdsByTabId['tab-live'] = [replacement] + deps.listSurfaceOwners.mockResolvedValueOnce(indexLiveTerminalSurfaceOwners([], WORKTREE_ID)) + replyWithOldInventory([{ ...listed(predecessor), worktreeId: WORKTREE_ID }]) + await activation + + expect(createTab).not.toHaveBeenCalled() + expect(store.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['tab-live']) + expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId[LIVE_LEAF_ID]).toBe(replacement) + }) + it('gives every host pane of an unmounted tab its own leaf', async () => { const firstPtyId = `${WORKTREE_ID}@@live-agent` const secondPtyId = `${WORKTREE_ID}@@live-sibling` @@ -706,7 +756,7 @@ describe('worktree agent activation gate', () => { const livePtyId = `${WORKTREE_ID}@@orphan-agent` const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)], - surfaceOwners: new Map() + surfaceOwners: new Map([[livePtyId, 'unowned']]) }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 3675b7575da..81ac509ffb7 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -155,7 +155,7 @@ export async function adoptLiveWorkspacePtySurfaces( continue } const owner = surfaceOwners?.get(ptyId) - if (owner) { + if (owner && owner !== 'unowned') { if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds)) { surfaced = true } else { @@ -165,7 +165,7 @@ export async function adoptLiveWorkspacePtySurfaces( } // Why: only the execution host can prove a live PTY is unowned, and minting // on anything weaker forks a running agent onto a second empty surface. - if (!surfaceOwners || surfaceOwners.has(ptyId)) { + if (owner !== 'unowned') { declinedPtyIds.push(ptyId) continue } diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts index 033ff1da034..66db1463496 100644 --- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts +++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts @@ -27,10 +27,12 @@ function summary(overrides: Partial): RuntimeTerminalSum } } -function stubTerminalList(result: unknown): void { +function stubTerminalList(result: unknown) { + const call = vi.fn(async () => ({ ok: true, result })) vi.stubGlobal('window', { - api: { runtime: { call: vi.fn(async () => ({ ok: true, result })) } } + api: { runtime: { call } } }) + return call } afterEach(() => { @@ -48,7 +50,7 @@ describe('live terminal surface owners', () => { }) }) - it('leaves an orphaned PTY absent so it stays eligible for a recovery tab', () => { + it('records explicit live orphan evidence for a recovery tab', () => { const ptyId = `${WORKTREE_ID}@@orphan` const owners = indexLiveTerminalSurfaceOwners( [ @@ -62,9 +64,43 @@ describe('live terminal surface owners', () => { WORKTREE_ID ) - expect(owners.has(ptyId)).toBe(false) + expect(owners.get(ptyId)).toBe('unowned') }) + it('does not authorize adoption of a disconnected orphan', () => { + const ptyId = `${WORKTREE_ID}@@orphan` + const owners = indexLiveTerminalSurfaceOwners( + [summary({ ptyId, orphaned: true, connected: false })], + WORKTREE_ID + ) + + expect(owners.get(ptyId)).toBeNull() + }) + + it('does not infer orphan ownership from a legacy synthetic surface', () => { + const ptyId = `${WORKTREE_ID}@@orphan` + const owners = indexLiveTerminalSurfaceOwners( + [summary({ ptyId, tabId: `pty:${ptyId}`, leafId: `pty:${ptyId}` })], + WORKTREE_ID + ) + + expect(owners.get(ptyId)).toBeNull() + }) + + it.each([false, true])( + 'rejects conflicting owned and orphan rows (orphan first: %s)', + (orphanFirst) => { + const orphan = summary({ orphaned: true }) + const owned = summary({}) + const owners = indexLiveTerminalSurfaceOwners( + orphanFirst ? [orphan, owned, orphan] : [owned, orphan, owned], + WORKTREE_ID + ) + + expect(owners.get(owned.ptyId!)).toBeNull() + } + ) + it('ignores rows belonging to another workspace', () => { const owners = indexLiveTerminalSurfaceOwners( [summary({ worktreeId: 'repo::/other' })], @@ -74,14 +110,13 @@ describe('live terminal surface owners', () => { expect(owners.size).toBe(0) }) - // Dropping the host's row over path spelling would read as `unowned` and mint a duplicate. it('indexes a row the host spelled with an equivalent workspace path', () => { const owners = indexLiveTerminalSurfaceOwners( [summary({ worktreeId: `${WORKTREE_ID}/` })], WORKTREE_ID ) - expect(owners.get(`${WORKTREE_ID}@@live-agent`)?.tabId).toBe('tab-live') + expect(owners.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' }) }) it('reports a PTY claimed by two panes as unverifiable rather than unowned', () => { @@ -122,7 +157,7 @@ describe('live terminal surface owners', () => { const owners = await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID) - expect(owners?.get(`${WORKTREE_ID}@@live-agent`)?.tabId).toBe('tab-live') + expect(owners?.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' }) }) it('refuses a census from a host that cannot name the scope it answered for', async () => { @@ -142,7 +177,7 @@ describe('live terminal surface owners', () => { }) it('indexes a complete census', async () => { - stubTerminalList({ + const call = stubTerminalList({ terminals: [summary({})], truncated: false, hostScope: { hostIds: ['local'], omittedHostIds: [] } @@ -150,7 +185,16 @@ describe('live terminal surface owners', () => { const owners = await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID) - expect(owners?.get(`${WORKTREE_ID}@@live-agent`)?.tabId).toBe('tab-live') + expect(owners?.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' }) + expect(call).toHaveBeenCalledWith({ + method: 'terminal.list', + params: { + worktree: `id:${WORKTREE_ID}`, + limit: 200, + requireFreshPtyLiveness: true, + includeVisualLayouts: false + } + }) }) it('refuses a census the host could not answer', async () => { diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts index d42c8e5ad4f..34aa91438f8 100644 --- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts +++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts @@ -20,12 +20,11 @@ export type LiveTerminalSurfaceOwner = { * client-created tab can leave empty, so they cannot answer "is this PTY * unowned?" — only the host can. * - * Three verdicts, never two: an entry is the owner, a `null` entry is - * `unverifiable` (the host named a surface this renderer cannot address, or - * named two), and a whole-index `null` is `unverifiable` for every PTY. Absence - * from a readable index is the only proof of `unowned`. + * Only `unowned` proves the host observed a live PTY with no surface. Null and + * missing entries are unverifiable; an earlier inventory may name a retired PTY. */ -export type LiveTerminalSurfaceOwnerIndex = ReadonlyMap +type LiveTerminalSurfaceOwnership = LiveTerminalSurfaceOwner | 'unowned' | null +export type LiveTerminalSurfaceOwnerIndex = ReadonlyMap const OWNER_LISTING_LIMIT = 200 @@ -65,25 +64,25 @@ function toSurfaceOwner(terminal: RuntimeTerminalSummary): LiveTerminalSurfaceOw export function indexLiveTerminalSurfaceOwners( terminals: readonly RuntimeTerminalSummary[], worktreeId: string -): Map { - const owners = new Map() +): Map { + const owners = new Map() for (const terminal of terminals) { - // `orphaned` is the host's own word for "live PTY, no surface owns it". - // Path spelling can differ between the host's row and the renderer's id; dropping a row - // over that would read as `unowned` and mint the duplicate this index exists to prevent. - if ( - !worktreeIdsEqual(terminal.worktreeId, worktreeId) || - !terminal.ptyId || - terminal.orphaned === true - ) { + if (!worktreeIdsEqual(terminal.worktreeId, worktreeId) || !terminal.ptyId) { continue } - const owner = toSurfaceOwner(terminal) + const owner = + terminal.orphaned === true + ? terminal.connected === true + ? 'unowned' + : null + : toSurfaceOwner(terminal) const recorded = owners.get(terminal.ptyId) - // Two surfaces claiming one PTY is the duplicate this index must not endorse. + const recordedPane = recorded && recorded !== 'unowned' ? recorded.paneKey : recorded + const ownerPane = owner && owner !== 'unowned' ? owner.paneKey : owner + // Conflicting ownership claims cannot authorize adoption. owners.set( terminal.ptyId, - owners.has(terminal.ptyId) && recorded?.paneKey !== owner?.paneKey ? null : owner + owners.has(terminal.ptyId) && recordedPane !== ownerPane ? null : owner ) } return owners @@ -104,6 +103,7 @@ export async function readWorktreeLiveTerminalSurfaceOwners( params: { worktree: toRuntimeWorktreeSelector(worktreeId), limit: OWNER_LISTING_LIMIT, + requireFreshPtyLiveness: true, includeVisualLayouts: false } }) diff --git a/src/renderer/src/web/web-runtime-client-heartbeat.test.ts b/src/renderer/src/web/web-runtime-client-heartbeat.test.ts index ab85698660b..cde7022eefd 100644 --- a/src/renderer/src/web/web-runtime-client-heartbeat.test.ts +++ b/src/renderer/src/web/web-runtime-client-heartbeat.test.ts @@ -6,6 +6,7 @@ import { WebRuntimeClient } from './web-runtime-client' // keeping its timer armed while the window is hidden. const fakeSockets: FakeWebSocket[] = [] +const clients: WebRuntimeClient[] = [] let visibilityState: DocumentVisibilityState = 'visible' let nextIntervalId = 1 const documentListeners = new Map void>() @@ -73,6 +74,7 @@ function makeConnectedClient(): { deviceToken: 'token', publicKeyB64: Buffer.alloc(32).toString('base64') }) + clients.push(client) const internals = client as unknown as HeartbeatInternals // Override the protected time/visibility seams deterministically. internals.now = () => nowMs @@ -130,6 +132,7 @@ describe('WebRuntimeClient liveness heartbeat', () => { }) afterEach(() => { + clients.splice(0).forEach((client) => client.close()) vi.unstubAllGlobals() }) diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index f4252c03e4a..a3ad092c3c8 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -9,6 +9,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'comments', 'connect', 'current', + 'current-json', 'debug', 'dry-run', 'enter', diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 9a2598caefb..aa737ea4aed 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -49,6 +49,8 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. { filename: 'daemon-entry.js' }, + { filename: 'profile-state-writer-worker-entry.js' }, + { filename: 'profile-state-backup-worker-entry.js' }, ...ORCAD_RIPGREP_ARTIFACTS.map((filename) => ({ filename })), ...ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((filename) => ({ filename })) ] diff --git a/src/shared/profile-state-recovery-command.test.ts b/src/shared/profile-state-recovery-command.test.ts new file mode 100644 index 00000000000..8036ecac085 --- /dev/null +++ b/src/shared/profile-state-recovery-command.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { profileStateRecoveryRequestSchema } from './profile-state-recovery-command' + +describe('offline recovery source selection', () => { + it.each([ + { kind: 'current-json' }, + { kind: 'json', revision: 1 }, + { kind: 'sqlite', backupId: 'selected-backup' } + ])('accepts an explicit source: %j', (selector) => { + expect( + profileStateRecoveryRequestSchema.parse({ userDataPath: '/profile', selector }).selector + ).toEqual(selector) + }) + + it.each([ + { kind: 'current-json', revision: 1 }, + { kind: 'current-json', backupId: 'selected-backup' }, + { kind: 'current-json', path: '../different-profile/orca-data.json' }, + { kind: 'json', revision: null }, + { kind: 'json', revision: 0 }, + { kind: 'sqlite' }, + { kind: 'json' } + ])('rejects ambiguous or incomplete sources: %j', (selector) => { + expect( + profileStateRecoveryRequestSchema.safeParse({ userDataPath: '/profile', selector }).success + ).toBe(false) + }) +}) diff --git a/src/shared/profile-state-recovery-command.ts b/src/shared/profile-state-recovery-command.ts new file mode 100644 index 00000000000..726e034ef93 --- /dev/null +++ b/src/shared/profile-state-recovery-command.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' + +export const PROFILE_STATE_RECOVERY_FLAG = '--profile-state-recovery' +export const PROFILE_STATE_RECOVERY_RESULT_PREFIX = '[profile-state-recovery] ' + +const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const selectorSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('json'), revision: positiveInteger }).strict(), + z.object({ kind: z.literal('current-json') }).strict(), + z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict() +]) + +export const profileStateRecoveryRequestSchema = z + .object({ + userDataPath: z.string().min(1), + selector: selectorSchema + }) + .strict() + +const exportsSchema = z.object({ + profileId: z.string(), + dataFile: z.string(), + databaseFile: z.string(), + exportPaths: z.array(z.string()).readonly(), + backups: z + .array(z.object({ id: z.string(), path: z.string(), createdAtMs: positiveInteger })) + .readonly() +}) +const rollbackSchema = exportsSchema.extend({ + // Canonical JSON edited outside SQLite has no database revision. + revision: positiveInteger.nullable(), + quarantineDirectory: z.string(), + removedDatabaseFiles: z.array(z.string()).readonly(), + storage: z.enum(['json', 'sqlite']), + restoredPath: z.string(), + backupId: z.string().optional() +}) + +export const profileStateRecoveryResponseSchema = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true), result: rollbackSchema }), + z.object({ + ok: z.literal(false), + code: z.enum(['invalid_argument', 'runtime_error']), + message: z.string() + }) +]) + +export type ProfileStateRecoverySelector = z.infer +export type ProfileStateRecoveryRequest = z.infer +export type ProfileStateRecoveryResponse = z.infer +export type ProfileStateExportsResult = z.infer +export type ProfileStateRollbackResult = z.infer + +export class ProfileStateRecoveryCommandError extends Error { + constructor( + readonly code: 'invalid_argument' | 'runtime_error', + message: string + ) { + super(message) + this.name = 'ProfileStateRecoveryCommandError' + } +} + +export function isProfileStateRecoveryCommandError( + error: unknown +): error is Error & { code: 'invalid_argument' | 'runtime_error' } { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'invalid_argument' || error.code === 'runtime_error') + ) +} diff --git a/src/shared/profile-state-storage-paths.ts b/src/shared/profile-state-storage-paths.ts new file mode 100644 index 00000000000..0dea3dbb0a5 --- /dev/null +++ b/src/shared/profile-state-storage-paths.ts @@ -0,0 +1,16 @@ +import { join } from 'node:path' + +export const PROFILE_STATE_DATABASE_FILE_NAME = 'profile-state.db' + +/** Pure profile-state path helpers shared by the offline CLI and main process. */ +export function profileStateDatabaseFile(profileDirectory: string): string { + return join(profileDirectory, PROFILE_STATE_DATABASE_FILE_NAME) +} + +export function getOrcaProfileDataFile(profileId: string, userDataPath: string): string { + return join(userDataPath, 'profiles', profileId, 'orca-data.json') +} + +export function getOrcaProfileStateDatabaseFile(profileId: string, userDataPath: string): string { + return profileStateDatabaseFile(join(userDataPath, 'profiles', profileId)) +} diff --git a/src/shared/profile-state-telemetry-schema.test.ts b/src/shared/profile-state-telemetry-schema.test.ts new file mode 100644 index 00000000000..63c9dd88ab0 --- /dev/null +++ b/src/shared/profile-state-telemetry-schema.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-events' + +describe('profile state authority telemetry', () => { + it('accepts the bounded startup selection payload', () => { + expect( + eventSchemas.profile_state_authority_selected.safeParse({ + backend: 'sqlite', + classification: 'json-only', + authority_mode: 'sqlite-candidate', + runtime: 'desktop', + migrated: true + }).success + ).toBe(true) + }) + + it('rejects paths or other unbounded diagnostic fields', () => { + expect( + eventSchemas.profile_state_authority_selected.safeParse({ + backend: 'sqlite', + classification: 'sqlite-only', + authority_mode: 'sqlite-established', + runtime: 'desktop', + migrated: false, + database_path: '/private/profile.sqlite' + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/telemetry-daemon-event-schemas.ts b/src/shared/telemetry-daemon-event-schemas.ts index 26762e5a52e..9632e75bd05 100644 --- a/src/shared/telemetry-daemon-event-schemas.ts +++ b/src/shared/telemetry-daemon-event-schemas.ts @@ -201,6 +201,19 @@ export const settingsChangedSchema = z }) .strict() +// Why: profile-state cutover needs fleet-level evidence that authority selection and migration +// agree with the rollout plan. Keep this enum-only: paths, profile IDs, and serialized state never +// belong in telemetry. +export const profileStateAuthoritySelectedSchema = z + .object({ + backend: z.enum(['json', 'sqlite']), + classification: z.enum(['neither', 'json-only', 'sqlite-only', 'both']), + authority_mode: z.enum(['legacy', 'sqlite-candidate', 'sqlite-established']), + runtime: z.enum(['desktop', 'orcad']), + migrated: z.boolean() + }) + .strict() + // Managed-hook installer label from `AGENT_HOOK_TARGETS`, distinct from `AGENT_KIND_VALUES`; `claude` (not `claude-code`) is intentional. export const hookInstallAgentSchema = z.enum(AGENT_HOOK_TARGETS) export type HookInstallAgent = z.infer diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 810ba8b35d1..cba4ad9a208 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -21,6 +21,7 @@ import { daemonPtyCwdVerdictSchema, daemonStartFailedSchema, mainThreadHangDetectedSchema, + profileStateAuthoritySelectedSchema, remoteOutboundBudgetCloseSchema, runtimeRpcStartFailedSchema, settingsChangedSchema @@ -132,6 +133,7 @@ export const eventSchemas = { daemon_audit_eligibility: daemonAuditEligibilitySchema, runtime_rpc_start_failed: runtimeRpcStartFailedSchema, remote_outbound_budget_close: remoteOutboundBudgetCloseSchema, + profile_state_authority_selected: profileStateAuthoritySelectedSchema, codex_trust_grant: codexTrustGrantSchema, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index c2cf9036dc0..d1bec760ecd 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -49,6 +49,7 @@ export { } from './telemetry-app-event-schemas' export { hookInstallAgentSchema, + profileStateAuthoritySelectedSchema, runtimeRpcStartErrorClassSchema } from './telemetry-daemon-event-schemas' export type { HookInstallAgent, RuntimeRpcStartErrorClass } from './telemetry-daemon-event-schemas' diff --git a/src/shared/uuid-v4.test.ts b/src/shared/uuid-v4.test.ts new file mode 100644 index 00000000000..a12396accca --- /dev/null +++ b/src/shared/uuid-v4.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUuidV4 } from './uuid-v4' + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe('createUuidV4', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('uses the browser crypto fallback when randomUUID is unavailable', () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => { + bytes.fill(0xab) + return bytes + } + }) + + expect(createUuidV4()).toMatch(UUID_V4) + }) + + it('still returns a UUID when Web Crypto is unavailable', () => { + vi.stubGlobal('crypto', undefined) + + expect(createUuidV4()).toMatch(UUID_V4) + }) +}) diff --git a/src/shared/uuid-v4.ts b/src/shared/uuid-v4.ts new file mode 100644 index 00000000000..2bda84ec25e --- /dev/null +++ b/src/shared/uuid-v4.ts @@ -0,0 +1 @@ +export { createNonSecureContextUuid as createUuidV4 } from './non-secure-context-uuid' diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts index 18e98409094..b6609c29df7 100644 --- a/tests/e2e/agent-session-live-force-exit-resume.spec.ts +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -1,5 +1,9 @@ +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { ChildProcess } from 'node:child_process' import type { ElectronApplication } from '@stablyai/playwright-test' @@ -16,7 +20,6 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-live-force-exit-session' @@ -41,17 +44,9 @@ type PersistedData = { workspaceSession?: PersistedWorkspaceSession } -function dataFilePath(userDataDir: string): string { - // Fresh sessions migrate the seeded legacy file, then persist only here. - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function readPersistedData(userDataDir: string): PersistedData { - return JSON.parse(readFileSync(dataFilePath(userDataDir), 'utf8')) as PersistedData -} - -function writePersistedData(userDataDir: string, data: PersistedData): void { - writeFileSync(dataFilePath(userDataDir), `${JSON.stringify(data, null, 2)}\n`, 'utf8') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + return readPersistedProfileState(userDataDir) as PersistedData } function daemonPidPath(userDataDir: string): string { @@ -115,29 +110,31 @@ function killPid(pid: number): void { } function stripPersistedPtyOwnership(userDataDir: string): void { - const data = readPersistedData(userDataDir) - const session = data.workspaceSession - if (!session) { - throw new Error('Expected persisted workspace session') - } - for (const tabs of Object.values(session.tabsByWorktree ?? {})) { - for (const tab of tabs) { - tab.ptyId = null + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const session = data.workspaceSession + if (!session) { + throw new Error('Expected persisted workspace session') } - } - // Why: this models the updater/crash artifact from #6370: the UI tab and - // live resume record survive, but no pane has the old stable leaf key or - // daemon session to own resume. - session.terminalLayoutsByTabId = {} - session.activeWorktreeIdsOnShutdown = [] - for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - if (record.providerSession?.id === PROVIDER_SESSION_ID) { - // Why: the e2e proof should verify Orca launches the resumed command, - // not depend on a developer machine having a real Codex CLI installed. - record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + tab.ptyId = null + } } - } - writePersistedData(userDataDir, data) + // Why: this models the updater/crash artifact from #6370: the UI tab and + // live resume record survive, but no pane has the old stable leaf key or + // daemon session to own resume. + session.terminalLayoutsByTabId = {} + session.activeWorktreeIdsOnShutdown = [] + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (record.providerSession?.id === PROVIDER_SESSION_ID) { + // Why: the e2e proof should verify Orca launches the resumed command, + // not depend on a developer machine having a real Codex CLI installed. + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + } + } + }) } function persistedLiveRecordExists(userDataDir: string): boolean { diff --git a/tests/e2e/agent-session-quit-resume.spec.ts b/tests/e2e/agent-session-quit-resume.spec.ts index 17c68f01ca7..b4a8573904f 100644 --- a/tests/e2e/agent-session-quit-resume.spec.ts +++ b/tests/e2e/agent-session-quit-resume.spec.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' @@ -14,40 +15,35 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-quit-resume-session' function stubPersistedResumeCommand(userDataDir: string): void { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { - workspaceSession?: { - sleepingAgentSessionsByPaneKey?: Record< - string, - { - providerSession?: { id?: unknown } - launchConfig?: { - agentCommand?: string - agentArgs?: string - agentEnv?: Record + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + providerSession?: { id?: unknown } + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } } - } - > + > + } } - } - const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( - (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID - ) - if (!record) { - throw new Error('Expected a persisted resumable agent session') - } - record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected a persisted resumable agent session') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + }) } function readDaemonPid(userDataDir: string): number { diff --git a/tests/e2e/finished-agent-ghost-resume.spec.ts b/tests/e2e/finished-agent-ghost-resume.spec.ts index 5d135161f08..6022edbf201 100644 --- a/tests/e2e/finished-agent-ghost-resume.spec.ts +++ b/tests/e2e/finished-agent-ghost-resume.spec.ts @@ -1,3 +1,7 @@ +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' /** * A LOCAL agent that FINISHED its turn must not be respawned when the app * reopens the workspace. @@ -15,8 +19,7 @@ * pnpm exec playwright test tests/e2e/finished-agent-ghost-resume.spec.ts \ * --config tests/playwright.config.ts --project electron-headless --workers=1 */ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' +import { existsSync, readFileSync } from 'node:fs' import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' @@ -31,7 +34,6 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { createHostRendererTerminalTab } from './helpers/host-created-terminal-retention-oracle' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-finished-agent-session' @@ -43,13 +45,8 @@ type PersistedRecord = { } function readPersistedRecords(userDataDir: string): Record { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as { workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } } return data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {} @@ -57,24 +54,20 @@ function readPersistedRecords(userDataDir: string): Record } - } - const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( - (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID - ) - if (!record) { - throw new Error('Expected the finished agent turn to leave a persisted record') - } - record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') - return record + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as { + workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } + } + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected the finished agent turn to leave a persisted record') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + return record + }) } test.describe.configure({ mode: 'serial' }) diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 1d4b4a0f82c..673f548d18c 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -1,3 +1,4 @@ +import { readPersistedProfileState } from './helpers/persisted-profile-state' import { spawn, type ChildProcess } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' @@ -31,7 +32,6 @@ import type { } from '../../src/shared/runtime-types' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' import { parsePaneKey } from '../../src/shared/stable-pane-id' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') const electronPath = path.join( @@ -76,12 +76,8 @@ function readPersistedPromotionBinding( leafId: string ): { tabId: string; leafId: string; ptyId: string } | null { try { - const persisted = JSON.parse( - readFileSync( - path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), - 'utf8' - ) - ) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const persisted = readPersistedProfileState(userDataDir) as { workspaceSession?: { tabsByWorktree?: Record terminalLayoutsByTabId?: Record }> diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index d3ef3fb8195..5ac0d8c1948 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -1,3 +1,4 @@ +import { readPersistedProfileState } from './persisted-profile-state' import { execFileSync } from 'node:child_process' import { chmodSync, @@ -11,7 +12,6 @@ import { import os from 'node:os' import path from 'node:path' import type { RuntimeClient } from '../../../src/cli/runtime-client' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalSummary @@ -194,16 +194,8 @@ export async function listRuntimeTerminals( } export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - if (!existsSync(dataPath)) { - return null - } - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as { workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record< string, diff --git a/tests/e2e/helpers/electron-launch-args.ts b/tests/e2e/helpers/electron-launch-args.ts index 6868f48b083..e749945eddb 100644 --- a/tests/e2e/helpers/electron-launch-args.ts +++ b/tests/e2e/helpers/electron-launch-args.ts @@ -1,12 +1,14 @@ import { dirname } from 'node:path' +export function getElectronIsolatedKeychainArgs(): string[] { + // Isolated macOS profiles must not invoke the system keychain UI. + return process.platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] +} + export function getOrcaElectronLaunchArgs(mainPath: string, headful: boolean): string[] { // Launch through package.json so app version and resource paths match a packaged app. const appPath = dirname(dirname(dirname(mainPath))) - // Isolated macOS profiles must not invoke the system keychain UI. Without - // these Chromium switches startup can block before the first renderer target. - const keychainArgs = - process.platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] + const keychainArgs = getElectronIsolatedKeychainArgs() if (process.platform === 'darwin') { // Crash tests must not block later launches on AppKit's saved-window recovery dialog. return [...keychainArgs, appPath, '-ApplePersistenceIgnoreState', 'YES'] diff --git a/tests/e2e/helpers/electron-launch-args.unit.test.ts b/tests/e2e/helpers/electron-launch-args.unit.test.ts index c538d6f9a85..301548a9f4e 100644 --- a/tests/e2e/helpers/electron-launch-args.unit.test.ts +++ b/tests/e2e/helpers/electron-launch-args.unit.test.ts @@ -1,10 +1,24 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getOrcaElectronLaunchArgs } from './electron-launch-args' +import { getElectronIsolatedKeychainArgs, getOrcaElectronLaunchArgs } from './electron-launch-args' describe('getOrcaElectronLaunchArgs', () => { afterEach(() => vi.unstubAllGlobals()) + it.each(['darwin', 'linux', 'win32'])( + 'isolates packaged and source test keychains on %s', + (platform) => { + vi.stubGlobal('process', { ...process, platform }) + const args = getElectronIsolatedKeychainArgs() + expect(args).toEqual( + platform === 'darwin' ? ['--password-store=basic', '--use-mock-keychain'] : [] + ) + expect(getOrcaElectronLaunchArgs(join('orca', 'out', 'main', 'index.js'), false)).toEqual( + expect.arrayContaining(args) + ) + } + ) + it.each([ ['linux', 'true', true, true], ['linux', undefined, true, false], diff --git a/tests/e2e/helpers/electron-main-evaluate-retry.ts b/tests/e2e/helpers/electron-main-evaluate-retry.ts index 502e93152a0..c7794c40728 100644 --- a/tests/e2e/helpers/electron-main-evaluate-retry.ts +++ b/tests/e2e/helpers/electron-main-evaluate-retry.ts @@ -1,13 +1,13 @@ const MAIN_EVALUATE_ATTEMPTS = 5 const MAIN_EVALUATE_RETRY_MS = 200 -/** - * Playwright raises this message for any main-process CDP failure that is neither - * a JS error nor a closed session, so it does not mean anything navigated — it is - * also what a handle the main process has not finished publishing looks like. - */ +// Startup can invalidate the CDP context or collect a pending evaluation promise. function isTransientMainEvaluateError(error: unknown): boolean { - return error instanceof Error && error.message.includes('Execution context was destroyed') + return ( + error instanceof Error && + (error.message.includes('Execution context was destroyed') || + error.message.includes('Resulting promise was garbage collected')) + ) } function waitBeforeRetry(): Promise { diff --git a/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts b/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts index 7909a4d6eab..c6735e2fd1f 100644 --- a/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts +++ b/tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' -const transient = (): Error => - new Error('Execution context was destroyed, most likely because of a navigation.') +const transientMessages = [ + 'Execution context was destroyed, most likely because of a navigation.', + 'electronApplication.evaluate: Resulting promise was garbage collected.' +] describe('retryTransientMainEvaluate', () => { it('returns the first successful read without retrying', async () => { @@ -16,13 +18,13 @@ describe('retryTransientMainEvaluate', () => { expect(calls).toBe(1) }) - it('rides out the startup window that made the paired-client launch flaky', async () => { + it.each(transientMessages)('retries a transient startup failure: %s', async (message) => { let calls = 0 await expect( retryTransientMainEvaluate(async () => { calls += 1 if (calls < 3) { - throw transient() + throw new Error(message) } return '/isolated/home' }) @@ -41,14 +43,30 @@ describe('retryTransientMainEvaluate', () => { expect(calls).toBe(1) }) - it('gives up rather than looping forever when the app never becomes evaluable', async () => { + it.each(transientMessages)( + 'bounds retries when evaluation keeps failing: %s', + async (message) => { + let calls = 0 + await expect( + retryTransientMainEvaluate(async () => { + calls += 1 + throw new Error(message) + }) + ).rejects.toThrow(message) + expect(calls).toBe(5) + } + ) + + it('does not retry a closed application', async () => { let calls = 0 await expect( retryTransientMainEvaluate(async () => { calls += 1 - throw transient() + throw new Error( + 'electronApplication.evaluate: Target page, context or browser has been closed' + ) }) - ).rejects.toThrow(/Execution context was destroyed/) - expect(calls).toBe(5) + ).rejects.toThrow(/has been closed/) + expect(calls).toBe(1) }) }) diff --git a/tests/e2e/helpers/electron-process-shutdown.ts b/tests/e2e/helpers/electron-process-shutdown.ts index f9b642a676e..5d78b761b96 100644 --- a/tests/e2e/helpers/electron-process-shutdown.ts +++ b/tests/e2e/helpers/electron-process-shutdown.ts @@ -157,11 +157,20 @@ async function forceKillProcessTree(proc: ChildProcess): Promise { * Use `closeElectronAppForE2E` for an ordinary quit — this exists for specs that need a client to * vanish without unwinding its sockets or subscriptions. */ -export async function forceQuitElectronAppForE2E(app: ElectronApplication): Promise { +export async function forceQuitElectronAppForE2E( + app: ElectronApplication, + options: { preserveDaemons?: boolean } = {} +): Promise { const proc = app.process() const pid = proc.pid if (pid) { - if (process.platform === 'win32') { + if (options.preserveDaemons) { + // Chromium helpers hold Windows profile handles; Electron's list excludes detached daemons. + const appPids = await app.evaluate(({ app }) => app.getAppMetrics().map(({ pid }) => pid)) + for (const targetPid of new Set([pid, ...appPids])) { + killPid(targetPid, 'SIGKILL') + } + } else if (process.platform === 'win32') { try { execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' @@ -178,8 +187,12 @@ export async function forceQuitElectronAppForE2E(app: ElectronApplication): Prom } await waitForExit(proc, PROCESS_EXIT_TIMEOUT_MS) releaseExitedProcessPipes(proc) - // Hands the dead app back to Playwright so worker teardown has nothing left to wait on. - await app.close().catch(() => undefined) + // Playwright close can remain pending after an external force-kill. + await withTimeout( + app.close(), + PROCESS_EXIT_TIMEOUT_MS, + 'Timed out releasing killed Electron app' + ).catch(() => undefined) } export async function closeElectronAppForE2E(app: ElectronApplication): Promise { diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 2560fde154d..8be4adb79f8 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { createServer } from 'node:net' import os from 'node:os' import path from 'node:path' +import { runProcess, type ProcessResult } from '../../../src/shared/child-process/run-process' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' @@ -51,6 +52,7 @@ type RestartSession = { userDataDir: string seedCodexResumeRollout: (sessionId: string, cwd: string) => string launch: (options?: LaunchOptions) => Promise + launchUntilExit: (executablePath: string) => Promise /** Gracefully close a launch, letting beforeunload flush session state. */ close: (app: ElectronApplication) => Promise /** Remove the shared userDataDir after the test is done. */ @@ -190,23 +192,45 @@ export function createRestartSession( } try { const resolvedHome = await retryTransientMainEvaluate(() => - app.evaluate(({ app }) => app.getPath('home')) + app.evaluate(({ app }) => { + // This fixture owns every launch; native relaunch leaves an unattached Playwright child. + app.relaunch = () => {} + return app.getPath('home') + }) ) assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation) + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + return { app, page } } catch (error) { await closeElectronAppForE2E(app) throw error } - const page = await app.firstWindow({ timeout: 120_000 }) - await page.waitForLoadState('domcontentloaded') - await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) - return { app, page } } const close = async (app: ElectronApplication): Promise => { await closeElectronAppForE2E(app) } + // Startup refusals exit before a renderer exists; capture their output from process creation. + const launchUntilExit = async (executablePath: string): Promise => { + runtimeWsPort ??= await reserveRestartRuntimeWsPort() + return runProcess({ + program: executablePath, + args: getOrcaElectronLaunchArgs(mainPath, false), + env: { + ...homeIsolation.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_E2E_HEADLESS: '1', + ORCA_E2E_RUNTIME_WS_PORT: String(runtimeWsPort) + }, + timeoutMs: 30_000, + detached: process.platform !== 'win32', + terminationBarrier: true + }) + } + const dispose = async (): Promise => { await cleanupE2EDaemons(userDataDir) if (process.env.ORCA_E2E_PRESERVE_RESTART_PROFILE === '1') { @@ -218,7 +242,7 @@ export function createRestartSession( } } - return { userDataDir, seedCodexResumeRollout, launch, close, dispose } + return { userDataDir, seedCodexResumeRollout, launch, launchUntilExit, close, dispose } } /** diff --git a/tests/e2e/helpers/persisted-profile-state.ts b/tests/e2e/helpers/persisted-profile-state.ts new file mode 100644 index 00000000000..02e00a89873 --- /dev/null +++ b/tests/e2e/helpers/persisted-profile-state.ts @@ -0,0 +1,60 @@ +import { join } from 'node:path' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from '../../../src/main/persistence/profile-state/profile-state-access' +import { openProfileStateDatabaseReadOnly } from '../../../src/main/persistence/profile-state/profile-state-database' +import { readProfileStateParsedSnapshot } from '../../../src/main/persistence/profile-state/profile-state-documents' +import { parseProfileStateRoot } from '../../../src/main/persistence/profile-state/profile-state-document-validation' +import { ProfileStateSqliteAuthority } from '../../../src/main/persistence/profile-state/profile-state-sqlite-authority' + +/** Read committed storage without consulting a retained migration JSON snapshot. */ +export function readPersistedProfileState( + userDataDir: string, + profileId = DEFAULT_LOCAL_ORCA_PROFILE_ID +): Record { + const admission = acquireProfileStateRuntimeAdmission(userDataDir) + try { + const opened = openProfileStateDatabaseReadOnly( + join(userDataDir, 'profiles', profileId, 'profile-state.db'), + profileId + ) + try { + return readProfileStateParsedSnapshot(opened.db).state + } finally { + opened.db.close() + } + } finally { + admission.release() + } +} + +/** Seed restart artifacts only while no runtime or other fixture writer owns the profile. */ +export function mutateStoppedProfileState( + userDataDir: string, + mutate: (state: Record) => T, + profileId = DEFAULT_LOCAL_ORCA_PROFILE_ID +): T { + const maintenance = acquireProfileStateMaintenance(userDataDir) + const authority = new ProfileStateSqliteAuthority( + join(userDataDir, 'profiles', profileId, 'profile-state.db'), + profileId + ) + try { + const serialized = authority.readSerializedState() + if (serialized === undefined) { + throw new Error('Expected an established profile before seeding restart state') + } + const state = parseProfileStateRoot(serialized) + const result = mutate(state) + authority.writeSerializedState(Buffer.from(JSON.stringify(state))) + return result + } finally { + try { + authority.close() + } finally { + maintenance.release() + } + } +} diff --git a/tests/e2e/helpers/persisted-profile-state.unit.test.ts b/tests/e2e/helpers/persisted-profile-state.unit.test.ts new file mode 100644 index 00000000000..50588174d27 --- /dev/null +++ b/tests/e2e/helpers/persisted-profile-state.unit.test.ts @@ -0,0 +1,71 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { openProfileStateDatabase } from '../../../src/main/persistence/profile-state/profile-state-database' +import { importProfileStateJson } from '../../../src/main/persistence/profile-state/profile-state-documents' +import { acquireProfileStateRuntimeAdmission } from '../../../src/main/persistence/profile-state/profile-state-access' +import { mutateStoppedProfileState, readPersistedProfileState } from './persisted-profile-state' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-persisted-test-state-')) + roots.push(root) + const directory = join(root, 'profiles', 'local-default') + mkdirSync(directory, { recursive: true }) + const databaseFile = join(directory, 'profile-state.db') + const dataFile = join(directory, 'orca-data.json') + const state = { settings: { sealed: 'ciphertext' }, unknown: { kept: null, output: '\ud800' } } + const opened = openProfileStateDatabase(databaseFile, 'local-default') + try { + importProfileStateJson(opened.db, JSON.stringify(state)) + } finally { + opened.db.close() + } + writeFileSync(dataFile, '{"retained":"old migration snapshot"}') + return { root, databaseFile, dataFile, state } +} + +it('reads committed SQLite and changes stopped fixtures without rewriting retained JSON', () => { + const item = fixture() + const before = readFileSync(item.dataFile) + expect(readPersistedProfileState(item.root)).toEqual(item.state) + const result = mutateStoppedProfileState(item.root, (state) => { + expect(() => acquireProfileStateRuntimeAdmission(item.root)).toThrow() + state.fixture = { changed: true } + return 'complete' + }) + expect(result).toBe('complete') + expect(readPersistedProfileState(item.root)).toEqual({ + ...item.state, + fixture: { changed: true } + }) + expect(readFileSync(item.dataFile)).toEqual(before) +}) + +it('refuses a fixture write while a runtime is admitted and releases the refused owner', () => { + const item = fixture() + const runtime = acquireProfileStateRuntimeAdmission(item.root) + try { + expect(() => mutateStoppedProfileState(item.root, () => {})).toThrow() + expect(readPersistedProfileState(item.root)).toEqual(item.state) + } finally { + runtime.release() + } + expect(() => mutateStoppedProfileState(item.root, () => {})).not.toThrow() +}) + +it('does not create missing authority when a test points at the wrong profile', () => { + const item = fixture() + rmSync(item.databaseFile) + expect(() => mutateStoppedProfileState(item.root, () => {})).toThrow() + expect(existsSync(item.databaseFile)).toBe(false) + const runtime = acquireProfileStateRuntimeAdmission(item.root) + runtime.release() +}) diff --git a/tests/e2e/helpers/ssh-reconnect-failure-observation.ts b/tests/e2e/helpers/ssh-reconnect-failure-observation.ts new file mode 100644 index 00000000000..4a1dd4f9d58 --- /dev/null +++ b/tests/e2e/helpers/ssh-reconnect-failure-observation.ts @@ -0,0 +1,120 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetControlCommand, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' +import { getTerminalContent, readPaneIdentitySnapshot } from './terminal-pane-identity' + +export async function attachSshReconnectFailureObservation( + page: Page, + testInfo: TestInfo, + target: DockerSshRelayTarget | null, + targetId: string | null, + originalPtyId: string | null +): Promise { + const observations: Record = { originalPtyId, targetId } + const observe = async (name: string, read: () => unknown): Promise => { + let timer: ReturnType | undefined + try { + observations[name] = await Promise.race([ + Promise.resolve().then(read), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Observation timed out')), 5_000) + }) + ]) + } catch (error) { + observations[name] = { error: String(error) } + } finally { + clearTimeout(timer) + } + } + await Promise.all([ + observe('paneIdentity', () => readPaneIdentitySnapshot(page)), + observe('renderedContent', () => getTerminalContent(page, 8000)), + observe('renderer', () => + page.evaluate((targetId) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + return { + authority: targetId ? state?.sshConnectionStates.get(targetId) : null, + worktreeId, + activeTabId: state?.activeTabId, + tabs: worktreeId + ? state?.tabsByWorktree[worktreeId]?.map(({ id, title }) => ({ id, title })) + : null, + panes: [...(window.__paneManagers?.entries() ?? [])].map(([tabId, manager]) => ({ + tabId, + diagnostics: manager.getRenderingDiagnostics() + })) + } + }, targetId) + ), + observe('pty', () => + page.evaluate(async (originalPtyId) => { + const ids = new Set(originalPtyId ? [originalPtyId] : []) + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes()) { + const id = pane.container.dataset.ptyId + if (id) { + ids.add(id) + } + } + } + const read = async (request: Promise): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + request, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('PTY observation timed out')), 3_000) + }) + ]) + } catch (error) { + return { error: String(error) } + } finally { + clearTimeout(timer) + } + } + const [delivery, processes] = await Promise.all([ + read(window.api.pty.getRendererDeliveryDebugSnapshot()), + Promise.all( + [...ids].map(async (id) => { + const [process, buffer] = await Promise.all([ + read(window.api.pty.inspectProcess(id, { scanChildProcesses: true })), + read( + window.api.pty.getMainBufferSnapshot(id, { scrollbackRows: 40 }).then((buffer) => + buffer + ? { + source: buffer.source, + seq: buffer.seq, + alternateScreen: buffer.alternateScreen, + cols: buffer.cols, + rows: buffer.rows, + data: buffer.data.slice(-8000), + scrollbackAnsi: buffer.scrollbackAnsi?.slice(-8000) + } + : null + ) + ) + ]) + return { id, process, buffer } + }) + ) + ]) + return { delivery, processes } + }, originalPtyId) + ) + ]) + if (target) { + await observe('remoteProcesses', () => + execDockerSshRelayTargetControlCommand( + target, + 'ps -eo pid,ppid,pgid,sid,tpgid,stat,comm,args' + ) + ) + } + await testInfo.attach('ssh-reconnect-failure.json', { + body: JSON.stringify(observations, null, 2), + contentType: 'application/json' + }) +} diff --git a/tests/e2e/helpers/terminal-restart-persistence.ts b/tests/e2e/helpers/terminal-restart-persistence.ts new file mode 100644 index 00000000000..46ba4efda9f --- /dev/null +++ b/tests/e2e/helpers/terminal-restart-persistence.ts @@ -0,0 +1,186 @@ +import { readFileSync, existsSync } from 'node:fs' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './orca-app' +import { TEST_REPO_PATH_FILE } from '../global-setup' +import { attachRepoAndOpenTerminal } from './orca-restart' +import { discoverActivePtyId, waitForActiveTerminalManager, waitForPaneCount } from './terminal' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + getActiveTabId, + ensureTerminalVisible +} from './store' + +const REQUIRE_WINDOWS_TERMINAL_RESTART_E2E = + process.env.ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E === '1' +const MISSING_SEEDED_REPO_MESSAGE = 'Global setup did not produce a seeded test repo' + +export function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + const unavailable = !repoPath || !existsSync(repoPath) + if (unavailable && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { + throw new Error('Required Windows restart E2E seeded repo is unavailable') + } + test.skip(unavailable, MISSING_SEEDED_REPO_MESSAGE) + return repoPath +} + +/** + * Shared bootstrap for a *first* launch: attach the seeded test repo, + * activate its worktree, ensure a terminal is mounted, and return the + * PTY id we can drive with `execInTerminal`. + * + * Why: every test in this file needs the exact same starting state on the + * first launch. Inlining it would obscure the thing each test is actually + * asserting about the *second* launch. + */ +export async function bootstrapFirstLaunch( + page: Page, + repoPath: string +): Promise<{ worktreeId: string; ptyId: string }> { + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + + const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) + .then(() => true) + .catch(() => false) + if (!hasPaneManager && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { + throw new Error('Required Windows restart E2E TerminalPane manager did not mount') + } + test.skip( + !hasPaneManager, + 'Electron automation in this environment never mounts the TerminalPane manager, so restart-persistence assertions would only fail on harness setup.' + ) + await waitForPaneCount(page, 1, 30_000) + + const ptyId = await discoverActivePtyId(page) + return { worktreeId, ptyId } +} + +/** + * Shared bootstrap for a *second* launch: just wait for the session to + * restore, and confirm the previously-active worktree is the active one + * again so downstream assertions operate against the right worktree. + */ +export async function bootstrapRestoredLaunch( + page: Page, + expectedWorktreeId: string +): Promise { + await waitForSessionReady(page) + await expect + .poll(async () => getActiveWorktreeId(page), { timeout: 10_000 }) + .toBe(expectedWorktreeId) + await ensureTerminalVisible(page) + // Why: the PaneManager remounts asynchronously after session hydration. The + // restored terminal surface is what we're about to assert against, so make + // sure it exists before any content/layout assertion races. + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +export async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise { + const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = + process.platform === 'win32' ? ['Control'] : [] + await page + .locator('.xterm:visible') + .first() + .click({ button: 'right', position: { x: 40, y: 40 }, modifiers }) + await page.getByText('Set Title…', { exact: true }).click() + const titleInput = page.locator('.pane-title-input').first() + await expect(titleInput).toBeVisible() + await titleInput.fill(title) + await titleInput.press('Enter') +} + +export async function getTabCustomTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + return page.evaluate( + ({ targetWorktreeId, targetTabId }) => { + const state = window.__store!.getState() + const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find( + (entry) => entry.id === targetTabId + ) + return tab?.customTitle ?? null + }, + { targetWorktreeId: worktreeId, targetTabId: tabId } + ) +} + +export async function readTerminalActiveLine(page: Page): Promise { + const tabId = await getActiveTabId(page) + if (!tabId) { + return null + } + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const buffer = pane?.terminal?.buffer.active + if (!buffer) { + return null + } + const cursorLine = buffer.baseY + buffer.cursorY + return buffer.getLine(cursorLine)?.translateToString(true) ?? null + }, tabId) +} + +export async function waitForTerminalActiveLine(page: Page, expectedText: string): Promise { + await expect + .poll(async () => (await readTerminalActiveLine(page))?.includes(expectedText), { + timeout: 15_000, + message: `Terminal cursor line did not contain "${expectedText}"` + }) + .toBe(true) + + const activeLine = await readTerminalActiveLine(page) + if (activeLine === null) { + throw new Error('Terminal cursor line disappeared after settling') + } + return activeLine +} + +export async function waitForElectronProcessExit(app: ElectronApplication): Promise { + const process = app.process() + if (process.exitCode !== null || process.signalCode !== null) { + return + } + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + process.off('exit', onExit) + reject(new Error('Electron profile-switch relaunch did not exit')) + }, 15_000) + const onExit = (): void => { + clearTimeout(timeout) + process.off('exit', onExit) + resolve() + } + process.once('exit', onExit) + }) +} + +export async function expectSavedLayoutToContainTitle( + page: Page, + tabId: string, + title: string +): Promise { + await expect + .poll( + () => + page.evaluate( + ({ targetTabId, title }) => { + const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId] + return Object.values(layout?.titlesByLeafId ?? {}).includes(title) + }, + { targetTabId: tabId, title } + ), + { timeout: 3_000 } + ) + .toBe(true) +} diff --git a/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts index 73527afa50d..28d8f5565df 100644 --- a/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts @@ -1,3 +1,4 @@ +import { readPersistedProfileState } from './helpers/persisted-profile-state' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -17,7 +18,6 @@ import { DaemonClient } from '../../src/main/daemon/client' import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner' import Database from '../../src/main/sqlite/sync-database' import { LEGACY_CONTRACT_VERSION } from '../../src/main/runtime/orchestration/db' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' import { buildFakeAgentCommandOverride, @@ -144,12 +144,9 @@ async function detachedDaemonSessionExists(userDataDir: string, ptyId: string): } } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function hasPersistedResumeRecord(userDataDir: string, paneKey: string): boolean { - const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as { workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } diff --git a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts index 68c508eca62..a7090c0fd92 100644 --- a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts @@ -1,3 +1,7 @@ +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -21,7 +25,6 @@ import { LEGACY_CONTRACT_VERSION, LEGACY_RUN_ID } from '../../src/main/runtime/orchestration/db' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' import { listAllOrchestrationRuns } from './orchestration-run-pages' import { @@ -231,12 +234,9 @@ function isProcessAlive(pid: number): boolean { } } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function readPersistedData(userDataDir: string): PersistedData { - return JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + return readPersistedProfileState(userDataDir) as PersistedData } function hasPersistedResumeRecord(userDataDir: string, paneKey: string): boolean { @@ -273,42 +273,44 @@ function stripLegacyWorkerRendererBinding( workerPaneKey: string } ): void { - const data = readPersistedData(userDataDir) - const session = data.workspaceSession - if (!session) { - throw new Error('Expected a persisted workspace session') - } - const sleeping = session.sleepingAgentSessionsByPaneKey?.[input.workerPaneKey] - if (sleeping?.providerSession?.id !== PROVIDER_SESSION_ID) { - throw new Error('Expected the legacy worker resume record before removing its tab binding') - } - session.tabsByWorktree = { - ...session.tabsByWorktree, - [input.worktreeId]: (session.tabsByWorktree?.[input.worktreeId] ?? []).filter( - (tab) => tab.id !== input.workerTabId - ) - } - delete session.terminalLayoutsByTabId?.[input.workerTabId] - if (session.unifiedTabs?.[input.worktreeId]) { - session.unifiedTabs[input.worktreeId] = session.unifiedTabs[input.worktreeId].filter( - (tab) => tab.id !== input.workerTabId && tab.entityId !== input.workerTabId - ) - } - for (const group of session.tabGroups?.[input.worktreeId] ?? []) { - group.tabOrder = group.tabOrder.filter((tabId) => tabId !== input.workerTabId) - group.recentTabIds = group.recentTabIds?.filter((tabId) => tabId !== input.workerTabId) - if (group.activeTabId === input.workerTabId) { - group.activeTabId = input.coordinatorTabId + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const session = data.workspaceSession + if (!session) { + throw new Error('Expected a persisted workspace session') } - } - session.activeTabId = input.coordinatorTabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [input.worktreeId]: input.coordinatorTabId - } - delete session.terminalPtyIncarnationsByPaneKey?.[input.workerPaneKey] - delete session.terminalSurfaceTombstonesByPaneKey?.[input.workerPaneKey] - writeFileSync(persistedDataPath(userDataDir), `${JSON.stringify(data, null, 2)}\n`, 'utf8') + const sleeping = session.sleepingAgentSessionsByPaneKey?.[input.workerPaneKey] + if (sleeping?.providerSession?.id !== PROVIDER_SESSION_ID) { + throw new Error('Expected the legacy worker resume record before removing its tab binding') + } + session.tabsByWorktree = { + ...session.tabsByWorktree, + [input.worktreeId]: (session.tabsByWorktree?.[input.worktreeId] ?? []).filter( + (tab) => tab.id !== input.workerTabId + ) + } + delete session.terminalLayoutsByTabId?.[input.workerTabId] + if (session.unifiedTabs?.[input.worktreeId]) { + session.unifiedTabs[input.worktreeId] = session.unifiedTabs[input.worktreeId].filter( + (tab) => tab.id !== input.workerTabId && tab.entityId !== input.workerTabId + ) + } + for (const group of session.tabGroups?.[input.worktreeId] ?? []) { + group.tabOrder = group.tabOrder.filter((tabId) => tabId !== input.workerTabId) + group.recentTabIds = group.recentTabIds?.filter((tabId) => tabId !== input.workerTabId) + if (group.activeTabId === input.workerTabId) { + group.activeTabId = input.coordinatorTabId + } + } + session.activeTabId = input.coordinatorTabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [input.worktreeId]: input.coordinatorTabId + } + delete session.terminalPtyIncarnationsByPaneKey?.[input.workerPaneKey] + delete session.terminalSurfaceTombstonesByPaneKey?.[input.workerPaneKey] + }) } function assertDispatchRemainsCurrent( diff --git a/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts b/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts index b9cd2b35dc7..bc6514a3904 100644 --- a/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts +++ b/tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts @@ -1,4 +1,5 @@ -import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { readdirSync, existsSync } from 'node:fs' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' import path from 'node:path' import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' @@ -35,61 +36,44 @@ const RECONNECT_GRACE_OVERSHOOT_MS = 20_000 * nothing on the host answers for. */ function forgetPersistedClientHostedPages(userDataDir: string): number { - return listOrcaDataFiles(userDataDir).reduce( - (total, dataFile) => total + forgetPersistedClientHostedPagesIn(dataFile), - 0 - ) -} - -/** - * Every orca-data.json under a user-data dir. - * - * The live one is `profiles//orca-data.json`; the root file is only the harness's onboarding - * seed, which the first boot migrates from. Reading the seed alone made the strip a no-op that - * looked exactly like a runtime that had persisted nothing. - */ -function listOrcaDataFiles(userDataDir: string): string[] { const profilesDir = path.join(userDataDir, 'profiles') - let profileFiles: string[] = [] - try { - profileFiles = readdirSync(profilesDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(profilesDir, entry.name, 'orca-data.json')) - } catch { - // No profile directory yet; only the harness seed exists. - } - return [path.join(userDataDir, 'orca-data.json'), ...profileFiles].filter((file) => { - try { - readFileSync(file, 'utf8') - return true - } catch { - return false - } - }) -} - -function forgetPersistedClientHostedPagesIn(dataFile: string): number { - const state = JSON.parse(readFileSync(dataFile, 'utf8')) as { - workspaceSession?: { clientHostedBrowserPagesByWorktree?: Record } - workspaceSessionsByHostId?: Record< - string, - { clientHostedBrowserPagesByWorktree?: Record } - > - } - let forgotten = 0 - for (const session of [ - state.workspaceSession, - ...Object.values(state.workspaceSessionsByHostId ?? {}) - ]) { - const rows = session?.clientHostedBrowserPagesByWorktree - if (!rows) { - continue - } - forgotten += Object.values(rows).reduce((total, list) => total + list.length, 0) - delete session.clientHostedBrowserPagesByWorktree - } - writeFileSync(dataFile, `${JSON.stringify(state, null, 2)}\n`) - return forgotten + return readdirSync(profilesDir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && existsSync(path.join(profilesDir, entry.name, 'profile-state.db')) + ) + .reduce( + (total, entry) => + total + + mutateStoppedProfileState( + userDataDir, + (raw) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const state = raw as { + workspaceSession?: { clientHostedBrowserPagesByWorktree?: Record } + workspaceSessionsByHostId?: Record< + string, + { clientHostedBrowserPagesByWorktree?: Record } + > + } + let forgotten = 0 + for (const session of [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}) + ]) { + const rows = session?.clientHostedBrowserPagesByWorktree + if (!rows) { + continue + } + forgotten += Object.values(rows).reduce((count, list) => count + list.length, 0) + delete session.clientHostedBrowserPagesByWorktree + } + return forgotten + }, + entry.name + ), + 0 + ) } /** diff --git a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts index 5d83bef49f4..608c31111af 100644 --- a/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts @@ -21,18 +21,18 @@ * the remote host's partition never received the capture (#21295). This is the test that fails * with the fix reverted. * - * The on-disk reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId` + * The SQLite reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId` * partition, and names the partition each reading came from — the issue's original "onDisk: 0" was a * reader that inspected only the local blob while the capture sat in the runtime partition, a - * reading that could not contradict itself. An empty list means no session file at all (a deleted - * profile), distinguished from an empty buffer. + * reading that could not contradict itself. A missing database fails the read; an empty list means + * no session partitions were persisted, distinguished from an empty buffer. * * Run: * pnpm exec playwright test \ * tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts \ * --config tests/playwright.config.ts --project electron-headless --workers=1 */ -import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -49,10 +49,12 @@ import { callEnvironment, createPairedHostTerminal, openPairedClientTab, - waitForPairedPaneMarker + waitForPairedPaneMarker, + type PairedHostTerminal } from './helpers/paired-host-terminal' import { focusActiveTerminalInput } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' +import { readPersistedProfileState } from './helpers/persisted-profile-state' const PARK_DELAY_MS = 2_000 const PAINT_BUDGET_MS = 30_000 @@ -137,31 +139,21 @@ function stringRecord(value: unknown): Record | undefined { ) } -/** Walks the local `workspaceSession` and every `workspaceSessionsByHostId` partition. An empty - * list means no session file was found at all — a reader problem, not an empty buffer. */ +/** Read committed session partitions without consulting the retained compatibility export. */ function readOnDiskPartitions(userDataDir: string, webTabId: string): OnDiskPartitionReading[] { + const state = readPersistedProfileState(userDataDir) const readings: OnDiskPartitionReading[] = [] - for (const file of globSync(path.join(userDataDir, '**', 'orca-data.json'))) { - try { - const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) - if (!isRecord(parsed)) { - continue - } - const local = readSessionPartition('local', parsed.workspaceSession, webTabId) - if (local) { - readings.push(local) - } - const partitions = isRecord(parsed.workspaceSessionsByHostId) - ? parsed.workspaceSessionsByHostId - : {} - for (const [hostId, session] of Object.entries(partitions)) { - const reading = readSessionPartition(hostId, session, webTabId) - if (reading) { - readings.push(reading) - } - } - } catch { - // A partially written profile is itself a datapoint; keep scanning the rest. + const local = readSessionPartition('local', state.workspaceSession, webTabId) + if (local) { + readings.push(local) + } + const partitions = isRecord(state.workspaceSessionsByHostId) + ? state.workspaceSessionsByHostId + : {} + for (const [hostId, session] of Object.entries(partitions)) { + const reading = readSessionPartition(hostId, session, webTabId) + if (reading) { + readings.push(reading) } } return readings @@ -258,7 +250,7 @@ async function parkRemoteTerminalWithToken( fixtureCommand() ) createdTerminals.push(target.terminal) - const decoys = [] + const decoys: PairedHostTerminal[] = [] for (let index = 0; index < 2; index += 1) { const decoy = await createPairedHostTerminal( client.page, @@ -428,7 +420,7 @@ test.describe('host retains nothing', () => { expect({ tokenBeforePark: parked.tokenBeforePark, capturedAtPark: parked.storeAtPark > 0, - // Distinguishes a deleted profile (no partitions) from an empty buffer. + // Distinguishes missing session partitions from an empty buffer. profileSurvived: onDiskAfterQuit.length > 0, runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterQuit) > 0, localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterQuit) <= 0 diff --git a/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts index b9d2242de22..7047f8a6dcb 100644 --- a/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts +++ b/tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts @@ -8,11 +8,13 @@ * tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts \ * --config tests/playwright.config.ts --project electron-headless --workers=1 */ -import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { getHistorySessionDirName } from '../../src/main/daemon/history-paths' import { LOG_HEADER_BYTES } from '../../src/main/daemon/terminal-history-log' +import { profileStateDatabaseFile } from '../../src/main/persistence/profile-state/profile-state-database' +import { ProfileStateSqliteAuthority } from '../../src/main/persistence/profile-state/profile-state-sqlite-authority' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { toRemoteRuntimePtyId } from '../../src/shared/remote-runtime-pty-id' @@ -87,6 +89,80 @@ test.afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) +test('SQLite candidate retains a remote automation run across serve restart', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + + const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + const automation = await host.client.call<{ automation: { id: string } }>('automation.create', { + agentId: 'codex', + name: `remote-sqlite-restart-${Date.now()}`, + prompt: 'Retain this remote automation run through a SQLite-only serve restart.', + repo: `id:${added.result.repo.id}`, + runContext: { + kind: 'workspace-run', + projectId: added.result.repo.id, + hostId: 'runtime:missing', + projectHostSetupId: `missing-${Date.now()}`, + repoId: added.result.repo.id, + path: testRepoPath + }, + workspaceMode: 'new_per_run', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: Date.now(), + enabled: false, + missedRunGraceMinutes: 720 + }) + const run = await host.client.call<{ run: { id: string; status: string } }>( + 'automation.runNow', + { id: automation.result.automation.id } + ) + expect(['dispatching', 'dispatched']).toContain(run.result.run.status) + + const beforeRestart = await host.client.call<{ runs: { id: string }[] }>('automation.runs', { + automationId: automation.result.automation.id + }) + expect(beforeRestart.result.runs.some((entry) => entry.id === run.result.run.id)).toBe(true) + + const profileDirectory = path.join(host.userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const profileJsonPath = path.join(profileDirectory, 'orca-data.json') + const rootJsonPath = path.join(host.userDataDir, 'orca-data.json') + const databasePath = profileStateDatabaseFile(profileDirectory) + expect(existsSync(databasePath)).toBe(true) + await host.restartServeProcess({ + betweenProcesses: () => { + rmSync(profileJsonPath, { force: true }) + rmSync(rootJsonPath, { force: true }) + } + }) + expect(existsSync(profileJsonPath)).toBe(false) + expect(existsSync(rootJsonPath)).toBe(false) + + const afterRestartDefinitions = await host.client.call<{ + automations: { id: string }[] + }>('automation.list') + expect( + afterRestartDefinitions.result.automations.some( + (entry) => entry.id === automation.result.automation.id + ) + ).toBe(true) + const afterRestart = await host.client.call<{ runs: { id: string }[] }>('automation.runs', { + automationId: automation.result.automation.id + }) + expect(afterRestart.result.runs.some((entry) => entry.id === run.result.run.id)).toBe(true) + } finally { + await host.dispose() + } +}) + type HostSurface = { leafId: string parentTabId: string @@ -125,14 +201,35 @@ function removePersistedTerminalBinding( terminal: Pick ): void { const dataPath = persistedDataPath(userDataDir) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData - const bindings = - data.workspaceSession?.terminalLayoutsByTabId?.[terminal.parentTabId]?.ptyIdsByLeafId - if (bindings?.[terminal.leafId] !== terminal.ptyId) { - throw new Error('Expected the live terminal binding before removing it from persisted state') + const authority = new ProfileStateSqliteAuthority( + profileStateDatabaseFile(path.dirname(dataPath)), + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + try { + const serialized = authority.readSerializedState() + if (!serialized) { + throw new Error('Expected established SQLite state before removing terminal binding') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SQLite authority validates the complete storage snapshot before returning it. + const session = JSON.parse(serialized) as PersistedData + const layout = session.workspaceSession?.terminalLayoutsByTabId?.[terminal.parentTabId] + const bindings = layout?.ptyIdsByLeafId + if (!layout || !bindings || bindings[terminal.leafId] !== terminal.ptyId) { + throw new Error('Expected the live terminal binding before removing it from SQLite state') + } + const nextBindings = { ...bindings } + delete nextBindings[terminal.leafId] + session.workspaceSession = { + ...session.workspaceSession, + terminalLayoutsByTabId: { + ...session.workspaceSession?.terminalLayoutsByTabId, + [terminal.parentTabId]: { ...layout, ptyIdsByLeafId: nextBindings } + } + } + authority.writeSerializedState(Buffer.from(JSON.stringify(session))) + } finally { + authority.close?.() } - delete bindings[terminal.leafId] - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') } function readHistoryLogEvidence(outputLogPath: string, marker: string): HistoryLogEvidence { diff --git a/tests/e2e/persisted-session-production-upgrade.spec.ts b/tests/e2e/persisted-session-production-upgrade.spec.ts index 3f63366b25f..dbfba16936e 100644 --- a/tests/e2e/persisted-session-production-upgrade.spec.ts +++ b/tests/e2e/persisted-session-production-upgrade.spec.ts @@ -1,12 +1,29 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { + _electron as electron, + type ElectronApplication, + type Page, + type TestInfo +} from '@stablyai/playwright-test' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' -import { test, expect } from './helpers/orca-app' +import { forwardElectronProcessLogs, test, expect } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { getElectronIsolatedKeychainArgs } from './helpers/electron-launch-args' +import { + areSameHomePath, + assertElectronResolvedIsolatedHome, + createElectronHomeIsolation +} from './helpers/electron-home-isolation' import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { openProfileStateDatabaseReadOnly } from '../../src/main/persistence/profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../../src/main/persistence/profile-state/profile-state-documents' +import { ProfileStateSqliteAuthority } from '../../src/main/persistence/profile-state/profile-state-sqlite-authority' +import { acquireProfileStateMaintenance } from '../../src/main/persistence/profile-state/profile-state-access' +import { restoreProfileStateJsonExport } from '../../src/main/persistence/profile-state/profile-state-recovery' import { discoverActivePtyId, execInTerminal, @@ -26,6 +43,7 @@ const FIXTURE_PATH = path.join( ) // This fixture captures a legacy production schema boundary; the test runs the current build. const RESTORED_TITLE = 'Production agent session' +const PACKAGED_OLD_EXECUTABLE_ENV = 'ORCA_PROFILE_STATE_PACKAGED_OLD_EXECUTABLE' type FixtureSession = { _fixtureProvenance?: unknown @@ -75,6 +93,125 @@ function installProductionSessionFixture( writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`) } +function materializeLegacyProfileJson(userDataDir: string): void { + const profileDirectory = path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const dataPath = path.join(profileDirectory, 'orca-data.json') + const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + writeFileSync(dataPath, `${readProfileStateSnapshot(opened.db).json}\n`) + } finally { + opened.db.close() + } +} + +function publishLegacyCompatibilitySnapshot(userDataDir: string): string { + const profileDirectory = path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const dataPath = path.join(profileDirectory, 'orca-data.json') + const databasePath = path.join(profileDirectory, 'profile-state.db') + const authority = new ProfileStateSqliteAuthority(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + authority.readSerializedState() + const revision = authority.writeJsonCompatibilityExport(dataPath) + if (revision === undefined) { + throw new Error('Expected the candidate profile to have a persisted revision') + } + return dataPath + } finally { + authority.close() + } +} + +function restoreLegacyProfileJson(userDataDir: string): void { + const profileDirectory = path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const dataFile = path.join(profileDirectory, 'orca-data.json') + const maintenance = acquireProfileStateMaintenance(userDataDir) + try { + restoreProfileStateJsonExport({ + maintenance, + databasePath, + dataFile, + exportPath: dataFile, + profileId: DEFAULT_LOCAL_ORCA_PROFILE_ID + }) + } finally { + maintenance.release() + } +} + +async function launchPackagedOldProfile(args: { + executablePath: string + userDataDir: string + testInfo: TestInfo +}): Promise<{ app: ElectronApplication; page: Page }> { + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + const homeIsolation = createElectronHomeIsolation({ + inheritedEnv: cleanEnv, + launchEnv: {}, + extraEnv: {}, + userDataDir: args.userDataDir + }) + const app = await electron.launch({ + executablePath: args.executablePath, + args: [...getElectronIsolatedKeychainArgs(), `--user-data-dir=${args.userDataDir}`], + env: { + ...homeIsolation.env, + NODE_ENV: 'production', + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_E2E_HEADLESS: '1', + ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1' + } + }) + forwardElectronProcessLogs(app, args.testInfo) + try { + assertElectronResolvedIsolatedHome( + await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')), + homeIsolation + ) + const resolvedUserDataDir = await app.evaluate(({ app: electronApp }) => + electronApp.getPath('userData') + ) + if (!areSameHomePath(resolvedUserDataDir, args.userDataDir)) { + throw new Error('Packaged old build escaped the disposable user-data boundary') + } + await app.firstWindow({ timeout: 120_000 }) + let apiPage: Page | undefined + await expect + .poll( + async () => { + for (const candidate of app.windows()) { + const hasSettingsApi = await Promise.race([ + candidate.evaluate(() => Boolean(window.api?.settings?.get)).catch(() => false), + new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 2_000) + timeout.unref?.() + }) + ]) + if (hasSettingsApi) { + apiPage = candidate + return true + } + } + return false + }, + // Older packaged builds can spend longer in their first-run renderer bootstrap + // while the candidate daemon from the same test worker is shutting down. + { timeout: 120_000 } + ) + .toBe(true) + if (!apiPage) { + throw new Error('Packaged old build did not expose its renderer API') + } + await apiPage.waitForLoadState('domcontentloaded') + return { app, page: apiPage } + } catch (error) { + await closeElectronAppForE2E(app).catch(() => {}) + throw error + } +} + async function expectProductionSessionRestored( page: Page, expected: { marker: string; ptyId: string; repoId: string; worktreeId: string } @@ -147,7 +284,11 @@ test('upgrades a legacy daemon session and keeps it stable after relaunch', asyn await session.close(oldApp) oldApp = null + // Recreate a pre-cutover profile while retaining its live terminal identity. + materializeLegacyProfileJson(session.userDataDir) installProductionSessionFixture(session.userDataDir, repoId, worktreeId, ptyId) + await cleanupE2EDaemons(session.userDataDir) + restoreLegacyProfileJson(session.userDataDir) const currentLaunch = await session.launch() currentApp = currentLaunch.app @@ -169,3 +310,288 @@ test('upgrades a legacy daemon session and keeps it stable after relaunch', asyn await session.dispose() } }) + +// oxlint-disable-next-line no-empty-pattern -- This mixed-version test owns its Electron launches. +test('restores a JSON compatibility snapshot and migrates it on normal restart', async ({}, testInfo) => { + test.setTimeout(240_000) + + const session = createRestartSession(testInfo) + let candidateApp: ElectronApplication | null = null + let reupgradedApp: ElectronApplication | null = null + try { + const candidateLaunch = await session.launch() + candidateApp = candidateLaunch.app + await waitForSessionReady(candidateLaunch.page) + const marker = 17 + await candidateLaunch.page.evaluate(async (terminalFontSize) => { + const updateSettings = window.__store?.getState().updateSettingsOrThrow + if (!updateSettings) { + throw new Error('Candidate renderer did not expose settings persistence') + } + await updateSettings({ terminalFontSize }) + }, marker) + await expect + .poll( + () => + candidateLaunch.page.evaluate( + () => window.__store?.getState().settings?.terminalFontSize + ), + { timeout: 15_000 } + ) + .toBe(marker) + + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const databasePath = path.join(profileDirectory, 'profile-state.db') + await session.close(candidateApp) + candidateApp = null + const dataPath = publishLegacyCompatibilitySnapshot(session.userDataDir) + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(dataPath)).toBe(true) + + // Restore the exported JSON, then exercise normal first migration again. + await cleanupE2EDaemons(session.userDataDir) + restoreLegacyProfileJson(session.userDataDir) + expect(existsSync(databasePath)).toBe(false) + expect(JSON.parse(readFileSync(dataPath, 'utf8')).settings.terminalFontSize).toBe(marker) + const reupgradedLaunch = await session.launch() + reupgradedApp = reupgradedLaunch.app + await waitForSessionReady(reupgradedLaunch.page) + await expect + .poll( + () => + reupgradedLaunch.page.evaluate( + () => window.__store?.getState().settings?.terminalFontSize + ), + { timeout: 15_000 } + ) + .toBe(marker) + expect(existsSync(databasePath)).toBe(true) + } finally { + for (const app of [reupgradedApp, candidateApp]) { + if (app) { + await session.close(app).catch(() => {}) + } + } + await session.dispose() + } +}) + +test('real packaged old build reads compatibility JSON before candidate re-import', async ({ + browserName: _browserName +}, testInfo) => { + test.setTimeout(300_000) + const executablePath = process.env[PACKAGED_OLD_EXECUTABLE_ENV] + test.skip( + !executablePath || !existsSync(executablePath), + `${PACKAGED_OLD_EXECUTABLE_ENV} must point at an older packaged Orca executable` + ) + + const session = createRestartSession(testInfo) + let candidateApp: ElectronApplication | null = null + let oldApp: ElectronApplication | null = null + let reupgradedApp: ElectronApplication | null = null + try { + const candidateLaunch = await session.launch() + candidateApp = candidateLaunch.app + const marker = 19 + await candidateLaunch.page.evaluate(async (terminalFontSize) => { + const updateSettings = window.__store?.getState().updateSettingsOrThrow + if (!updateSettings) { + throw new Error('Candidate renderer did not expose settings persistence') + } + await updateSettings({ terminalFontSize }) + }, marker) + await expect + .poll(() => + candidateLaunch.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(marker) + await session.close(candidateApp) + candidateApp = null + + const databasePath = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'profile-state.db' + ) + const compatibilityPath = publishLegacyCompatibilitySnapshot(session.userDataDir) + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(compatibilityPath)).toBe(true) + await cleanupE2EDaemons(session.userDataDir) + // A pre-migration build reads the canonical JSON restored by rollback. + restoreLegacyProfileJson(session.userDataDir) + + const oldLaunch = await launchPackagedOldProfile({ + executablePath: executablePath!, + userDataDir: session.userDataDir, + testInfo + }) + oldApp = oldLaunch.app + await expect + .poll(() => oldLaunch.page.evaluate(() => window.api.settings.get())) + .toMatchObject({ + terminalFontSize: marker + }) + await closeElectronAppForE2E(oldApp) + oldApp = null + + const reupgradedLaunch = await session.launch() + reupgradedApp = reupgradedLaunch.app + await expect + .poll( + () => + reupgradedLaunch.page.evaluate( + () => window.__store?.getState().settings?.terminalFontSize + ), + { timeout: 30_000 } + ) + .toBe(marker) + expect(existsSync(databasePath)).toBe(true) + } finally { + for (const app of [reupgradedApp, oldApp, candidateApp]) { + if (app) { + await closeElectronAppForE2E(app).catch(() => {}) + } + } + await session.dispose() + } +}) + +test('fails closed when a packaged old build mutates live SQLite compatibility JSON', async ({ + browserName: _browserName +}, testInfo) => { + test.setTimeout(300_000) + const executablePath = process.env[PACKAGED_OLD_EXECUTABLE_ENV] + test.skip( + !executablePath || !existsSync(executablePath), + `${PACKAGED_OLD_EXECUTABLE_ENV} must point at an older packaged Orca executable` + ) + + const session = createRestartSession(testInfo) + let candidateApp: ElectronApplication | null = null + let oldApp: ElectronApplication | null = null + try { + const candidateLaunch = await session.launch() + candidateApp = candidateLaunch.app + const candidateExecutable = await candidateApp.evaluate(() => process.execPath) + await waitForSessionReady(candidateLaunch.page) + await candidateLaunch.page.evaluate(async () => { + const updateSettings = window.__store?.getState().updateSettingsOrThrow + if (!updateSettings) { + throw new Error('Candidate renderer did not expose settings persistence') + } + await updateSettings({ terminalFontSize: 19 }) + }) + await expect + .poll(() => + candidateLaunch.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(19) + await session.close(candidateApp) + candidateApp = null + + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const compatibilityPath = publishLegacyCompatibilitySnapshot(session.userDataDir) + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(compatibilityPath)).toBe(true) + + const oldLaunch = await launchPackagedOldProfile({ + executablePath: executablePath!, + userDataDir: session.userDataDir, + testInfo + }) + oldApp = oldLaunch.app + await expect + .poll(() => oldLaunch.page.evaluate(() => window.api.settings.get())) + .toMatchObject({ terminalFontSize: 19 }) + await oldLaunch.page.evaluate(async () => { + await window.api.settings.set({ terminalFontSize: 23 }) + }) + await expect + .poll(() => oldLaunch.page.evaluate(() => window.api.settings.get())) + .toMatchObject({ terminalFontSize: 23 }) + await closeElectronAppForE2E(oldApp) + oldApp = null + + const mutatedJson = JSON.parse(readFileSync(compatibilityPath, 'utf8')) + expect(mutatedJson).toMatchObject({ settings: { terminalFontSize: 23 } }) + expect(existsSync(databasePath)).toBe(true) + + const refused = await session.launchUntilExit(candidateExecutable) + expect(refused, refused.stderr).toMatchObject({ code: 1, signal: null, timedOut: false }) + expect(refused.stderr).toContain( + 'both JSON and SQLite storage without a matching acceptance marker' + ) + expect(existsSync(databasePath)).toBe(true) + const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + expect(JSON.parse(readProfileStateSnapshot(opened.db).json)).toMatchObject({ + settings: { terminalFontSize: 19 } + }) + } finally { + opened.db.close() + } + } finally { + for (const app of [oldApp, candidateApp]) { + if (app) { + await closeElectronAppForE2E(app).catch(() => {}) + } + } + await session.dispose() + } +}) + +// oxlint-disable-next-line no-empty-pattern -- This recovery test owns its Electron launches. +test('fails closed on a corrupt established SQLite profile and retains recovery evidence', async ({}, testInfo) => { + test.setTimeout(180_000) + + const session = createRestartSession(testInfo) + let app: ElectronApplication | null = null + try { + const initialLaunch = await session.launch() + app = initialLaunch.app + const candidateExecutable = await app.evaluate(() => process.execPath) + await waitForSessionReady(initialLaunch.page) + await session.close(app) + app = null + + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const dataFile = path.join(profileDirectory, 'orca-data.json') + const databaseFile = path.join(profileDirectory, 'profile-state.db') + const retainedExports = readdirSync(profileDirectory).filter((name) => + /^orca-data\.json\.sqlite-export\.\d+\.json$/.test(name) + ) + expect(retainedExports.length).toBeGreaterThan(0) + const jsonBeforeCorruption = readFileSync(dataFile) + + writeFileSync(databaseFile, 'corrupt profile-state database') + const refused = await session.launchUntilExit(candidateExecutable) + expect(refused, refused.stderr).toMatchObject({ code: 1, signal: null, timedOut: false }) + expect(refused.stderr).toContain('cannot safely open the active profile') + expect(readFileSync(dataFile)).toEqual(jsonBeforeCorruption) + expect( + readdirSync(profileDirectory).filter((name) => + /^orca-data\.json\.sqlite-export\.\d+\.json$/.test(name) + ) + ).toEqual(retainedExports) + } finally { + if (app) { + await session.close(app).catch(() => {}) + } + await session.dispose() + } +}) diff --git a/tests/e2e/plugin-startup-budget.spec.ts b/tests/e2e/plugin-startup-budget.spec.ts index 8f03d03d3e7..369a0c6e24e 100644 --- a/tests/e2e/plugin-startup-budget.spec.ts +++ b/tests/e2e/plugin-startup-budget.spec.ts @@ -126,27 +126,28 @@ function median(values: readonly number[]): number { // oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo. test('keeps real Electron launch stable with 20 approved inert plugins', async ({}, testInfo) => { test.setTimeout(240_000) - const session = createRestartSession(testInfo, { ORCA_STARTUP_DIAGNOSTICS: '1' }) const baseline: StartupSample[] = [] const populated: StartupSample[] = [] - let markerPaths: string[] = [] - try { - for (let sample = 0; sample < SAMPLE_COUNT; sample += 1) { - seedPlugins(session.userDataDir, 0) - baseline.push(await launchSample(session, 0, testInfo)) - markerPaths = seedPlugins(session.userDataDir, PLUGIN_COUNT) - populated.push(await launchSample(session, PLUGIN_COUNT, testInfo)) + for (let sample = 0; sample < SAMPLE_COUNT; sample += 1) { + for (const count of [0, PLUGIN_COUNT]) { + // Seed each profile before its first migration into authoritative SQLite state. + const session = createRestartSession(testInfo, { ORCA_STARTUP_DIAGNOSTICS: '1' }) + try { + const markerPaths = seedPlugins(session.userDataDir, count) + const samples = count === 0 ? baseline : populated + samples.push(await launchSample(session, count, testInfo)) + expect(markerPaths.every((markerPath) => !existsSync(markerPath))).toBe(true) + } finally { + await session.dispose() + } } - - // The isolated 20-sample unit gate owns the ≤50 ms P95. This app-level - // complement measures the user-visible launch delta because background - // discovery completion overlaps unrelated main-process startup work. - expect(populated.every((sample) => Number.isFinite(sample.pluginDurationMs))).toBe(true) - expect(median(populated.map((sample) => sample.readyToShowMs))).toBeLessThanOrEqual( - median(baseline.map((sample) => sample.readyToShowMs)) + 50 - ) - expect(markerPaths.every((markerPath) => !existsSync(markerPath))).toBe(true) - } finally { - await session.dispose() } + + // The isolated 20-sample unit gate owns the ≤50 ms P95. This app-level + // complement measures the user-visible launch delta because background + // discovery completion overlaps unrelated main-process startup work. + expect(populated.every((sample) => Number.isFinite(sample.pluginDurationMs))).toBe(true) + expect(median(populated.map((sample) => sample.readyToShowMs))).toBeLessThanOrEqual( + median(baseline.map((sample) => sample.readyToShowMs)) + 50 + ) }) diff --git a/tests/e2e/profile-state-automatic-backup-recovery.spec.ts b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts new file mode 100644 index 00000000000..d3eb3a815e9 --- /dev/null +++ b/tests/e2e/profile-state-automatic-backup-recovery.spec.ts @@ -0,0 +1,222 @@ +import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { runProcess } from '../../src/shared/child-process/run-process' +import { openProfileStateDatabaseReadOnly } from '../../src/main/persistence/profile-state/profile-state-database' +import { readProfileStateSnapshot } from '../../src/main/persistence/profile-state/profile-state-documents' +import { profileStateDatabaseBackups } from '../../src/main/persistence/profile-state/profile-state-backup-path' +import { test, expect } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { createElectronHomeIsolation } from './helpers/electron-home-isolation' +import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' +import { waitForSessionReady } from './helpers/store' + +function readSnapshot(databasePath: string) { + const opened = openProfileStateDatabaseReadOnly(databasePath, DEFAULT_LOCAL_ORCA_PROFILE_ID) + try { + return readProfileStateSnapshot(opened.db) + } finally { + opened.db.close() + } +} + +function rollbackProfileBackup(userDataDir: string, backupId: string, executable?: string) { + const cliIsolation = createElectronHomeIsolation({ + inheritedEnv: process.env, + launchEnv: { ORCA_USER_DATA_PATH: userDataDir, ORCA_BACKGROUND_LAUNCH: '1' }, + extraEnv: { + ...(executable + ? { ORCA_APP_EXECUTABLE: executable, ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1' } + : {}), + // Raw development Electron needs the same sandbox opt-out as Playwright. + ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {}) + }, + userDataDir + }) + return runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'profile', + 'state', + 'rollback', + '--backup', + backupId, + '--json' + ], + env: cliIsolation.env, + timeoutMs: 30_000, + maxOutputBytes: 64 * 1024 + }) +} + +for (const recoveryRuntime of ['node', 'electron'] as const) { + // oxlint-disable-next-line no-empty-pattern -- This test owns both hidden Electron launches. + test(`restores an automatic SQLite backup through the ${recoveryRuntime} CLI after primary corruption`, async ({}, testInfo) => { + test.setTimeout(180_000) + const session = createRestartSession(testInfo, { ORCA_BACKGROUND_LAUNCH: '1' }) + const rootJson = path.join(session.userDataDir, 'orca-data.json') + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID + ) + const databasePath = path.join(profileDirectory, 'profile-state.db') + const dataFile = path.join(profileDirectory, 'orca-data.json') + const marker = `automatic-backup-${Date.now()}` + const recoveryMarker = { + marker, + // Exercise native cloning in the Electron recovery process on macOS. + payload: recoveryRuntime === 'electron' ? 'retained recovery data'.repeat(450_000) : '' + } + const seed = getE2ECompletedOnboardingProfile() + writeFileSync( + rootJson, + JSON.stringify({ + ...seed, + settings: { ...seed.settings, terminalFontSize: 19, theme: 'light' }, + backupRecoveryMarker: recoveryMarker + }) + ) + let firstApp: ElectronApplication | null = null + let restoredApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + expect( + await first.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + await first.page.evaluate(async () => { + const update = window.__store?.getState().updateSettingsOrThrow + if (!update) { + throw new Error('Renderer settings persistence is unavailable') + } + await update({ theme: 'dark' }) + }) + + // Only normal app writes create this recovery point; the test never calls a snapshot writer. + await expect + .poll(() => profileStateDatabaseBackups(databasePath).length, { timeout: 30_000 }) + .toBeGreaterThan(0) + const backup = profileStateDatabaseBackups(databasePath)[0] + const chosen = readSnapshot(backup.path) + expect(JSON.parse(chosen.json)).toMatchObject({ + settings: { terminalFontSize: 19 }, + backupRecoveryMarker: recoveryMarker + }) + if (recoveryRuntime === 'electron') { + expect(statSync(backup.path).size).toBeGreaterThan(8 * 1024 * 1024) + } + const backupBytes = readFileSync(backup.path) + await first.page.evaluate(async () => { + const update = window.__store?.getState().updateSettingsOrThrow + if (!update) { + throw new Error('Renderer settings persistence is unavailable') + } + await update({ terminalFontSize: 23 }) + }) + await expect + .poll(() => JSON.parse(readSnapshot(databasePath).json).settings.terminalFontSize) + .toBe(23) + expect(readFileSync(backup.path).equals(backupBytes)).toBe(true) + const beforeRefusal = readSnapshot(databasePath) + const recoveryExecutable = + recoveryRuntime === 'electron' + ? await first.app.evaluate(() => process.execPath) + : undefined + const refused = await rollbackProfileBackup( + session.userDataDir, + backup.id, + recoveryExecutable + ) + expect(refused.code).toBe(1) + expect(refused.stdout + refused.stderr).toContain( + recoveryRuntime === 'electron' ? 'Stop Orca' : 'in use' + ) + expect(readSnapshot(databasePath)).toEqual(beforeRefusal) + expect(readFileSync(backup.path).equals(backupBytes)).toBe(true) + await session.close(firstApp) + firstApp = null + await cleanupE2EDaemons(session.userDataDir) + + for (const file of [dataFile, rootJson, `${databasePath}-wal`, `${databasePath}-shm`]) { + rmSync(file, { force: true }) + } + // Preserve both large artifacts so quarantine exercises batched recovery copies. + const corruptPrimary = readFileSync(databasePath) + corruptPrimary.write('deliberately corrupt SQLite primary') + writeFileSync(databasePath, corruptPrimary) + if (recoveryRuntime === 'electron') { + expect(corruptPrimary.length).toBeGreaterThan(8 * 1024 * 1024) + } + expect(() => readSnapshot(databasePath)).toThrow() + const restored = await rollbackProfileBackup( + session.userDataDir, + backup.id, + recoveryExecutable + ) + expect(restored.code, restored.stderr || restored.stdout).toBe(0) + const recovered = JSON.parse(restored.stdout) + expect(recovered).toMatchObject({ + ok: true, + result: { + storage: 'sqlite', + backupId: backup.id, + revision: chosen.revision, + profileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + restoredPath: recoveryRuntime === 'electron' ? realpathSync(databasePath) : databasePath + } + }) + expect(readSnapshot(databasePath)).toEqual(chosen) + const quarantineDirectory: unknown = recovered.result.quarantineDirectory + if (typeof quarantineDirectory !== 'string') { + throw new Error('Recovery did not report its quarantine directory') + } + expect( + readFileSync(path.join(quarantineDirectory, 'profile-state.db')).equals(corruptPrimary) + ).toBe(true) + expect( + readFileSync(path.join(quarantineDirectory, path.basename(backup.path))).equals(backupBytes) + ).toBe(true) + expect(readFileSync(backup.path).equals(backupBytes)).toBe(true) + expect(existsSync(dataFile)).toBe(false) + + const relaunched = await session.launch() + restoredApp = relaunched.app + await waitForSessionReady(relaunched.page) + await expect + .poll(() => + relaunched.page.evaluate(() => window.__store?.getState().settings?.terminalFontSize) + ) + .toBe(19) + await expect(relaunched.page.locator('html')).toHaveClass( + JSON.parse(chosen.json).settings.theme === 'dark' ? /\bdark\b/ : /\blight\b/ + ) + expect(JSON.parse(readSnapshot(databasePath).json).backupRecoveryMarker).toEqual( + recoveryMarker + ) + expect( + await relaunched.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + expect(existsSync(dataFile)).toBe(false) + } finally { + try { + if (restoredApp) { + await session.close(restoredApp) + } + if (firstApp) { + await session.close(firstApp) + } + } finally { + await session.dispose() + } + } + }) +} diff --git a/tests/e2e/profile-state-terminal-restart-persistence.spec.ts b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts new file mode 100644 index 00000000000..656692e9f68 --- /dev/null +++ b/tests/e2e/profile-state-terminal-restart-persistence.spec.ts @@ -0,0 +1,591 @@ +/** Candidate SQLite terminal restart/profile journeys. */ + +import { readFileSync, existsSync, rmSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { getRepoIdFromWorktreeId } from '../../src/shared/worktree/id' +import { test, expect } from './helpers/orca-app' +import { forceQuitElectronAppForE2E } from './helpers/electron-process-shutdown' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { execInTerminal, waitForTerminalOutput, waitForActivePanePtyId } from './helpers/terminal' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + getWorktreeTabs +} from './helpers/store' +import { + seededRepoPathOrSkip, + bootstrapFirstLaunch, + bootstrapRestoredLaunch, + waitForElectronProcessExit +} from './helpers/terminal-restart-persistence' + +test.describe.configure({ mode: 'serial' }) + +test.describe('SQLite candidate terminal restart persistence', () => { + test('SQLite survives restart after legacy JSON is removed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const { worktreeId, ptyId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath) + + const automationName = `sqlite-candidate-restart-${Date.now()}` + const automationPrompt = 'Persist this candidate automation across an SQLite-only restart.' + const automationLifecycle = await firstLaunch.page.evaluate( + async ({ name, prompt }) => { + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + const repo = window.__store?.getState().repos[0] + if (!repo) { + throw new Error('SQLite candidate E2E did not find a seeded repository') + } + const response = await window.api.runtime.call({ + method: 'automation.create', + params: { + agentId: 'codex', + name, + prompt, + repo: `id:${repo.id}`, + // Keep a full run context in the persisted definition so `runNow` + // exercises the same runtime RPC and normalized automationRuns + // path used by real scheduled work. + runContext: { + kind: 'workspace-run', + projectId: repo.id, + hostId: 'runtime:missing', + projectHostSetupId: `missing-${Date.now()}`, + repoId: repo.id, + path: repo.path + }, + workspaceMode: 'new_per_run', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: Date.now(), + enabled: false, + missedRunGraceMinutes: 720 + } + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + const createResult = isRecord(response.result) ? response.result : null + const automation = + createResult && isRecord(createResult.automation) ? createResult.automation : null + if (!automation || typeof automation.id !== 'string') { + throw new Error('automation.create returned an invalid automation') + } + const runResponse = await window.api.runtime.call({ + method: 'automation.runNow', + params: { id: automation.id } + }) + if (!runResponse.ok) { + throw new Error(`${runResponse.error.code}: ${runResponse.error.message}`) + } + const runResult = isRecord(runResponse.result) ? runResponse.result : null + const run = runResult && isRecord(runResult.run) ? runResult.run : null + if (!run || typeof run.id !== 'string' || typeof run.status !== 'string') { + throw new Error('automation.runNow returned an invalid run') + } + return { + automationId: automation.id, + runId: run.id, + runStatus: run.status + } + }, + { name: automationName, prompt: automationPrompt } + ) + expect(automationLifecycle.runStatus).toBe('dispatching') + + const profileIndex: unknown = JSON.parse( + readFileSync(path.join(session.userDataDir, 'orca-profile-index.json'), 'utf8') + ) + if ( + typeof profileIndex !== 'object' || + profileIndex === null || + Array.isArray(profileIndex) || + !('activeProfileId' in profileIndex) || + typeof profileIndex.activeProfileId !== 'string' + ) { + throw new Error('SQLite cutover E2E did not find an active profile id') + } + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + profileIndex.activeProfileId + ) + const legacyProfileState = path.join(profileDirectory, 'orca-data.json') + const legacyRootState = path.join(session.userDataDir, 'orca-data.json') + const databasePath = path.join(profileDirectory, 'profile-state.db') + expect(existsSync(databasePath)).toBe(true) + expect(existsSync(legacyProfileState)).toBe(true) + + await session.close(firstApp) + firstApp = null + expect(JSON.parse(readFileSync(legacyProfileState, 'utf8'))).toMatchObject({ + automations: expect.arrayContaining([ + expect.objectContaining({ id: automationLifecycle.automationId, name: automationName }) + ]) + }) + // Prove the next launch has only the SQLite authority available. + rmSync(legacyProfileState, { force: true }) + rmSync(legacyRootState, { force: true }) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await bootstrapRestoredLaunch(secondLaunch.page, worktreeId) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + // The daemon survives the clean Electron restart, so a SQLite-only launch + // must reattach the same PTY binding instead of silently spawning a new shell. + await expect + .poll(() => waitForActivePanePtyId(secondLaunch.page), { timeout: 15_000 }) + .toBe(ptyId) + const ptyMarker = `SQLITE_PTY_REATTACHED_${Date.now()}` + await execInTerminal(secondLaunch.page, ptyId, `echo ${ptyMarker}`) + await waitForTerminalOutput(secondLaunch.page, ptyMarker) + await expect + .poll( + async () => + await secondLaunch.page.evaluate( + async ({ name, prompt }) => { + const response = await window.api.runtime.call({ method: 'automation.list' }) + if (!response.ok) { + return false + } + if ( + typeof response.result !== 'object' || + response.result === null || + !('automations' in response.result) || + !Array.isArray(response.result.automations) + ) { + return false + } + return response.result.automations.some((automation) => { + if (typeof automation !== 'object' || automation === null) { + return false + } + return ( + 'name' in automation && + 'prompt' in automation && + automation.name === name && + automation.prompt === prompt + ) + }) + }, + { name: automationName, prompt: automationPrompt } + ), + { timeout: 10_000 } + ) + .toBe(true) + await expect + .poll( + async () => + await secondLaunch.page.evaluate(async ({ automationId, runId }) => { + const response = await window.api.runtime.call({ + method: 'automation.runs', + params: { automationId } + }) + if ( + !response.ok || + typeof response.result !== 'object' || + response.result === null || + !('runs' in response.result) || + !Array.isArray(response.result.runs) + ) { + return false + } + return response.result.runs.some( + (run) => typeof run === 'object' && run !== null && 'id' in run && run.id === runId + ) + }, automationLifecycle), + { timeout: 10_000 } + ) + .toBe(true) + await expect + .poll(async () => (await getWorktreeTabs(secondLaunch.page, worktreeId)).length, { + timeout: 10_000 + }) + .toBeGreaterThanOrEqual(1) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) + + test('SQLite profile switch keeps independent profiles isolated', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let thirdApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const defaultWorktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + const defaultProfileId = await firstLaunch.page.evaluate(async () => { + const profiles = await window.api.orcaProfiles.list() + return profiles.activeProfileId + }) + const targetProfileId = await firstLaunch.page.evaluate(async () => { + const created = await window.api.orcaProfiles.createLocal({ + name: `SQLite switch target ${Date.now()}` + }) + return created.profile.id + }) + + const defaultProfileDirectory = path.join(session.userDataDir, 'profiles', defaultProfileId) + const defaultJson = path.join(defaultProfileDirectory, 'orca-data.json') + const rootJson = path.join(session.userDataDir, 'orca-data.json') + const defaultDatabase = path.join(defaultProfileDirectory, 'profile-state.db') + expect(existsSync(defaultDatabase)).toBe(true) + + // The target switch must flush and publish the index before relaunching. + await expect( + firstLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + targetProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + const targetList = await secondLaunch.page.evaluate(() => window.api.orcaProfiles.list()) + expect(targetList.activeProfileId).toBe(targetProfileId) + const targetProfileDirectory = path.join(session.userDataDir, 'profiles', targetProfileId) + const targetDatabase = path.join(targetProfileDirectory, 'profile-state.db') + const targetJson = path.join(targetProfileDirectory, 'orca-data.json') + expect(existsSync(targetDatabase)).toBe(true) + + // Seed an independent target-profile document before switching back. + await attachRepoAndOpenTerminal(secondLaunch.page, repoPath) + await waitForSessionReady(secondLaunch.page) + + await expect( + secondLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + defaultProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(secondApp) + secondApp = null + + // Clean maintenance refreshes compatibility JSON before releasing the profile. + expect(existsSync(targetJson)).toBe(true) + for (const legacyPath of [targetJson, defaultJson, rootJson]) { + rmSync(legacyPath, { force: true }) + } + const thirdLaunch = await session.launch() + thirdApp = thirdLaunch.app + await waitForSessionReady(thirdLaunch.page) + const finalList = await thirdLaunch.page.evaluate(() => window.api.orcaProfiles.list()) + expect(finalList.activeProfileId).toBe(defaultProfileId) + expect(existsSync(defaultDatabase)).toBe(true) + expect(existsSync(targetDatabase)).toBe(true) + expect(existsSync(defaultJson)).toBe(false) + expect(existsSync(targetJson)).toBe(false) + await waitForActiveWorktree(thirdLaunch.page) + await expect + .poll(() => getActiveWorktreeId(thirdLaunch.page), { timeout: 10_000 }) + .toBe(defaultWorktreeId) + } finally { + if (thirdApp) { + await session.close(thirdApp) + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) + + test('SQLite moves a project across JSON-free profiles', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let thirdApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + const profileState = await firstLaunch.page.evaluate(async (repoId) => { + const profiles = await window.api.orcaProfiles.list() + const repo = window.__store?.getState().repos.find((entry) => entry.id === repoId) + if (!repo) { + throw new Error('SQLite profile move E2E did not find the seeded repository') + } + const target = await window.api.orcaProfiles.createLocal({ + name: `SQLite move target ${Date.now()}` + }) + return { + sourceProfileId: profiles.activeProfileId, + targetProfileId: target.profile.id, + repoId: repo.id + } + }, getRepoIdFromWorktreeId(worktreeId)) + + const sourceDirectory = path.join( + session.userDataDir, + 'profiles', + profileState.sourceProfileId + ) + const targetDirectory = path.join( + session.userDataDir, + 'profiles', + profileState.targetProfileId + ) + const sourceDatabase = path.join(sourceDirectory, 'profile-state.db') + const targetDatabase = path.join(targetDirectory, 'profile-state.db') + const sourceJson = path.join(sourceDirectory, 'orca-data.json') + const targetJson = path.join(targetDirectory, 'orca-data.json') + const rootJson = path.join(session.userDataDir, 'orca-data.json') + expect(existsSync(sourceDatabase)).toBe(true) + + // Visit the target once so candidate startup establishes its own database before the move. + await expect( + firstLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + profileState.targetProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + expect(await secondLaunch.page.evaluate(() => window.api.orcaProfiles.list())).toMatchObject({ + activeProfileId: profileState.targetProfileId + }) + expect(existsSync(targetDatabase)).toBe(true) + + await expect( + secondLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + profileState.sourceProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(secondApp) + secondApp = null + + const thirdLaunch = await session.launch() + thirdApp = thirdLaunch.app + await waitForSessionReady(thirdLaunch.page) + const moveResult = await thirdLaunch.page.evaluate( + (args) => window.api.orcaProfiles.transferProject(args), + { + sourceProfileId: profileState.sourceProfileId, + targetProfileId: profileState.targetProfileId, + repoId: profileState.repoId, + mode: 'move' as const + } + ) + expect(moveResult).toMatchObject({ + status: 'transferred', + mode: 'move', + willRelaunch: true + }) + await waitForElectronProcessExit(thirdApp) + thirdApp = null + + // The move commits both SQLite participants before relaunch. Remove every JSON mirror to + // prove the target and source are both reopened from their databases alone. + for (const legacyPath of [sourceJson, targetJson, rootJson]) { + rmSync(legacyPath, { force: true }) + } + expect(existsSync(sourceJson)).toBe(false) + expect(existsSync(targetJson)).toBe(false) + + const targetLaunch = await session.launch() + secondApp = targetLaunch.app + await waitForSessionReady(targetLaunch.page) + expect(await targetLaunch.page.evaluate(() => window.api.orcaProfiles.list())).toMatchObject({ + activeProfileId: profileState.targetProfileId + }) + expect( + await targetLaunch.page.evaluate( + (repoId) => window.__store?.getState().repos.some((repo) => repo.id === repoId), + profileState.repoId + ) + ).toBe(true) + expect(existsSync(targetDatabase)).toBe(true) + + await expect( + targetLaunch.page.evaluate( + (profileId) => window.api.orcaProfiles.switchProfile({ profileId }), + profileState.sourceProfileId + ) + ).resolves.toEqual({ status: 'relaunching' }) + await waitForElectronProcessExit(secondApp) + secondApp = null + + // Switching away refreshes the target export; remove it before the SQL-only source launch. + rmSync(targetJson, { force: true }) + const sourceLaunch = await session.launch() + thirdApp = sourceLaunch.app + await waitForSessionReady(sourceLaunch.page) + expect(await sourceLaunch.page.evaluate(() => window.api.orcaProfiles.list())).toMatchObject({ + activeProfileId: profileState.sourceProfileId + }) + expect( + await sourceLaunch.page.evaluate( + (repoId) => window.__store?.getState().repos.some((repo) => repo.id === repoId), + profileState.repoId + ) + ).toBe(false) + expect(existsSync(sourceDatabase)).toBe(true) + expect(existsSync(sourceJson)).toBe(false) + expect(existsSync(targetJson)).toBe(false) + } finally { + if (thirdApp) { + await session.close(thirdApp) + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) + + test('SQLite survives abrupt process termination after legacy JSON is removed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + const repoPath = seededRepoPathOrSkip() + + const session = createRestartSession(testInfo) + const stderr: string[] = [] + const launchOptions = { + onStderr: (chunk: string): void => { + stderr.push(chunk) + } + } + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch(launchOptions) + firstApp = firstLaunch.app + const { worktreeId, ptyId } = await bootstrapFirstLaunch(firstLaunch.page, repoPath) + + const profileIndex: unknown = JSON.parse( + readFileSync(path.join(session.userDataDir, 'orca-profile-index.json'), 'utf8') + ) + if ( + typeof profileIndex !== 'object' || + profileIndex === null || + Array.isArray(profileIndex) || + !('activeProfileId' in profileIndex) || + typeof profileIndex.activeProfileId !== 'string' + ) { + throw new Error('SQLite crash E2E did not find an active profile id') + } + const profileDirectory = path.join( + session.userDataDir, + 'profiles', + profileIndex.activeProfileId + ) + const legacyProfileState = path.join(profileDirectory, 'orca-data.json') + const legacyRootState = path.join(session.userDataDir, 'orca-data.json') + const databasePath = path.join(profileDirectory, 'profile-state.db') + expect(existsSync(databasePath)).toBe(true) + + // Remove both JSON mirrors before the kill. Any state recovered by the next + // launch must therefore come from SQLite, including the terminal topology. + rmSync(legacyProfileState, { force: true }) + rmSync(legacyRootState, { force: true }) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + + const mainPid = await firstApp.evaluate(() => process.pid) + await forceQuitElectronAppForE2E(firstApp, { preserveDaemons: true }) + // Windows Playwright exposes a cmd wrapper; verify the actual Electron process exited. + await expect + .poll(() => { + try { + process.kill(mainPid, 0) + return false + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') { + return true + } + throw error + } + }) + .toBe(true) + firstApp = null + for (const suffix of ['', '-wal', '-shm']) { + const filename = `profile-state.db${suffix}` + const filePath = path.join(profileDirectory, filename) + if (existsSync(filePath)) { + await testInfo.attach(filename, { + body: readFileSync(filePath), + contentType: 'application/octet-stream' + }) + } + } + + const secondLaunch = await session.launch(launchOptions) + secondApp = secondLaunch.app + await bootstrapRestoredLaunch(secondLaunch.page, worktreeId) + expect(existsSync(legacyProfileState)).toBe(false) + expect(existsSync(legacyRootState)).toBe(false) + // The daemon survives the Electron crash, so a SQLite-only restart must + // reattach the same PTY binding instead of silently spawning a new shell. + await expect + .poll(() => waitForActivePanePtyId(secondLaunch.page), { timeout: 15_000 }) + .toBe(ptyId) + const ptyMarker = `SQLITE_PTY_REATTACHED_AFTER_KILL_${Date.now()}` + await execInTerminal(secondLaunch.page, ptyId, `echo ${ptyMarker}`) + await waitForTerminalOutput(secondLaunch.page, ptyMarker) + await expect + .poll(async () => (await getWorktreeTabs(secondLaunch.page, worktreeId)).length, { + timeout: 10_000 + }) + .toBeGreaterThanOrEqual(1) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + await testInfo.attach('restart-stderr', { + body: stderr.join(''), + contentType: 'text/plain' + }) + } + }) +}) diff --git a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts index 45ac2f8b0a0..4d5ee50a1d7 100644 --- a/tests/e2e/remote-terminal-tab-retirement.unit.test.ts +++ b/tests/e2e/remote-terminal-tab-retirement.unit.test.ts @@ -10,6 +10,7 @@ import { type WebSessionTabsSyncState } from '../../src/renderer/src/runtime/web-session-tabs-sync' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { withDurableRuntimeStore } from '../../src/main/runtime/runtime-durable-store-fixture' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { setState: vi.fn() } @@ -129,13 +130,16 @@ describe('remote terminal tab retirement publication', () => { it('removes a permanent host exit from simultaneous viewers without stale resurrection', async () => { let session = makePersistedSession() const flushOrThrow = vi.fn() - const runtime = new OrcaRuntimeService({ - getWorkspaceSession: () => session, - setWorkspaceSession: (next) => { - session = next - }, - flushOrThrow - } as never) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This retirement fixture implements the Store session and durability operations used by the runtime. + withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: (next) => { + session = next + }, + flushOrThrow + }) as never + ) runtime.attachWindow(1) const staleLiveSnapshot = makeHostSnapshot() runtime.syncWindowGraph(1, { @@ -177,7 +181,7 @@ describe('remote terminal tab retirement publication', () => { const publications: (typeof livePublication)[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((event) => publications.push(event)) - runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID) + await runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID) const retiredPublication = publications.at(-1) expect(retiredPublication).toBeDefined() if (!retiredPublication) { diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts index db3b03409dd..695abfa0325 100644 --- a/tests/e2e/settled-worker-tab-survives-restart.spec.ts +++ b/tests/e2e/settled-worker-tab-survives-restart.spec.ts @@ -1,8 +1,9 @@ +import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema' +import { readPersistedProfileState } from './helpers/persisted-profile-state' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { DaemonClient } from '../../src/main/daemon/client' import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { TEST_REPO_PATH_FILE } from './global-setup' @@ -71,6 +72,8 @@ async function findSecondaryWorktree( } async function backgroundMountTab(page: Page, worktreeId: string, tabId: string): Promise { + // The synthetic event bypasses the production queue, so its Terminal listener must be mounted. + await waitForActiveTerminalManager(page) await page.evaluate( ({ tabId, worktreeId }) => { window.dispatchEvent( @@ -87,21 +90,20 @@ async function backgroundMountTab(page: Page, worktreeId: string, tabId: string) } function readPersistedSession(userDataDir: string) { - return JSON.parse( - readFileSync( - path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), - 'utf8' - ) - ).workspaceSession + const parsed = parseWorkspaceSession(readPersistedProfileState(userDataDir).workspaceSession) + if (!parsed.ok) { + throw new Error(`Invalid persisted workspace session: ${parsed.error}`) + } + return parsed.value } function expectNoPersistedWorkerFence(userDataDir: string, paneKey: string): void { - const persisted = readPersistedSession(userDataDir) + const persisted = readPersistedProfileState(userDataDir).workspaceSession // Keep the baseline running through reveal even when it still writes the withdrawn policy. expect - .soft(persisted.sleepingAgentSessionsByPaneKey?.[paneKey] ?? {}) - .not.toHaveProperty('automaticResumeBlockedBy') - expect.soft(persisted.legacyWorkerResumeFencesByPaneKey ?? {}).not.toHaveProperty(paneKey) + .soft(persisted) + .not.toHaveProperty(['sleepingAgentSessionsByPaneKey', paneKey, 'automaticResumeBlockedBy']) + expect.soft(persisted).not.toHaveProperty(['legacyWorkerResumeFencesByPaneKey', paneKey]) } // A restored worker must attach through main so revealing it never fabricates a missing PTY. @@ -479,7 +481,7 @@ for (const daemonSessionGone of [false, true]) { const paneKeys = await second.page.evaluate((tabId) => { const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] const leaves: string[] = [] - const visit = (node: NonNullable['root']) => { + const visit = (node: NonNullable['root']>) => { if (node.type === 'leaf') { leaves.push(`${tabId}:${node.leafId}`) } else { @@ -514,7 +516,7 @@ for (const daemonSessionGone of [false, true]) { expect(persisted.terminalLayoutsByTabId[workerTabId]).toBeDefined() if (!daemonSessionGone) { expect( - Object.values(persisted.terminalLayoutsByTabId[workerTabId].ptyIdsByLeafId) + Object.values(persisted.terminalLayoutsByTabId[workerTabId].ptyIdsByLeafId ?? {}) ).toContain(workerPtyId) } } finally { diff --git a/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts index a7a2261de68..27be0b9ad1b 100644 --- a/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts +++ b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts @@ -18,6 +18,7 @@ import { reconnectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' import { openTerminalTabInActiveGroup } from './helpers/terminal-tab-open' +import { attachSshReconnectFailureObservation } from './helpers/ssh-reconnect-failure-observation' const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' @@ -63,6 +64,8 @@ test.describe('SSH reconnect pane restore', () => { }, testInfo) => { test.slow() let target: DockerSshRelayTarget | null = null + let targetId: string | null = null + let originalPtyId: string | null = null try { target = startDockerSshRelayTarget(testInfo) // The fixture image's shell emits no OSC 0, so without this every tab keeps its placeholder @@ -71,9 +74,11 @@ test.describe('SSH reconnect pane restore', () => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) const remote = await connectDockerSshRelayTarget(orcaPage, target) + targetId = remote.targetId await ensureTerminalVisible(orcaPage, 45_000) await waitForActiveTerminalManager(orcaPage, 60_000) const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + originalPtyId = ptyId // A marker rather than a prompt: a prompt reappears on its own after a reconnect, so it cannot // distinguish restored scrollback from a fresh shell. This string only exists if the pane kept @@ -108,13 +113,15 @@ test.describe('SSH reconnect pane restore', () => { await execInTerminal(orcaPage, ptyId, 'top -b -n 1 > /dev/null; top') await waitForTerminalOutput(orcaPage, 'load average', 30_000, 8000) - await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) - await waitForActiveTerminalManager(orcaPage, 60_000) - await waitForActivePanePtyId(orcaPage, 60_000) + for (let reconnect = 0; reconnect < 3; reconnect += 1) { + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + await waitForActiveTerminalManager(orcaPage, 60_000) + await waitForActivePanePtyId(orcaPage, 60_000) - await waitForTerminalOutput(orcaPage, 'load average', 60_000, 8000) - const tuiContent = await getTerminalContent(orcaPage, 8000) - expect(tuiContent).toContain('PID') + await waitForTerminalOutput(orcaPage, 'load average', 60_000, 8000) + const tuiContent = await getTerminalContent(orcaPage, 8000) + expect(tuiContent).toContain('PID') + } // REGRESSION 2: opening a tab AFTER a reconnect. The prepaint could still fire on this mount // and write over the new shell, leaving a pane with no prompt and a generic tab title. @@ -152,6 +159,17 @@ test.describe('SSH reconnect pane restore', () => { { timeout: 60_000, message: 'New tab kept its placeholder title' } ) .not.toMatch(/^Terminal \d+$/) + } catch (error) { + await attachSshReconnectFailureObservation( + orcaPage, + testInfo, + target, + targetId, + originalPtyId + ).catch((diagnosticError) => + console.warn('SSH reconnect diagnostics failed', diagnosticError) + ) + throw error } finally { if (target) { cleanupDockerSshRelayTarget(target) diff --git a/tests/e2e/ssh-docker-resource-accumulation.spec.ts b/tests/e2e/ssh-docker-resource-accumulation.spec.ts index f028ef0d2ab..bb4a3520183 100644 --- a/tests/e2e/ssh-docker-resource-accumulation.spec.ts +++ b/tests/e2e/ssh-docker-resource-accumulation.spec.ts @@ -71,17 +71,22 @@ const DESCRIBE_MASTER_FD_HOLDERS = [ 'done' ].join('\n') +function readRelayFdCount(target: DockerSshRelayTarget, relayPid: number): number { + const raw = execDockerSshRelayTargetCommand( + target, + `set -o pipefail\nls /proc/${relayPid}/fd | wc -l` + ) + return Number(raw.trim()) +} + function sampleRemoteResources(target: DockerSshRelayTarget): RemoteResourceSample { const groups = readDockerSshRelayProcessSnapshots(target) // Why: fd growth is only meaningful against the relay that owns the PTYs, so read // the table of every relay group and sum, rather than assuming a single relay. - const relayFdCount = groups.reduce((total, group) => { - const raw = execDockerSshRelayTargetCommand( - target, - `ls /proc/${group.relayPid}/fd 2>/dev/null | wc -l` - ) - return total + Number(raw.trim() || '0') - }, 0) + const relayFdCount = groups.reduce( + (total, group) => total + readRelayFdCount(target, group.relayPid), + 0 + ) const ptsCount = Number( execDockerSshRelayTargetCommand(target, 'ls /dev/pts | grep -c "^[0-9]" || true').trim() || '0' ) @@ -192,7 +197,36 @@ test.describe('Docker SSH relay resource accumulation', () => { // Why: the interesting failure is monotonic growth across cycles, not the // absolute count, so compare the last cycle against the first. expect(last.ptsCount).toBeLessThanOrEqual(first.ptsCount) - expect(last.relayFdCount).toBeLessThanOrEqual(first.relayFdCount + 4) + let settledFdCount = last.relayFdCount + if (settledFdCount > first.relayFdCount + 4) { + const groups = readDockerSshRelayProcessSnapshots(target) + expect(groups).toHaveLength(1) + const relayPid = groups[0]!.relayPid + const diagnostics = execDockerSshRelayTargetCommand( + target, + [`ls -l /proc/${relayPid}/fd || true`, 'ps -eo pid,ppid,stat,tty,args'].join('\n') + ) + console.log(`[resource-accumulation] over-budget relay fds\n${diagnostics}`) + await testInfo.attach('relay-fd-over-budget', { + body: diagnostics, + contentType: 'text/plain' + }) + // Background inventory reads can overlap this sample; allow 10s for their FDs to close. + await expect + .poll( + () => { + expect( + readDockerSshRelayProcessSnapshots(captured).map((group) => group.relayPid) + ).toEqual([relayPid]) + settledFdCount = readRelayFdCount(captured, relayPid) + console.log(`[resource-accumulation] settling relay fds ${settledFdCount}`) + return settledFdCount + }, + { timeout: 10_000, intervals: [100, 250, 500] } + ) + .toBeLessThanOrEqual(first.relayFdCount + 4) + } + expect(settledFdCount).toBeLessThanOrEqual(first.relayFdCount + 4) expect(last.nodeProcessCount).toBeLessThanOrEqual(first.nodeProcessCount) expect(last.leakedMasterFdCount).toBeLessThanOrEqual(first.leakedMasterFdCount) } finally { diff --git a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts index 83a7ae66f65..7939320d666 100644 --- a/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts +++ b/tests/e2e/ssh-docker-transport-drop-recovery.spec.ts @@ -1,8 +1,6 @@ -import path from 'node:path' -import { readFileSync } from 'node:fs' +import { readPersistedProfileState } from './helpers/persisted-profile-state' import type { ElectronApplication } from '@playwright/test' import { test, expect } from './helpers/orca-app' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { sshRemotePtyLeaseAllowsReattach, type SshRemotePtyLease } from '../../src/shared/ssh-types' import { toRelaySshPtyId } from '../../src/shared/ssh-pty-id' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -60,13 +58,8 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' * drift from the fan-out it exists to bound. */ function readSshLeases(userDataDir: string, targetId: string): SshRemotePtyLease[] { - const dataPath = path.join( - userDataDir, - 'profiles', - DEFAULT_LOCAL_ORCA_PROFILE_ID, - 'orca-data.json' - ) - const parsed = JSON.parse(readFileSync(dataPath, 'utf8')) as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const parsed = readPersistedProfileState(userDataDir) as { sshRemotePtyLeases?: SshRemotePtyLease[] } return (parsed.sshRemotePtyLeases ?? []).filter((lease) => lease.targetId === targetId) diff --git a/tests/e2e/terminal-binding-crash-hydration.unit.test.ts b/tests/e2e/terminal-binding-crash-hydration.unit.test.ts new file mode 100644 index 00000000000..ec83a396e38 --- /dev/null +++ b/tests/e2e/terminal-binding-crash-hydration.unit.test.ts @@ -0,0 +1,101 @@ +import '../../src/renderer/src/store/slices/terminal-hydration-store-test-bootstrap' +import { describe, expect, it, vi } from 'vitest' +import { applyPtyBinding } from '../../src/main/persistence/loading-store/pty-binding-session-update' +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import { folderWorkspaceKey } from '../../src/shared/workspace-scope' +import type { WorkspaceSessionState } from '../../src/shared/workspace-session-state-types' +import { reconcileHydratedWorkspaceTabModels } from '../../src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '../../src/renderer/src/store/slices/store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn() +})) + +const TAB_ID = 'crash-survivor' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const INCARNATION_ID = '22222222-2222-4222-8222-222222222222' +const FOLDER_KEY = folderWorkspaceKey('crash-folder') + +describe.each(['repo1::/repo1', FOLDER_KEY])('binding crash hydration for %s', (worktreeId) => { + it.each([false, true])( + 'restores the acknowledged terminal before a renderer snapshot (existing row: %s)', + async (existingRow) => { + const ptyId = `${worktreeId}@@surviving-pty` + const persisted: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: worktreeId, + activeTabId: TAB_ID, + activeTabIdByWorktree: { [worktreeId]: TAB_ID }, + activeWorktreeIdsOnShutdown: [], + unifiedTabs: {}, + tabGroups: {}, + tabGroupLayouts: {}, + ...(existingRow + ? { tabsByWorktree: { [worktreeId]: [makeTab({ id: TAB_ID, worktreeId, ptyId: null })] } } + : {}) + } + applyPtyBinding( + { worktreeId, tabId: TAB_ID, leafId: LEAF_ID, ptyId, incarnationId: INCARNATION_ID }, + persisted, + worktreeId, + `${TAB_ID}:${LEAF_ID}` + ) + + // A crash leaves only the binding transaction; no renderer snapshot can fill its gaps. + const restoredSession = structuredClone(persisted) + const store = createTestStore() + store.setState({ + repos: [TEST_REPO], + worktreesByRepo: { + repo1: [makeWorktree({ id: 'repo1::/repo1', repoId: 'repo1', path: '/repo1' })] + } + }) + const options = { additionalValidWorkspaceKeys: [FOLDER_KEY] } + store.getState().hydrateWorkspaceSession(restoredSession, options) + store.getState().hydrateTabsSession(restoredSession, options) + reconcileHydratedWorkspaceTabModels( + restoredSession, + store.getState().reconcileWorktreeTabModels + ) + + const beforeReconnect = store.getState() + expect(beforeReconnect.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([TAB_ID]) + expect(beforeReconnect.pendingReconnectPtyIdByTabId[TAB_ID]).toBe(ptyId) + expect(beforeReconnect.unifiedTabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: TAB_ID, entityId: TAB_ID, contentType: 'terminal' }) + ]) + expect(beforeReconnect.groupsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ activeTabId: TAB_ID, tabOrder: [TAB_ID] }) + ]) + expect(beforeReconnect.layoutByWorktree[worktreeId]).toEqual({ + type: 'leaf', + groupId: beforeReconnect.groupsByWorktree[worktreeId][0].id + }) + expect(beforeReconnect.terminalLayoutsByTabId[TAB_ID]).toMatchObject({ + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + }) + expect(restoredSession.terminalPtyIncarnationsByPaneKey?.[`${TAB_ID}:${LEAF_ID}`]).toBe( + INCARNATION_ID + ) + + await store.getState().reconnectPersistedTerminals() + + expect(store.getState().workspaceSessionReady).toBe(true) + expect(store.getState().activeTabId).toBe(TAB_ID) + expect(store.getState().tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ id: TAB_ID, ptyId }) + ]) + expect(store.getState().ptyIdsByTabId[TAB_ID]).toEqual([ptyId]) + } + ) +}) diff --git a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts index 2eec7356f65..48a0f7a118c 100644 --- a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts +++ b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts @@ -1,9 +1,8 @@ +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' import { randomUUID } from 'node:crypto' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' +import { existsSync, readFileSync } from 'node:fs' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import type { TerminalLayoutSnapshot } from '../../src/shared/terminal-tab-types' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { test, expect } from './helpers/orca-app' import { findMarkerFrame, @@ -59,39 +58,36 @@ setInterval(() => { `.trim() } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function seedDuplicatePtyOwnership(userDataDir: string): void { - const dataPath = persistedDataPath(userDataDir) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData - const session = data.workspaceSession - const tabId = session?.activeTabId - const layout = tabId ? session?.terminalLayoutsByTabId?.[tabId] : undefined - const retainedLeafId = layout?.activeLeafId - const ptyId = retainedLeafId ? layout?.ptyIdsByLeafId?.[retainedLeafId] : undefined - if (!session?.terminalLayoutsByTabId || !tabId || !layout || !retainedLeafId || !ptyId) { - throw new Error('Persisted terminal ownership was unavailable for duplicate-layout seeding') - } - - const duplicateLeafId = randomUUID() - session.terminalLayoutsByTabId[tabId] = { - ...layout, - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: retainedLeafId }, - second: { type: 'leaf', leafId: duplicateLeafId } - }, - activeLeafId: retainedLeafId, - expandedLeafId: null, - ptyIdsByLeafId: { - [retainedLeafId]: ptyId, - [duplicateLeafId]: ptyId + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const session = data.workspaceSession + const tabId = session?.activeTabId + const layout = tabId ? session?.terminalLayoutsByTabId?.[tabId] : undefined + const retainedLeafId = layout?.activeLeafId + const ptyId = retainedLeafId ? layout?.ptyIdsByLeafId?.[retainedLeafId] : undefined + if (!session?.terminalLayoutsByTabId || !tabId || !layout || !retainedLeafId || !ptyId) { + throw new Error('Persisted terminal ownership was unavailable for duplicate-layout seeding') } - } - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) + + const duplicateLeafId = randomUUID() + session.terminalLayoutsByTabId[tabId] = { + ...layout, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: retainedLeafId }, + second: { type: 'leaf', leafId: duplicateLeafId } + }, + activeLeafId: retainedLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { + [retainedLeafId]: ptyId, + [duplicateLeafId]: ptyId + } + } + }) } async function waitForRestoredTerminal(page: Page, worktreeId: string): Promise { diff --git a/tests/e2e/terminal-restart-persistence.spec.ts b/tests/e2e/terminal-restart-persistence.spec.ts index b9cbd6a00b6..95bc5a4dd5d 100644 --- a/tests/e2e/terminal-restart-persistence.spec.ts +++ b/tests/e2e/terminal-restart-persistence.spec.ts @@ -24,187 +24,33 @@ * - Crash/SIGKILL recovery — that is covered by daemon history checkpoints. */ -import { readFileSync, existsSync } from 'node:fs' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { ElectronApplication } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' -import { TEST_REPO_PATH_FILE } from './global-setup' import { - discoverActivePtyId, execInTerminal, - waitForActiveTerminalManager, waitForTerminalOutput, waitForPaneCount, getTerminalContent, splitActiveTerminalPane } from './helpers/terminal' -import { - waitForSessionReady, - waitForActiveWorktree, - getActiveWorktreeId, - getActiveTabId, - getWorktreeTabs, - ensureTerminalVisible -} from './helpers/store' -import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { getActiveTabId, getWorktreeTabs } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' +import { + seededRepoPathOrSkip, + bootstrapFirstLaunch, + bootstrapRestoredLaunch, + waitForTerminalActiveLine, + readTerminalActiveLine, + setPaneTitleFromTerminalMenu, + getTabCustomTitle, + expectSavedLayoutToContainTitle +} from './helpers/terminal-restart-persistence' -const REQUIRE_WINDOWS_TERMINAL_RESTART_E2E = - process.env.ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E === '1' -const MISSING_SEEDED_REPO_MESSAGE = 'Global setup did not produce a seeded test repo' - -// Why: each test in this file does a full quit→relaunch cycle, which spawns -// two Electron instances back-to-back. Running in serial keeps the isolated -// userDataDirs from competing for the same Electron cache lock on cold start -// and keeps the failure mode interpretable when something goes wrong. +// Each test performs a full quit/relaunch cycle; serialize them to avoid +// competing Electron cache locks and to keep failures interpretable. test.describe.configure({ mode: 'serial' }) -function seededRepoPathOrSkip(): string { - const repoPath = existsSync(TEST_REPO_PATH_FILE) - ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() - : '' - const unavailable = !repoPath || !existsSync(repoPath) - if (unavailable && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { - throw new Error('Required Windows restart E2E seeded repo is unavailable') - } - test.skip(unavailable, MISSING_SEEDED_REPO_MESSAGE) - return repoPath -} - -/** - * Shared bootstrap for a *first* launch: attach the seeded test repo, - * activate its worktree, ensure a terminal is mounted, and return the - * PTY id we can drive with `execInTerminal`. - * - * Why: every test in this file needs the exact same starting state on the - * first launch. Inlining it would obscure the thing each test is actually - * asserting about the *second* launch. - */ -async function bootstrapFirstLaunch( - page: Page, - repoPath: string -): Promise<{ worktreeId: string; ptyId: string }> { - const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) - await waitForSessionReady(page) - await waitForActiveWorktree(page) - await ensureTerminalVisible(page) - - const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) - .then(() => true) - .catch(() => false) - if (!hasPaneManager && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) { - throw new Error('Required Windows restart E2E TerminalPane manager did not mount') - } - test.skip( - !hasPaneManager, - 'Electron automation in this environment never mounts the TerminalPane manager, so restart-persistence assertions would only fail on harness setup.' - ) - await waitForPaneCount(page, 1, 30_000) - - const ptyId = await discoverActivePtyId(page) - return { worktreeId, ptyId } -} - -/** - * Shared bootstrap for a *second* launch: just wait for the session to - * restore, and confirm the previously-active worktree is the active one - * again so downstream assertions operate against the right worktree. - */ -async function bootstrapRestoredLaunch(page: Page, expectedWorktreeId: string): Promise { - await waitForSessionReady(page) - await expect - .poll(async () => getActiveWorktreeId(page), { timeout: 10_000 }) - .toBe(expectedWorktreeId) - await ensureTerminalVisible(page) - // Why: the PaneManager remounts asynchronously after session hydration. The - // restored terminal surface is what we're about to assert against, so make - // sure it exists before any content/layout assertion races. - await waitForActiveTerminalManager(page, 30_000) - await waitForPaneCount(page, 1, 30_000) -} - -async function setPaneTitleFromTerminalMenu(page: Page, title: string): Promise { - const modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = - process.platform === 'win32' ? ['Control'] : [] - await page - .locator('.xterm:visible') - .first() - .click({ button: 'right', position: { x: 40, y: 40 }, modifiers }) - await page.getByText('Set Title…', { exact: true }).click() - const titleInput = page.locator('.pane-title-input').first() - await expect(titleInput).toBeVisible() - await titleInput.fill(title) - await titleInput.press('Enter') -} - -async function getTabCustomTitle( - page: Page, - worktreeId: string, - tabId: string -): Promise { - return page.evaluate( - ({ targetWorktreeId, targetTabId }) => { - const state = window.__store!.getState() - const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find( - (entry) => entry.id === targetTabId - ) - return tab?.customTitle ?? null - }, - { targetWorktreeId: worktreeId, targetTabId: tabId } - ) -} - -async function readTerminalActiveLine(page: Page): Promise { - const tabId = await getActiveTabId(page) - if (!tabId) { - return null - } - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const buffer = pane?.terminal?.buffer.active - if (!buffer) { - return null - } - const cursorLine = buffer.baseY + buffer.cursorY - return buffer.getLine(cursorLine)?.translateToString(true) ?? null - }, tabId) -} - -async function waitForTerminalActiveLine(page: Page, expectedText: string): Promise { - await expect - .poll(async () => (await readTerminalActiveLine(page))?.includes(expectedText), { - timeout: 15_000, - message: `Terminal cursor line did not contain "${expectedText}"` - }) - .toBe(true) - - const activeLine = await readTerminalActiveLine(page) - if (activeLine === null) { - throw new Error('Terminal cursor line disappeared after settling') - } - return activeLine -} - -async function expectSavedLayoutToContainTitle( - page: Page, - tabId: string, - title: string -): Promise { - await expect - .poll( - () => - page.evaluate( - ({ targetTabId, title }) => { - const layout = window.__store!.getState().terminalLayoutsByTabId[targetTabId] - return Object.values(layout?.titlesByLeafId ?? {}).includes(title) - }, - { targetTabId: tabId, title } - ), - { timeout: 3_000 } - ) - .toBe(true) -} - test.describe('Terminal restart persistence', () => { test('scrollback survives clean quit and relaunch', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. {}, testInfo) => { diff --git a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts index 50c3d153101..26ce06e0da8 100644 --- a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts +++ b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts @@ -1,7 +1,9 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' +import { + readPersistedProfileState, + mutateStoppedProfileState +} from './helpers/persisted-profile-state' +import { existsSync, readFileSync } from 'node:fs' import type { ElectronApplication, Page } from '@stablyai/playwright-test' -import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import { test, expect } from './helpers/orca-app' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { ensureTerminalVisible, getActiveWorktreeId, waitForSessionReady } from './helpers/store' @@ -18,34 +20,32 @@ type PersistedData = { } } -function persistedDataPath(userDataDir: string): string { - return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') -} - function injectTruncatedTab(userDataDir: string, worktreeId: string, startupCwd: string): void { - const dataPath = persistedDataPath(userDataDir) - const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData - const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] - if (!tabs) { - throw new Error('Persisted terminal tabs were unavailable for corruption seeding') - } - tabs.push({ - id: CORRUPT_TAB_ID, - ptyId: null, - worktreeId, - title: 'Truncated terminal', - sortOrder: 999, - generation: 3, - startupCwd + return mutateStoppedProfileState(userDataDir, (state) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = state as PersistedData + const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] + if (!tabs) { + throw new Error('Persisted terminal tabs were unavailable for corruption seeding') + } + tabs.push({ + id: CORRUPT_TAB_ID, + ptyId: null, + worktreeId, + title: 'Truncated terminal', + sortOrder: 999, + generation: 3, + startupCwd + }) }) - writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) } function persistedSessionEvidence( userDataDir: string, worktreeId: string ): { corruptLegacyTabPresent: boolean; unifiedTabIds: string[] } { - const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test owns the persisted fixture; optional fields are checked at use sites. + const data = readPersistedProfileState(userDataDir) as PersistedData const legacyTabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] ?? [] const unifiedTabs = data.workspaceSession?.unifiedTabs?.[worktreeId] ?? [] return { From 49d7ed31c67293ffcaeb8eb7909fb80e0f45129c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 22:47:44 -0700 Subject: [PATCH 047/387] fix(native-chat): keep chat visible when detaching its pane (#23096) * fix(native-chat): persist current pane ownership across lifecycle events * fix(native-chat): retain ownership when client chat rendering is disabled * fix(native-chat): preserve chat mode when detaching its pane --------- Co-authored-by: m4air --- .../terminal-pane-tab-detach.test.ts | 25 +++++++++++++++++++ .../terminal-pane/terminal-pane-tab-detach.ts | 1 + 2 files changed, 26 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 209ac9dd5e9..8586db21e41 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -271,6 +271,31 @@ describe('resolveTerminalTabStripDropTarget', () => { }) describe('detachTerminalPaneToTab', () => { + it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', (chatLeafId) => { + const store = createStore({ ...splitLayout(), chatLeafId }) + detachTerminalPaneToTab({ + getStore: () => store, + manager: { + getPanes: () => [{ id: 1 }, { id: 2 }], + getLeafId: () => LEAF_2, + detachPaneForExternalMove: () => true + }, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + const options = vi.mocked(store.createTab).mock.calls[0]?.[3] + expect(options?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal') + expect(store.terminalLayoutsByTabId['tab-detached']?.chatLeafId).toBe( + chatLeafId === LEAF_2 ? LEAF_2 : undefined + ) + expect(store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.chatLeafId).toBe( + chatLeafId === LEAF_1 ? LEAF_1 : undefined + ) + }) + it('creates a new terminal tab with the detached leaf layout and PTY id', () => { const store = createStore() const manager = { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index ed63e48c2a8..27c97538d2d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -140,6 +140,7 @@ export function detachTerminalPaneToTab(args: { )?.shellOverride const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, sourceShellOverride, { activate: true, + ...(detachedLayout.chatLeafId ? { viewMode: 'chat' as const } : {}), initialPtyId: ptyId ?? undefined, ...(!ptyId ? { From 38bcdf76acffce15daf208803352e0e50dee9600 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 22:48:28 -0700 Subject: [PATCH 048/387] perf(ci): reduce queue pressure without paid runners (#23053) * ci: measure complete unit file costs for shard balancing * perf(ci): reduce repeated PR setup and capture complete shard timings * perf(ci): seed reusable main-branch native and typecheck caches * fix(ci): stop superseded unit workflows from resisting cancellation * perf(ci): reuse bundle fixtures and share the baseline Git build * ci: record hosted gains and refresh main hook parity * ci: retain default workers after performance-budget regression * docs: record hosted mobile timing flake --------- Co-authored-by: m4air --- .../prepare-git-compatibility/action.yml | 30 ++++ .github/workflows/ci-cache-warmup.yml | 65 ++++++++ .github/workflows/cloud-verify.yml | 9 +- .../git-command-termination-runtime.yml | 3 + .github/workflows/issue-os-labeler.yaml | 2 +- .github/workflows/pi-owner-runtime.yml | 3 + .github/workflows/pi-provider-runtime.yml | 3 + .github/workflows/pr-test-loc.yml | 3 +- .github/workflows/pr.yml | 103 ++++-------- .github/workflows/release-policy.yml | 2 +- .github/workflows/track-community-prs.yaml | 3 +- .github/workflows/unit-tests.yml | 3 - .../build-mobile-web-app-bundle.test.mjs | 156 ++++++++++-------- .../scripts/check-readme-local-links.test.mjs | 26 ++- .../check-root-directory-entries.test.mjs | 7 +- .../scripts/ci-cache-warmup-workflow.test.mjs | 40 +++++ config/scripts/ci-shard-timings.md | 24 ++- config/scripts/ci-unit-timing-import.mjs | 69 ++++++++ config/scripts/ci-unit-timing-reporter.mjs | 37 +++++ .../scripts/ci-unit-timing-reporter.test.mjs | 104 ++++++++++++ .../cloud-security-history-workflow.test.mjs | 90 ++++++++++ ...git-binary-compatibility-workflow.test.mjs | 30 +++- .../mobile-web-app-haptics-seam.test.mjs | 16 +- .../mobile-web-app-page-grant-call-sites.mjs | 66 +++++--- ...ile-web-app-page-grant-call-sites.test.mjs | 54 ++++-- config/scripts/pr-code-change-scope.mjs | 1 + config/scripts/pr-code-change-scope.test.mjs | 20 ++- config/scripts/pr-e2e-gate-contract.test.mjs | 3 +- config/scripts/pr-test-loc-summary.test.mjs | 2 +- .../scripts/pr-workflow-parallelism.test.mjs | 18 +- config/vitest.config.ts | 5 +- docs/reference/ci-runner-efficiency.md | 126 ++++++++++++++ 32 files changed, 908 insertions(+), 215 deletions(-) create mode 100644 .github/actions/prepare-git-compatibility/action.yml create mode 100644 .github/workflows/ci-cache-warmup.yml create mode 100644 config/scripts/ci-cache-warmup-workflow.test.mjs create mode 100644 config/scripts/ci-unit-timing-import.mjs create mode 100644 config/scripts/ci-unit-timing-reporter.mjs create mode 100644 config/scripts/ci-unit-timing-reporter.test.mjs create mode 100644 config/scripts/cloud-security-history-workflow.test.mjs diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml new file mode 100644 index 00000000000..dce54649421 --- /dev/null +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -0,0 +1,30 @@ +name: Prepare baseline Git compatibility binary +description: Restore or build the pinned Linux Git binary for the compatibility contract. + +runs: + using: composite + steps: + - name: Cache baseline Git build + uses: actions/cache@v5 + with: + path: ~/.cache/orca-git-compat/git-2.25.5 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 + + # Finish the CPU-heavy build before any timed compatibility lanes start. + - name: Build the baseline Git binary + shell: bash + run: | + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$HOME/.cache/orca-git-compat/git-2.25.5" + if [ -x "$source/git" ]; then + exit 0 + fi + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j"$(nproc)" \ + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + # Object files are no longer needed after linking the cached binary. + find "$source" -name '*.o' -delete diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml new file mode 100644 index 00000000000..9066dd266a3 --- /dev/null +++ b/.github/workflows/ci-cache-warmup.yml @@ -0,0 +1,65 @@ +name: Warm shared CI caches + +on: + schedule: + - cron: '41 * * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-git-compatibility/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'config/tsconfig*.json' + - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/rebuild-native-deps.mjs' + - 'config/patches/node-pty@1.1.0.patch' + - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + - 'native/windows-registry/**' + pull_request: + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/prepare-git-compatibility/**' + - 'config/scripts/ci-cache-warmup-workflow.test.mjs' + +permissions: + contents: read + +concurrency: + group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + warm: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Default-branch caches can be restored by every PR; PR caches cannot. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + + - uses: ./.github/actions/prepare-git-compatibility + + - name: Cache TypeScript incremental state + id: typecheck-cache + uses: actions/cache@v5 + with: + path: config/*.tsbuildinfo + key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} + restore-keys: | + tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- + + - name: Refresh TypeScript state for this commit + if: steps.typecheck-cache.outputs.cache-hit != 'true' + run: pnpm run typecheck diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index 6191552dab9..02f2d1822ec 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -32,7 +32,14 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + + # Scan every ancestor of HEAD without downloading unrelated branch/tag histories. + - name: Fetch complete scan history + working-directory: . + run: | + git fetch --no-tags --unshallow origin "$GITHUB_SHA" + test "$(git rev-parse --is-shallow-repository)" = false - name: Scan Cloud history reachable from HEAD with Gitleaks run: >- diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml index 1602bae956a..6b49177ed56 100644 --- a/.github/workflows/git-command-termination-runtime.yml +++ b/.github/workflows/git-command-termination-runtime.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: git-command-termination-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-exit: runs-on: windows-latest diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index c437db7f21a..cf0ee59a842 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -12,7 +12,7 @@ permissions: jobs: apply-os-label: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Apply OS label from issue form diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 9f9df4f9b0b..592bc75bb5e 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -9,6 +9,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml index 837c38baf9a..31046737499 100644 --- a/.github/workflows/pi-provider-runtime.yml +++ b/.github/workflows/pi-provider-runtime.yml @@ -7,6 +7,9 @@ on: - '.github/workflows/pi-provider-runtime.yml' permissions: contents: read +concurrency: + group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index a884aedc636..fb2934d1f73 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -19,7 +19,8 @@ permissions: jobs: loc: name: test vs non-test LoC - runs-on: ubuntu-latest + # API calls and a small Node script fit the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 2 steps: # Why no checkout: the Files API already has per-file additions/deletions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6ac6de78ed3..781617d0116 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,8 +23,10 @@ jobs: # Per-job outputs also skip git-compat/xterm/packaging/shell when those # inputs are unchanged; empty diffs fail closed and run everything. code_paths: - name: detect code-relevant changes - runs-on: ubuntu-latest + name: detect changes and check repository guards + # Reuse one lightweight checkout for detection and the always-required guards. + runs-on: ubuntu-slim + timeout-minutes: 5 outputs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} @@ -56,8 +58,27 @@ jobs: # few this job actually reads on demand. fetch-depth: 0 filter: blob:none + sparse-checkout: | + /package.json + /README.md + /docs/readme/ + /.github/scripts/check-root-directory-entries.mjs + /config/scripts/check-readme-local-links.mjs + /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-e2e-source-routing.mjs + sparse-checkout-cone-mode: false persist-credentials: false + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" + + # The full Git index retains link targets outside the sparse working tree. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + - name: Classify changed paths id: filter env: @@ -249,37 +270,11 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements - root_directory_guard: - name: root directory guard - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none - persist-credentials: false - - - name: Reject new root-level files and folders - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" - - # Why here: the READMEs embed media owned by docs/site and resources/onboarding, - # and the classifier skips static_analysis for docs-only diffs. This job runs - # on every PR and needs no install. - - name: Check README local links - run: node config/scripts/check-readme-local-links.mjs - typecheck: needs: [code_paths] if: needs.code_paths.outputs.typecheck == 'true' - runs-on: ubuntu-latest + # Typechecking uses no native runtime, so it can use the free public ARM runner. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -317,39 +312,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies - # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the - # same binary on every PR for minutes of runner time. The key carries the version - # because that is the only input; the sha256 assertion below still guards the - # tarball on the miss path that actually builds. Only this PR's own later pushes - # can restore it — GitHub scopes a cache written from a pull_request run to that - # ref — so a first push always takes the build path below. - - name: Cache baseline Git build - uses: actions/cache@v5 - with: - path: ~/.cache/orca-git-compat/git-2.25.5 - key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 - - # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below - # spend their wall clock waiting on container starts, not on Git. Sharing a runner - # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so - # the build has to finish before anything timed starts. - - name: Build the baseline Git binary - run: | - archive="$RUNNER_TEMP/git-2.25.5.tar.gz" - source="$HOME/.cache/orca-git-compat/git-2.25.5" - if [ -x "$source/git" ]; then - exit 0 - fi - curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" - echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ - | sha256sum --check - mkdir -p "$source" - tar -xzf "$archive" -C "$source" --strip-components=1 - make -C "$source" -j"$(nproc)" \ - NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git - # Why: the linked binaries are what the next run needs; the objects that - # produced them are most of the tree and would bloat the cache entry. - find "$source" -name '*.o' -delete + - uses: ./.github/actions/prepare-git-compatibility - name: Verify Git binary compatibility matrix run: | @@ -603,8 +566,9 @@ jobs: test: needs: [code_paths, test_native_cache] + # Honor cancellation while allowing the optional native-cache primer to skip. if: >- - always() && + !cancelled() && needs.code_paths.outputs.test == 'true' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml @@ -1089,11 +1053,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} verify: - if: always() + if: ${{ !cancelled() }} needs: - code_paths - static_analysis - - root_directory_guard - typecheck - git_compatibility - codex_index_heal_contract @@ -1106,7 +1069,9 @@ jobs: - managed_hook_node18 - package - package_windows - runs-on: ubuntu-latest + # Evaluating job results only needs the lightweight container runner. + runs-on: ubuntu-slim + timeout-minutes: 5 steps: # Why: e2e is deliberately absent from needs. The suite is currently red on @@ -1123,7 +1088,6 @@ jobs: SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} @@ -1152,9 +1116,6 @@ jobs: if [ "$CODE_PATHS" != "success" ]; then exit 1 fi - if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then - exit 1 - fi if [ "$SHOULD_RUN" != "true" ]; then echo "Docs-only change; expensive PR checks skipped." fi diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 8014145939b..e6ab98f0d0d 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -16,7 +16,7 @@ concurrency: jobs: enforce: if: github.repository == 'stablyai/orca' - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Enforce release policy diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml index 5a5dcb670c2..7a588663fb2 100644 --- a/.github/workflows/track-community-prs.yaml +++ b/.github/workflows/track-community-prs.yaml @@ -15,7 +15,8 @@ permissions: jobs: track-community-pr: - runs-on: ubuntu-latest + # API-only bookkeeping fits the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Generate bufo-bot token diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ed5063e318..73748238f5f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -33,9 +33,6 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} cache-electron-package: 'true' - cache-dependency-path: | - pnpm-lock.yaml - cloud/pnpm-lock.yaml - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index cd56aa98c0a..1b095416708 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { deserialize, serialize } from 'node:v8' import { describe, expect, it } from 'vitest' import { MOBILE_WEB_APP_NATIVE_PARITY_STYLE, @@ -74,6 +75,25 @@ async function withScratch(run) { } } +// Snapshots preserve Buffer methods and give each assertion its own mutable copy. +let appBundleSnapshot +let writtenBundleSnapshot + +async function readAppBundle() { + appBundleSnapshot ??= bundleMobileWebApp().then(serialize) + // Deserialized Buffers alias their snapshot, so copy it before exposing them. + return deserialize(Buffer.from(await appBundleSnapshot)) +} + +async function readWrittenBundle() { + writtenBundleSnapshot ??= withScratch(async (scratch) => { + const { outDir, ...result } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + return serialize({ ...result, html }) + }) + return deserialize(await writtenBundleSnapshot) +} + describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') @@ -91,8 +111,23 @@ describe('the CRLF pin', () => { }) describeBundling('the app bundle', () => { + it('isolates read-only fixture consumers from mutations in another assertion', async () => { + const first = await readAppBundle() + const original = first.script[0] + first.script[0] ^= 255 + first.chunks.length = 0 + first.routeKeys.length = 0 + const next = await readAppBundle() + expect(next.script[0]).toBe(original) + expect(next.chunks.length).toBeGreaterThan(0) + expect(next.routeKeys.length).toBeGreaterThan(0) + const written = await readWrittenBundle() + written.manifest.assets.length = 0 + expect((await readWrittenBundle()).manifest.assets.length).toBeGreaterThan(0) + }, 120_000) + it('resolves react-native to react-native-web and leaves no require.context', async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) for (const source of sources) { expect(source).not.toContain('require.context') } @@ -101,7 +136,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('cuts the routes into chunks the entry does not load', async () => { - const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + const { script, chunks, entryStaticBytes } = await readAppBundle() expect(chunks.length).toBeGreaterThan(1) // The entry's own bytes plus the chunks it imports statically, which is what the browser // parses before any route paints. Every route chunk is outside it. @@ -112,7 +147,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('names the chunk each route lands in', async () => { - const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + const { chunks, routeChunks, routeKeys } = await readAppBundle() expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) const emitted = new Set(chunks.map((chunk) => chunk.name)) for (const [key, name] of Object.entries(routeChunks)) { @@ -202,7 +237,7 @@ describeBundling('the app bundle', () => { ) it('bundles every route module', async () => { - const { routeKeys } = await bundleMobileWebApp() + const { routeKeys } = await readAppBundle() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) }, 120_000) @@ -274,7 +309,7 @@ describeBundling('the app bundle', () => { }, 240_000) it("names an output the same way the manifest's own asset hash does", async () => { - const { script, chunks } = await bundleMobileWebApp() + const { script, chunks } = await readAppBundle() // The name is embedded in the importer, so it cannot be recomputed later; this is what says // the name inside the bytes and the manifest's sha256 of those bytes are the same string. expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) @@ -330,7 +365,7 @@ describeBundling('the app bundle', () => { // once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already // traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is // what keeps the whole shim out of the bundle, so this reads the bytes rather than the import. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { // The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export // calls that too, and it touches no DOM until something invokes it. expect(source).not.toContain('ariaHidden') @@ -340,7 +375,7 @@ describeBundling('the app bundle', () => { }, 120_000) it("ships react-native-web's hairline at one device pixel, whichever of its builds resolves", async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) // Minified, so the assignment reads `.hairlineWidth=`; RNW's own value is the literal 1. const assignments = sources.flatMap( (source) => source.match(/\.hairlineWidth=[^;]{0,120}/g) ?? [] @@ -354,7 +389,7 @@ describeBundling('the app bundle', () => { it('embeds no absolute path from this checkout', async () => { // Every chunk, not only the entry: the route manifest names each route by absolute path, and // the chunk that import resolves to is where such a path would survive. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { expect(source).not.toContain(projectDir) } }, 120_000) @@ -370,76 +405,56 @@ describeBundling('the app bundle', () => { }, 120_000) it('loads the entry as a module, so its route imports resolve', async () => { - await withScratch(async (scratch) => { - const outDir = join(scratch, 'module-tag') - const { manifest } = await buildMobileWebAppBundle({ outDir }) - const html = await readFile(join(outDir, 'index.html'), 'utf8') - // import() in a classic script is a syntax error, so the tag and the format are one fact. - expect(html).toContain('` + ` } function childPage( context: string, From bd5dca4406ff6b755474474625af8177b5821c36 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:14:03 -0700 Subject: [PATCH 318/387] fix(editor): preserve combined diff scroll on line focus (#23735) --- .../src/components/editor/DiffSectionBody.tsx | 2 + .../e2e/combined-diff-scroll-restore.spec.ts | 37 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 869d0de5a73..5ac46e17538 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -166,6 +166,8 @@ export function DiffSectionBody({ keepCurrentOriginalModel keepCurrentModifiedModel options={{ + // Native EditContext focus scrolls its hidden input into the combined viewport. + editContext: false, readOnly: !isEditable, originalEditable: false, renderSideBySide: sideBySide, diff --git a/tests/e2e/combined-diff-scroll-restore.spec.ts b/tests/e2e/combined-diff-scroll-restore.spec.ts index 496bead3db6..da02f8d0bdd 100644 --- a/tests/e2e/combined-diff-scroll-restore.spec.ts +++ b/tests/e2e/combined-diff-scroll-restore.spec.ts @@ -370,26 +370,27 @@ async function clickVisibleDiffLine(page: Page): Promise { return null } const containerRect = container.getBoundingClientRect() - const visibleLine = Array.from( - container.querySelectorAll('.monaco-diff-editor .view-line') - ).find((line) => { + for (const line of container.querySelectorAll( + '.monaco-diff-editor .view-line' + )) { const rect = line.getBoundingClientRect() - return ( - rect.height > 0 && - rect.bottom > containerRect.top && - rect.top < containerRect.bottom && - rect.right > containerRect.left && - rect.left < containerRect.right - ) - }) - if (!visibleLine) { - return null - } - const rect = visibleLine.getBoundingClientRect() - return { - x: rect.left + Math.min(12, Math.max(1, rect.width / 2)), - y: rect.top + rect.height / 2 + const left = Math.max(rect.left, containerRect.left) + const right = Math.min(rect.right, containerRect.right) + const top = Math.max(rect.top, containerRect.top) + const bottom = Math.min(rect.bottom, containerRect.bottom) + if (left >= right || top >= bottom) { + continue + } + const point = { + x: left + Math.min(12, (right - left) / 2), + y: (top + bottom) / 2 + } + // Sticky headers can cover a line whose rectangle intersects the viewport. + if (line.contains(document.elementFromPoint(point.x, point.y))) { + return point + } } + return null }) return linePoint !== null }, From d606be3adef44c80214de3c29e28717b4c80d2be Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:45:09 -0700 Subject: [PATCH 319/387] test: wait for remote terminal grid convergence after reveal (#23738) * test: wait for revealed remote PTY grid convergence * test: retain reveal diagnostics on geometry failure --- ...rminal-parked-reveal-interactivity.spec.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts index a21c3ccab0d..7c413c06126 100644 --- a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -286,7 +286,9 @@ async function probeInteractivity( `LINE:${token}`, LIVE_PAINT_BUDGET_MS ) - const paneGrid = await readActivePaneGrid(page, target.webTabId) + let paneGrid = await readActivePaneGrid(page, target.webTabId) + let ptyGrid = readPtyGridFromContent(readSink(target.sinkPath)) + console.log(`[paired-grid-initial] ${JSON.stringify({ name, paneGrid, ptyGrid })}`) const diagnostics = await readPaneDiagnostics(page, worktreeId, target.webTabId) const screenshotPath = test.info().outputPath(`paired-terminal-${name}.png`) await page.screenshot({ path: screenshotPath }) @@ -305,6 +307,31 @@ async function probeInteractivity( LIVE_PAINT_BUDGET_MS ) } + // Preserve the scenario report even when geometry fails to converge. + await expect + .configure({ soft: true }) + .poll( + async () => { + paneGrid = await readActivePaneGrid(page, target.webTabId) + ptyGrid = readPtyGridFromContent(readSink(target.sinkPath)) + return { + paneGrid, + ptyGrid, + converged: + paneGrid !== null && + ptyGrid !== null && + paneGrid.cols > 0 && + paneGrid.rows > 0 && + paneGrid.cols === ptyGrid.cols && + paneGrid.rows === ptyGrid.rows + } + }, + { + timeout: REVEAL_BUDGET_MS, + message: `${name}: revealed pane and host PTY grids did not converge` + } + ) + .toMatchObject({ converged: true }) const sink = readSink(target.sinkPath) return { name, @@ -313,7 +340,7 @@ async function probeInteractivity( paintedLive, paintedAfterFlip, paneGrid, - ptyGrid: readPtyGridFromContent(sink), + ptyGrid, diagnostics } } From da48d98040a106665d544e13f4b3e53dd73fc725 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:57:08 -0700 Subject: [PATCH 320/387] fix: bound combined diff editors and scope chat style invalidation (#23725) * fix(editor): bound offscreen combined diff rendering by height * perf: scope native chat relational styles to their ancestor --- .../combined-diff-render-range.test.ts | 55 +++++++++++++++++++ .../combined-diff-render-range.ts | 28 ++++++++++ .../use-combined-diff-virtualizer.ts | 21 ++++--- .../NativeChatBackgroundTasksStatus.tsx | 4 +- .../native-chat/NativeChatCodeBlock.tsx | 2 +- .../native-chat/NativeChatMessageRow.test.tsx | 8 +-- .../native-chat/NativeChatMessageRow.tsx | 4 +- ...veChatStructuredSession.goal-dock.test.tsx | 2 +- ...ned-diff-invalidation-freeze-repro.spec.ts | 1 + 9 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.test.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.ts diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.test.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.test.ts new file mode 100644 index 00000000000..7f58e7c2a37 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { getCombinedDiffRenderRange } from './combined-diff-render-range' + +describe('combined diff render range', () => { + it('keeps visible large sections without mounting their large neighbors', () => { + expect( + getCombinedDiffRenderRange( + { startIndex: 2, endIndex: 3, count: 8, overscan: 5 }, + () => 230_000, + 800 + ) + ).toEqual([2, 3]) + }) + + it('pre-renders at most one viewport of small sections on each side', () => { + expect( + getCombinedDiffRenderRange( + { startIndex: 4, endIndex: 5, count: 10, overscan: 5 }, + () => 200, + 400 + ) + ).toEqual([2, 3, 4, 5, 6, 7]) + }) + + it('stops at a large neighboring section instead of skipping across it', () => { + const heights = [100, 230_000, 100, 100, 230_000, 100] + expect( + getCombinedDiffRenderRange( + { startIndex: 2, endIndex: 2, count: heights.length, overscan: 5 }, + (index) => heights[index], + 800 + ) + ).toEqual([2, 3]) + }) + + it('retains the row-count cap and list boundaries for short sections', () => { + expect( + getCombinedDiffRenderRange( + { startIndex: 1, endIndex: 2, count: 100, overscan: 5 }, + () => 28, + 800 + ) + ).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + }) + + it('renders only visible sections before the viewport is measured', () => { + expect( + getCombinedDiffRenderRange( + { startIndex: 1, endIndex: 2, count: 10, overscan: 5 }, + () => 88, + 0 + ) + ).toEqual([1, 2]) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.ts new file mode 100644 index 00000000000..0e57d785e86 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-render-range.ts @@ -0,0 +1,28 @@ +import type { Range } from '@tanstack/react-virtual' + +export function getCombinedDiffRenderRange( + range: Range, + estimateHeight: (index: number) => number, + viewportHeight: number +): number[] { + let start = range.startIndex + let end = range.endIndex + const budget = Math.max(0, viewportHeight) + let height = 0 + while (start > 0 && range.startIndex - start < range.overscan) { + height += estimateHeight(start - 1) + if (height > budget) { + break + } + start -= 1 + } + height = 0 + while (end + 1 < range.count && end - range.endIndex < range.overscan) { + height += estimateHeight(end + 1) + if (height > budget) { + break + } + end += 1 + } + return Array.from({ length: end - start + 1 }, (_, offset) => start + offset) +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts index d9874ea9265..4cca1137aff 100644 --- a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts @@ -4,6 +4,7 @@ import { elementScroll, useVirtualizer, type Virtualizer } from '@tanstack/react import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' import type { DiffSection } from '../../diff-section-types' import { getDiffSectionRowEstimatedHeight } from '../../diff-section-layout' +import { getCombinedDiffRenderRange } from './combined-diff-render-range' const COMBINED_DIFF_OVERSCAN = 5 @@ -28,17 +29,21 @@ export function useCombinedDiffVirtualizer({ sections: DiffSection[] sideBySide: boolean }): Virtualizer { + const estimateSize = (index: number): number => { + const section = sections[index] + return section ? getDiffSectionRowEstimatedHeight(section, sectionHeights[index]) : 88 + } const virtualizer = useVirtualizer({ count: sections.length, getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const section = sections[index] - if (!section) { - return 88 - } - - return getDiffSectionRowEstimatedHeight(section, sectionHeights[index]) - }, + estimateSize, + // Offscreen Monaco sections render their full height; bound that work by pixels, too. + rangeExtractor: (range) => + getCombinedDiffRenderRange( + range, + estimateSize, + scrollContainerRef.current?.clientHeight ?? 0 + ), overscan: COMBINED_DIFF_OVERSCAN, initialOffset: () => scrollOffsetRef.current, // Why: mark every virtualizer-issued scroll so events are attributed to the user only when this code didn't cause them. diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 2e8cf51f18f..de9ba8c655c 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -178,10 +178,10 @@ export function NativeChatBackgroundTasksStatus(props: { className="group/tasks shrink-0 bg-background px-3 pt-2 sm:px-4" > {/* When the goal tab is the next sibling, take its width and share its top edge. */} -
+
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 996716a838f..7097cb87aa4 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -60,9 +60,9 @@ describe('MessageRow control visibility', () => { 'can-hover:opacity-0', 'can-hover:pointer-events-none', 'group-hover:opacity-100', - 'group-has-[:focus-visible]:opacity-100', + '[.group:has(:focus-visible)_&]:opacity-100', 'group-hover:pointer-events-auto', - 'group-has-[:focus-visible]:pointer-events-auto' + '[.group:has(:focus-visible)_&]:pointer-events-auto' ) expect(copy.parentElement).not.toHaveClass('opacity-0', 'pointer-events-none') expect(time).not.toHaveAttribute('tabindex') @@ -79,9 +79,9 @@ describe('MessageRow control visibility', () => { 'can-hover:opacity-0', 'can-hover:pointer-events-none', 'group-hover:opacity-100', - 'group-has-[:focus-visible]:opacity-100', + '[.group:has(:focus-visible)_&]:opacity-100', 'group-hover:pointer-events-auto', - 'group-has-[:focus-visible]:pointer-events-auto' + '[.group:has(:focus-visible)_&]:pointer-events-auto' ) expect(copy.parentElement).not.toHaveClass('opacity-0', 'pointer-events-none') expect(copy.parentElement!.parentElement).toHaveClass('group') diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index c6505f86bf6..59e70cfc7e1 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -172,7 +172,7 @@ export const MessageRow = memo(function MessageRow({ {/* Copy + timestamp reveal together, mirroring the agent controls row. Image-only prompts have no text to copy, so the button is omitted. */} {markdown || message.timestamp !== null ? ( -
+
{markdown ? : null}
@@ -278,7 +278,7 @@ export const MessageRow = memo(function MessageRow({ markdown={markdown} timestamp={message.timestamp} onScrollToTop={scrollToTop} - className="mt-1 -mb-5 w-fit select-none transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-has-[:focus-visible]:pointer-events-auto group-has-[:focus-visible]:opacity-100" + className="mt-1 -mb-5 w-fit select-none transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100" /> ) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx index 471f1b636d2..c3c08f6647e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx @@ -35,7 +35,7 @@ const STRIP = '[data-native-chat-background-tasks]' const GOAL = '[data-native-chat-thread-goal]' // The seam is CSS on DOM adjacency: the strip styles itself when a goal tab follows // it, and the goal tab styles itself when the strip precedes it. -const STRIP_DOCK_RULE = /^group-has-\[\+\[([a-z-]+)\]\]\/tasks:(.+)$/ +const STRIP_DOCK_RULE = /^\[\[data-native-chat-background-tasks\]:has\(\+\[([a-z-]+)\]\)_&\]:(.+)$/ const GOAL_DOCK_RULE = /^group-\[\[([a-z-]+)\]\+&\]\/goal:(.+)$/ function dockRules(root: Element, rule: RegExp): { attribute: string; utility: string }[] { diff --git a/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts b/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts index cb11acf25f3..99b6b939f6d 100644 --- a/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts +++ b/tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts @@ -299,6 +299,7 @@ test.describe('Combined diff invalidation freeze repro (STA-3420)', () => { ) console.log(`external-change burst measurement ${JSON.stringify(measurement)}`) + expect(measurement.sectionRowCount).toBe(1) expect(measurement.stuckLoadingRowCount).toBe(0) expect(measurement.editorCount).toBeGreaterThan(0) // Why: before the fix this window blocked continuously — p95 3963ms, 16 samples in 23s. From ccdb324b63e4942891942edde1216586a51774bd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:11:25 -0700 Subject: [PATCH 321/387] Add CodeBuddy as a built-in coding agent (#23740) * feat(agents): integrate CodeBuddy launch, status and session history * docs: record CodeBuddy lifecycle verification * fix(codebuddy): backfill scoped history and negotiate remote resume * test(cli): include CodeBuddy in known search agents --- README.md | 1 + config/tsconfig.cli.json | 1 + docs/reference/codebuddy-harness.md | 44 ++++ docs/site/content/docs/agents/supported.mdx | 1 + .../components/mobile-agent-icon-assets.ts | 1 + mobile/src/tasks/mobile-tui-agents.ts | 1 + src/cli/search-command-arguments.test.ts | 2 +- .../managed-agent-hook-registry.ts | 5 + .../managed-hook-command-contract.test.ts | 17 +- ...ote-hook-service-registry-coverage.test.ts | 2 + .../remote-managed-hook-installers.ts | 2 + .../server-retired-pane-new-turn.test.ts | 1 + .../remote-session-scanner-sources.ts | 12 + .../session-cwd-bucket-layouts.test.ts | 18 +- .../ai-vault/session-cwd-bucket-layouts.ts | 27 +- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 10 + .../session-scanner-codebuddy-parser.ts | 230 ++++++++++++++++++ .../session-scanner-codebuddy-scope.test.ts | 135 ++++++++++ .../session-scanner-codebuddy-source.test.ts | 80 ++++++ .../session-scanner-log-agent-fixtures.ts | 17 ++ .../ai-vault/session-scanner-parse-cache.ts | 3 + .../session-scanner-scope-discovery.ts | 6 +- .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.ts | 12 +- src/main/claude/hook-settings.ts | 4 +- src/main/codebuddy/hook-service.test.ts | 47 ++++ src/main/codebuddy/hook-service.ts | 34 +++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/agent-catalog-platform.ts | 13 + src/renderer/src/lib/agent-catalog.tsx | 22 +- src/renderer/src/lib/agent-status.ts | 1 + ...t-resume-host-authority-capability.test.ts | 2 + .../agent-resume-host-authority-capability.ts | 2 + .../remote-agent-session-launch.test.ts | 40 +-- .../codebuddy-question-hooks.jsonl | 6 + .../codebuddy-question-hooks.meta.json | 6 + src/shared/agent-headless-command.ts | 2 + .../agent-hook-listener/provider-dispatch.ts | 11 + .../provider-event-routing.ts | 2 + .../providers/compatible-lifecycle-events.ts | 104 ++++++++ .../providers/qoder-events.ts | 82 +------ .../agent-hook-listener/source-routing.ts | 1 + src/shared/agent-hook-relay.ts | 1 + src/shared/agent-hook-types.ts | 1 + src/shared/agent-icons/codebuddy.png | Bin 0 -> 3794 bytes src/shared/agent-kind.ts | 1 + src/shared/agent-node-package-entrypoints.ts | 1 + .../agent-session-option-catalog-codebuddy.ts | 44 ++++ src/shared/agent-session-option-catalog.ts | 2 + src/shared/agent-session-resume.test.ts | 5 + src/shared/agent-session-resume.ts | 4 + src/shared/agent-type-label.ts | 1 + src/shared/ai-vault-resume-command.test.ts | 11 + src/shared/ai-vault-resume-command.ts | 1 + src/shared/ai-vault-session-deletion.ts | 1 + src/shared/ai-vault-types.ts | 2 + src/shared/codebuddy-agent.test.ts | 67 +++++ src/shared/codebuddy-headless-command.ts | 13 + src/shared/protocol-version.ts | 3 + src/shared/skills-cli-agent-keys.ts | 1 + src/shared/telemetry-property-schemas.ts | 1 + src/shared/tui-agent-config.ts | 6 +- src/shared/tui-agent-display-names.ts | 1 + src/shared/tui-agent-permissions.ts | 1 + src/shared/tui-agent-rest-signal.test.ts | 1 + src/shared/tui-agent-selection.ts | 3 +- src/shared/tui-agent.ts | 1 + 74 files changed, 1074 insertions(+), 121 deletions(-) create mode 100644 docs/reference/codebuddy-harness.md create mode 100644 src/main/ai-vault/session-scanner-codebuddy-parser.ts create mode 100644 src/main/ai-vault/session-scanner-codebuddy-scope.test.ts create mode 100644 src/main/ai-vault/session-scanner-codebuddy-source.test.ts create mode 100644 src/main/codebuddy/hook-service.test.ts create mode 100644 src/main/codebuddy/hook-service.ts create mode 100644 src/renderer/src/lib/agent-catalog-platform.ts create mode 100644 src/shared/__fixtures__/codebuddy-question-hooks.jsonl create mode 100644 src/shared/__fixtures__/codebuddy-question-hooks.meta.json create mode 100644 src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts create mode 100644 src/shared/agent-icons/codebuddy.png create mode 100644 src/shared/agent-session-option-catalog-codebuddy.ts create mode 100644 src/shared/codebuddy-agent.test.ts create mode 100644 src/shared/codebuddy-headless-command.ts diff --git a/README.md b/README.md index 0f337f9e677..93e09614609 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Autohand Code logo Autohand Code   Charm logo Charm   Cline logo Cline   + CodeBuddy logo CodeBuddy   Codebuff logo Codebuff   Freebuff logo Freebuff   Command Code logo Command Code   diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 030a90632b0..85f3529b2dc 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -9,6 +9,7 @@ "../src/main/claude/claude-hook-event-versions.ts", "../src/main/claude/claude-managed-hook-events.ts", "../src/main/qoder/hook-service.ts", + "../src/main/codebuddy/hook-service.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", diff --git a/docs/reference/codebuddy-harness.md b/docs/reference/codebuddy-harness.md new file mode 100644 index 00000000000..e59eaec855e --- /dev/null +++ b/docs/reference/codebuddy-harness.md @@ -0,0 +1,44 @@ +# CodeBuddy terminal harness + +CodeBuddy uses its own identity in the desktop and mobile agent catalogs, process +detection, telemetry, hooks, resume records and AI Vault. `codebuddy` and `cbc` +identify the interactive CLI; print, server, ACP and detached background modes do +not identify an interactive pane. Model and effort selections use `--model` and +`--effort`; the CLI resolves its stable model aliases for the current account. + +Managed hooks use the existing Claude-compatible installer and transport, with +CodeBuddy's own `.codebuddy/settings.json` and hook source. Installation preserves +user hooks and statusline configuration. Local and SSH installers share the same +plan; Windows explicitly selects CodeBuddy's supported PowerShell hook shell. +Status flows through the execution host's canonical hook store. + +## Observed lifecycle + +Verified with the authenticated CodeBuddy 2.159.0 CLI on macOS: + +- `UserPromptSubmit` starts work. `SessionStart` can arrive afterward and must not + settle that work. +- An unanswered `AskUserQuestion` emits a `Notification` with + `notification_type: permission_prompt` and the message + `needs your permission to use AskUserQuestion`. +- In this version, `PreToolUse(AskUserQuestion)` arrives **after** the user answers; + it resumes working, followed by `PostToolUse` and `Stop`. +- `Stop` supplies the final assistant message and the provider session identity + supplies the resume command. + +The sanitized real event sequence is +`src/shared/__fixtures__/codebuddy-question-hooks.jsonl`. The regression test +replays it through the shared hook listener. Qoder keeps its existing lifecycle +semantics while sharing the common event projection. + +AI Vault reads CodeBuddy's `type: message` JSONL records with top-level roles and +`input_text` / `output_text` content blocks. Local, WSL and SSH discovery use the +provider's `.codebuddy/projects` tree and the same incremental parser. + +## Validation scope + +Live macOS checks exercised launch through Orca's agent menu, model switching, +question waiting, answer submission, working and completion indicators, history +discovery and a resumed session recalling its earlier answer. Hidden-renderer CDP +screenshots record the working, question and completed states. Windows, Linux, +WSL and SSH runtime execution have not been exercised live on this machine. diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 0658ce6fd8c..ecc8cfd1305 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -45,6 +45,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Auggie | Auto-setup | [Augment](https://docs.augmentcode.com/cli/overview) | | Autohand | Auto-setup | [Autohand](https://github.com/autohandai/code-cli) | | Cline | Auto-setup | [Cline](https://docs.cline.bot/cline-cli/overview) | +| CodeBuddy | `npm install -g @tencent-ai/codebuddy-code` | [CodeBuddy](https://www.codebuddy.ai/cli) | | Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | | Freebuff | Auto-setup | [Freebuff](https://freebuff.com/cli) | | Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) | diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 8d0a6d474f7..53745ead77e 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -18,6 +18,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial = TUI_AGENT_DISPL export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = { openclaude: 'openclaude.gitlawb.com', + codebuddy: 'codebuddy.ai', grok: 'x.ai', copilot: 'github.com', opencode: 'opencode.ai', diff --git a/src/cli/search-command-arguments.test.ts b/src/cli/search-command-arguments.test.ts index be9195a1fab..27b06286f52 100644 --- a/src/cli/search-command-arguments.test.ts +++ b/src/cli/search-command-arguments.test.ts @@ -126,7 +126,7 @@ describe('orca search argument parsing', () => { it('rejects an unknown --agent and names the known ones', () => { expect(() => parseSearch(['search', 'q', '--agent', 'claude', '--agent', 'bogus'])).toThrow( - /Unknown --agent "bogus"\. Known agents: claude, codex, / + /Unknown --agent "bogus"\. Known agents: claude, codebuddy, codex, / ) }) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 31513658035..11b642ee090 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -1,3 +1,4 @@ +import { codebuddyHookService } from '../codebuddy/hook-service' import { qoderHookService } from '../qoder/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { HookInstallAgent } from '../../shared/telemetry-events' @@ -46,6 +47,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], ['qoder', () => qoderHookService.install()], + ['codebuddy', () => codebuddyHookService.install()], ['antigravity', () => antigravityHookService.install()], ['amp', () => ampHookService.install()], ['cursor', () => cursorHookService.install()], @@ -73,6 +75,7 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['codex', () => codexHookService.refreshManagedScripts()], ['gemini', () => geminiHookService.refreshManagedScripts()], ['qoder', () => qoderHookService.refreshManagedScripts()], + ['codebuddy', () => codebuddyHookService.refreshManagedScripts()], ['antigravity', () => antigravityHookService.refreshManagedScripts()], ['cursor', () => cursorHookService.refreshManagedScripts()], ['droid', () => droidHookService.refreshManagedScripts()], @@ -92,6 +95,7 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], ['qoder', () => qoderHookService.remove()], + ['codebuddy', () => codebuddyHookService.remove()], ['antigravity', () => antigravityHookService.remove()], ['amp', () => ampHookService.remove()], ['cursor', () => cursorHookService.remove()], @@ -117,6 +121,7 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], ['qoder', () => qoderHookService.getStatus()], + ['codebuddy', () => codebuddyHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], ['amp', () => ampHookService.getStatus()], ['cursor', () => cursorHookService.getStatus()], diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index b3166a6c90e..ebb1412ec58 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -96,6 +96,20 @@ const buildersByAgent = new Map([ remote: (path) => [getClaudeRemoteCommand(path)] } ], + [ + 'codebuddy', + { + local: (path) => [ + getManagedLifecycleHook(path, { + configDirName: '.codebuddy', + scriptBaseName: 'codebuddy-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }).command + ], + remote: (path) => [getClaudeRemoteCommand(path)] + } + ], [ 'codex', { @@ -224,7 +238,8 @@ describe('managed hook command contract', () => { const scannedCommand = platform === 'win32' && ((agent === 'codex' && command.startsWith('if (Test-Path')) || - (agent === 'qoder' && command.startsWith('$scriptPath = Join-Path'))) + ((agent === 'qoder' || agent === 'codebuddy') && + command.startsWith('$scriptPath = Join-Path'))) ? command .replaceAll('$LASTEXITCODE', '') .replaceAll('$env:', '') diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts index f9ff9c232e0..dea6c4a4663 100644 --- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -1,3 +1,4 @@ +import { codebuddyHookService } from '../codebuddy/hook-service' import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -53,6 +54,7 @@ describe('remote hook service registry coverage', () => { ['kimi', kimiHookService], ['muse', museHookService], ['qoder', qoderHookService], + ['codebuddy', codebuddyHookService], ['zcode', zcodeHookService], ['dsh', dshHookService] ]) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 272e330be11..b012af91ff3 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -1,3 +1,4 @@ +import { codebuddyHookService } from '../codebuddy/hook-service' import { qoderHookService } from '../qoder/hook-service' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types' @@ -64,6 +65,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['qoder', (sftp, remoteHome) => qoderHookService.installRemote(sftp, remoteHome)], + ['codebuddy', (sftp, remoteHome) => codebuddyHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index a405199a6e7..23dbb56c695 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -25,6 +25,7 @@ const NEW_TURN_EVENT: Record = { qoder: 'SessionStart', claude: 'SessionStart', kimi: 'UserPromptSubmit', + codebuddy: 'UserPromptSubmit', codex: 'SessionStart', gemini: 'BeforeAgent', antigravity: 'PreInvocation', diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index ed34e8b52a5..dfd012415d5 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -8,6 +8,7 @@ import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' import { parseClaudeSessionContent } from './session-scanner-primary-parsers' +import { parseCodebuddySessionContent } from './session-scanner-codebuddy-parser' import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' import { parseCursorSessionContent } from './session-scanner-cursor-parser' @@ -65,6 +66,17 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, + { + // The execution host owns CodeBuddy's transcripts, including subagent pruning. + ...jsonlSource( + 'codebuddy', + remoteHome, + hostPlatform, + ['.codebuddy', 'projects'], + parseCodebuddySessionContent + ), + partitionSubagentTranscripts: partitionSubagentTranscriptPaths + }, remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', diff --git a/src/main/ai-vault/session-cwd-bucket-layouts.test.ts b/src/main/ai-vault/session-cwd-bucket-layouts.test.ts index 38c2f0b68e2..7cbbbfbcc6d 100644 --- a/src/main/ai-vault/session-cwd-bucket-layouts.test.ts +++ b/src/main/ai-vault/session-cwd-bucket-layouts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { PI_CWD_BUCKET_LAYOUT } from './session-cwd-bucket-layouts' +import { PI_CWD_BUCKET_LAYOUT, CODEBUDDY_CWD_BUCKET_LAYOUT } from './session-cwd-bucket-layouts' function piDirInScope(dirName: string, scopePath: string): boolean { return PI_CWD_BUCKET_LAYOUT.isDirInScope( @@ -31,3 +31,19 @@ describe('PI_CWD_BUCKET_LAYOUT', () => { expect(piDirInScope('--C--Users-ada-repo--', 'C:\\Users\\ada\\repo')).toBe(true) }) }) + +describe('CODEBUDDY_CWD_BUCKET_LAYOUT', () => { + it.each([ + ['C:\\Users\\ada\\repo', 'C-Users-ada-repo-src'], + ['/home/ada/repo', 'home-ada-repo-src'], + ['/', 'home-ada-repo'], + [`/${'界'.repeat(100)}`, `${'界'.repeat(60)}-hash`] + ])('keeps candidate buckets for %s', (scope, bucket) => { + expect( + CODEBUDDY_CWD_BUCKET_LAYOUT.isDirInScope( + bucket, + new Set(CODEBUDDY_CWD_BUCKET_LAYOUT.encodeScopePrefixes(scope)) + ) + ).toBe(true) + }) +}) diff --git a/src/main/ai-vault/session-cwd-bucket-layouts.ts b/src/main/ai-vault/session-cwd-bucket-layouts.ts index d8b4a565949..3f0195cdcd4 100644 --- a/src/main/ai-vault/session-cwd-bucket-layouts.ts +++ b/src/main/ai-vault/session-cwd-bucket-layouts.ts @@ -3,7 +3,7 @@ import { encodeClaudeProjectPaths, isClaudeProjectDirInScope } from './claude-pr /** An agent that keeps one directory per cwd, named by a lossy encoding of that cwd. */ export type CwdBucketLayout = { - agent: Extract + agent: Extract encodeScopePrefixes: (scopePath: string) => string[] isDirInScope: (dirName: string, prefixes: ReadonlySet) => boolean } @@ -41,3 +41,28 @@ export const PI_CWD_BUCKET_LAYOUT: CwdBucketLayout = { return false } } + +// CodeBuddy 2.159.0 collapses separators and retains 180 UTF-8 bytes before long-path hashes. +function codebuddyScopePrefix(path: string): string { + const encoded = path + .replace(/[/\\:]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + let prefix = '' + for (const character of encoded) { + if (Buffer.byteLength(prefix + character, 'utf8') > 180) { + break + } + prefix += character + } + return prefix +} + +export const CODEBUDDY_CWD_BUCKET_LAYOUT: CwdBucketLayout = { + agent: 'codebuddy', + encodeScopePrefixes: (path) => [ + ...new Set([path, path.normalize('NFC')].map(codebuddyScopePrefix)) + ], + // This is only a prefilter: each transcript's cwd decides membership, including lossy collisions. + isDirInScope: (name, prefixes) => [...prefixes].some((prefix) => name.startsWith(prefix)) +} diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 80a56bf2519..95910d208b7 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,3 +1,4 @@ +import { parseCodebuddySessionFile } from './session-scanner-codebuddy-parser' import type { AiVaultSession } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { parseDevinSessionFile } from './session-scanner-devin-parser' @@ -87,6 +88,8 @@ export async function parseAgentSessionFile( signal?: AbortSignal ): Promise { switch (candidate.agent) { + case 'codebuddy': + return parseCodebuddySessionFile(candidate.file, platform, messages) case 'claude': return parseClaudeSessionFile(candidate.file, platform, messages) case 'codex': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index ee10422031b..66d069b68cc 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -34,6 +34,7 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') +const CODEBUDDY_PROJECTS_DIR = join(homedir(), '.codebuddy', 'projects') const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = resolveAbsoluteDirOverride( @@ -114,6 +115,15 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // their parent instead. directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME }, + codebuddy: { + rootDirs: (options, wslHomeDirs) => [ + options.codebuddyProjectsDir ?? CODEBUDDY_PROJECTS_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.codebuddy', 'projects')) + ], + extensions: ['.jsonl'], + // Nested subagent transcripts are not independent top-level conversations. + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, codex: { rootDirs: (options, wslHomeDirs) => uniqueCodexSessionsDirs([ diff --git a/src/main/ai-vault/session-scanner-codebuddy-parser.ts b/src/main/ai-vault/session-scanner-codebuddy-parser.ts new file mode 100644 index 00000000000..94fc52d1eab --- /dev/null +++ b/src/main/ai-vault/session-scanner-codebuddy-parser.ts @@ -0,0 +1,230 @@ +import { createInterface } from 'node:readline' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import type { FileWithMtime, ResumableSessionParseState } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' +import { + addPreviewMessage, + accumulatorSessionIdentity, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateLatestLocation, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + extractString, + normalizeTitleText, + parseJsonObject +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +// CodeBuddy stores top-level message roles and input_text/output_text blocks, not Claude records. +export type CodebuddySessionParseState = { + accumulator: ReturnType + aiTitle: string | null + firstUserTitle: string | null +} + +export function createCodebuddySessionParseState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): CodebuddySessionParseState { + return { + accumulator: createAccumulator({ + agent: 'codebuddy', + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), + aiTitle: null, + firstUserTitle: null + } +} + +export function cloneCodebuddySessionParseState( + state: CodebuddySessionParseState +): CodebuddySessionParseState { + return { + accumulator: { + ...state.accumulator, + previewMessages: [...state.accumulator.previewMessages] + }, + aiTitle: state.aiTitle, + firstUserTitle: state.firstUserTitle + } +} + +// CodeBuddy conversation content is an array of typed text blocks; join the +// text ones. Non-text blocks (tool calls, diffs) carry no preview copy. +function codebuddyContentText(content: unknown): string | null { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return null + } + const parts = content + .map((block) => { + const record = asRecord(block) + const type = extractString(record?.type) + return type === 'input_text' || type === 'output_text' || type === 'text' + ? extractString(record?.text) + : null + }) + .filter((text): text is string => typeof text === 'string' && text.length > 0) + return parts.length > 0 ? parts.join('\n\n') : null +} + +export function consumeCodebuddySessionLine(state: CodebuddySessionParseState, line: string): void { + const { accumulator } = state + const record = parseJsonObject(line) + if (!record) { + return + } + + if (typeof record.sessionId === 'string' && record.sessionId.trim()) { + accumulator.sessionId = record.sessionId.trim() + } + updateTimeline(accumulator, record.timestamp) + updateLatestLocation(accumulator, record) + + // The CLI's own session title; later summaries revise it, like Claude's + // custom-title. + if (record.type === 'summary') { + const summary = normalizeTitleText(extractString(record.summary) ?? '') + if (summary) { + accumulator.title = summary + } + return + } + + if (record.type === 'ai-title') { + const title = normalizeTitleText(extractString(record.aiTitle) ?? '') + if (title) { + // CodeBuddy can revise generated names; AI Vault mirrors the current one. + state.aiTitle = title + } + return + } + + if (record.type !== 'message') { + return + } + + const role = extractString(record.role) + const text = codebuddyContentText(record.content) + accumulator.messageCount++ + if (role === 'user') { + // Meta prompts (injected context) only seed the last-resort title. + // `providerData.skipRun` marks machinery-injected turns that never reached + // the model as a user prompt. + const isMetaUserTurn = + asRecord(record.providerData)?.skipRun === true || + (text != null && isKnownHarnessInjectedUserTurnText(text)) + addPreviewMessage(accumulator, { + role: 'user', + text, + timestamp: record.timestamp, + seedFirstUserPrompt: !isMetaUserTurn + }) + if (text && !isMetaUserTurn) { + state.firstUserTitle ??= text + } + return + } + if (role === 'assistant') { + addPreviewMessage(accumulator, { role: 'assistant', text, timestamp: record.timestamp }) + const model = extractString(asRecord(record.providerData)?.model) + if (model) { + accumulator.model = model + } + } +} + +export function finalizeCodebuddySessionParseState( + state: CodebuddySessionParseState, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + // Finalize a snapshot: the live state (and its preview array) may keep + // accumulating appended lines after this session object is handed out. + const snapshot = cloneCodebuddySessionParseState(state) + // Why: the CLI's summary title wins via accumulator.title; the generated + // ai-title should outrank the raw first prompt when present. + snapshot.accumulator.fallbackTitle = snapshot.aiTitle ?? snapshot.firstUserTitle + return finalizeSession(snapshot.accumulator, platform, options) +} + +function codebuddyResumeStateFromParseState( + state: CodebuddySessionParseState +): ResumableSessionParseState { + return { + consumeLine: (line) => consumeCodebuddySessionLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => codebuddyResumeStateFromParseState(cloneCodebuddySessionParseState(state)), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => finalizeCodebuddySessionParseState(state, platform, options) + } +} + +export function createCodebuddySessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { + return codebuddyResumeStateFromParseState(createCodebuddySessionParseState(file, messages)) +} + +export async function parseCodebuddySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink +): Promise { + const lines = createInterface({ + input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), + crlfDelay: Infinity + }) + return parseCodebuddySessionLines({ file, lines, platform, messages }) +} + +export async function parseCodebuddySessionContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {}, + signal?: AbortSignal +): Promise { + return parseCodebuddySessionLines({ + file, + lines: remoteSessionContentLines(content, signal), + platform, + options + }) +} + +async function parseCodebuddySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions + messages?: TranscriptMessageSink +}): Promise { + const state = createCodebuddySessionParseState(args.file, args.messages) + for await (const line of args.lines) { + consumeCodebuddySessionLine(state, line) + } + return finalizeCodebuddySessionParseState(state, args.platform, args.options) +} diff --git a/src/main/ai-vault/session-scanner-codebuddy-scope.test.ts b/src/main/ai-vault/session-scanner-codebuddy-scope.test.ts new file mode 100644 index 00000000000..9086ab1e811 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codebuddy-scope.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + resetProjectDirCwdCacheForTests() +}) + +function codebuddySessionDirName(cwd: string): string { + return cwd + .replace(/[/\\:]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +async function writeCodeBuddySession(args: { + sessionsDir: string + cwd: string + id: string + timestamp: string +}): Promise { + const dir = join(args.sessionsDir, codebuddySessionDirName(args.cwd)) + await mkdir(dir, { recursive: true }) + const file = join(dir, `${args.id}.jsonl`) + await writeFile( + file, + jsonLines([ + { + type: 'message', + timestamp: args.timestamp, + sessionId: args.id, + cwd: args.cwd, + role: 'user', + content: [{ type: 'input_text', text: `prompt ${args.id}` }] + } + ]) + ) + const time = new Date(args.timestamp) + await utimes(file, time, time) +} + +describe('scanAiVaultSessions — CodeBuddy scope discovery', () => { + it('lists an older in-scope CodeBuddy session the recency cap would drop', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codebuddy-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspace = '/home/ada/orca/workspaces/orca/feature' + + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: `${workspace}/packages/app`, + id: 'old-in-scope', + timestamp: '2026-05-01T10:00:00.000Z' + }) + // Newer, out of scope, and a sibling whose encoding shares the workspace prefix. + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: '/home/ada/other', + id: 'recent-elsewhere', + timestamp: '2026-06-01T10:00:00.000Z' + }) + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: `${workspace}-sibling`, + id: 'sibling', + timestamp: '2026-04-01T10:00:00.000Z' + }) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 1, + scopePaths: [workspace] + }) + const ids = result.sessions.map((session) => session.sessionId) + + expect(ids).toContain('old-in-scope') + expect(ids).toContain('recent-elsewhere') + expect(ids).not.toContain('sibling') + expect(result.sessions.find((session) => session.sessionId === 'old-in-scope')?.agent).toBe( + 'codebuddy' + ) + }) + + it('finds an older session when another cwd shares its encoded bucket', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codebuddy-collision-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + for (const [cwd, id, timestamp] of [ + ['/home/ada/repo/app', 'in-scope', '2026-04-01T10:00:00.000Z'], + ['/home/ada/repo-app', 'collision', '2026-05-01T10:00:00.000Z'], + ['/home/ada/elsewhere', 'recent', '2026-06-01T10:00:00.000Z'] + ]) { + await writeCodeBuddySession({ sessionsDir: roots.codebuddyProjectsDir, cwd, id, timestamp }) + } + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 1, + scopePaths: ['/home/ada/repo/app'] + }) + expect(result.sessions.map((session) => session.sessionId)).toContain('in-scope') + expect(result.sessions.map((session) => session.sessionId)).not.toContain('collision') + }) + + it('adds nothing when no scope is requested', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codebuddy-noscope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: '/home/ada/old', + id: 'old', + timestamp: '2026-05-01T10:00:00.000Z' + }) + await writeCodeBuddySession({ + sessionsDir: roots.codebuddyProjectsDir, + cwd: '/home/ada/new', + id: 'new', + timestamp: '2026-06-01T10:00:00.000Z' + }) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1 }) + + expect(result.sessions.map((session) => session.sessionId)).toEqual(['new']) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codebuddy-source.test.ts b/src/main/ai-vault/session-scanner-codebuddy-source.test.ts new file mode 100644 index 00000000000..a171ac6d671 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codebuddy-source.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions — CodeBuddy transcripts', () => { + it('discovers ~/.codebuddy/projects transcripts and attributes them to codebuddy', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codebuddy-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(join(roots.codebuddyProjectsDir, 'project'), { recursive: true }) + + await writeFile( + join(roots.codebuddyProjectsDir, 'project', 'codebuddy-session.jsonl'), + jsonLines([ + { + id: 'cb-msg-1', + timestamp: 1_777_634_400_000, + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'fix the flaky test' }], + sessionId: 'codebuddy-session', + cwd: '/repo/app' + }, + { + id: 'cb-msg-2', + timestamp: 1_777_634_405_000, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'On it' }], + providerData: { model: 'glm-5.3-flash' }, + sessionId: 'codebuddy-session', + cwd: '/repo/app' + }, + { + id: 'cb-title-1', + timestamp: 1_777_634_406_000, + type: 'ai-title', + aiTitle: 'Fixing the flaky test', + sessionId: 'codebuddy-session', + cwd: '/repo/app' + }, + { + id: 'cb-summary-1', + timestamp: 1_777_634_407_000, + type: 'summary', + summary: 'Fix the flaky test', + providerData: { source: 'initial-user-message' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + const codebuddy = result.sessions.find((session) => session.agent === 'codebuddy') + expect(codebuddy).toMatchObject({ + sessionId: 'codebuddy-session', + cwd: '/repo/app', + title: 'Fix the flaky test', + model: 'glm-5.3-flash', + resumeCommand: "cd '/repo/app' && codebuddy --resume 'codebuddy-session'", + filePath: join(roots.codebuddyProjectsDir, 'project', 'codebuddy-session.jsonl') + }) + expect(codebuddy?.messageCount).toBe(2) + expect(codebuddy?.previewMessages.map((message) => message.text)).toEqual([ + 'fix the flaky test', + 'On it' + ]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts index ed2cf8d2eee..be080271269 100644 --- a/src/main/ai-vault/session-scanner-log-agent-fixtures.ts +++ b/src/main/ai-vault/session-scanner-log-agent-fixtures.ts @@ -32,6 +32,23 @@ export async function writeLogAgentFixtures( ]) ) + // CodeBuddy writes its own message-record shape under its own root. + await mkdir(join(roots.codebuddyProjectsDir, 'project'), { recursive: true }) + await writeFile( + join(roots.codebuddyProjectsDir, 'project', 'codebuddy-session.jsonl'), + jsonlBody([ + { + id: 'codebuddy-msg-1', + timestamp: 1_777_634_430_000, + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'CodeBuddy title' }], + sessionId: 'codebuddy-session', + cwd: '/tmp/codebuddy' + } + ]) + ) + await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true }) await writeFile( join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'), diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 4610c198cb3..a00f4edd2cf 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -5,6 +5,7 @@ import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers' import { createClaudeSessionResumeState } from './session-scanner-primary-parsers' +import { createCodebuddySessionResumeState } from './session-scanner-codebuddy-parser' import { createGeminiJsonlSessionResumeState } from './session-scanner-gemini-parsers' import { createCopilotSessionResumeState } from './session-scanner-copilot-parser' import { createCursorSessionResumeState } from './session-scanner-cursor-parser' @@ -51,6 +52,8 @@ function resumableStateFactoryFor( switch (candidate.agent) { case 'claude': return (messages) => createClaudeSessionResumeState(candidate.file, messages) + case 'codebuddy': + return (messages) => createCodebuddySessionResumeState(candidate.file, messages) case 'codex': return (messages) => createCodexSessionResumeState(candidate.file, candidate.codexHome, messages) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index d9ef112f772..52795830e66 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -20,7 +20,7 @@ const REPRESENTATIVE_CWD_LINE_LIMIT = 200 const REPRESENTATIVE_FILE_LIMIT = 3 const TRANSCRIPT_EXTENSIONS = new Set(['.jsonl']) -// Both encodings are lossy; retain Claude’s existing cache and verify each Pi transcript. +// Bucket encodings are lossy; retain Claude’s cache and verify other transcripts individually. const PROJECT_DIR_CWD_CACHE_MAX = 2048 const projectDirCwdCache = new Map() @@ -75,7 +75,7 @@ type InScopeDiscoveryArgs = { } /** - * Fully include the transcripts of cwd-bucket directories (Claude, Pi) whose cwd + * Fully include the transcripts of cwd-bucket directories (Claude, Pi, CodeBuddy) whose cwd * falls inside the active workspace/project paths. * * Why: these agents keep one directory per cwd, e.g. `~/.claude/projects//`. @@ -108,7 +108,7 @@ export async function discoverInScopeCwdBucketFiles( } await collectBucketFiles({ agent: layout.agent, - scopePaths: layout.agent === 'pi' ? args.scopePaths : undefined, + scopePaths: layout.agent !== 'claude' ? args.scopePaths : undefined, projectDir, issues: args.issues, collected, diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 43d03d340dd..0d9b8cef5ab 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -54,6 +54,7 @@ export async function writeOpenCode2SqliteFixture(root: string): Promise export function isolatedScanRoots(root: string) { return { claudeProjectsDir: join(root, 'claude-projects'), + codebuddyProjectsDir: join(root, 'codebuddy-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), antigravityBrainDir: join(root, 'antigravity-brain'), diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 099b3593431..be97a4b3187 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,7 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' export type AiVaultScanOptions = { claudeProjectsDir?: string + codebuddyProjectsDir?: string codexSessionsDir?: string additionalCodexSessionsDirs?: readonly string[] // Why: tests inject a sandbox "real ~/.codex" so real-home attribution diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 2f5629f94c3..f56f17af796 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -27,7 +27,11 @@ import { getSessionParseCacheEntry } from './session-parse-cache-store' import { describeSkippedTranscriptRecords } from './session-transcript-record-budget' import { canStopParsingSessions } from './session-scan-cutoff' import { discoverInScopeCwdBucketFiles } from './session-scanner-scope-discovery' -import { CLAUDE_CWD_BUCKET_LAYOUT, PI_CWD_BUCKET_LAYOUT } from './session-cwd-bucket-layouts' +import { + CLAUDE_CWD_BUCKET_LAYOUT, + PI_CWD_BUCKET_LAYOUT, + CODEBUDDY_CWD_BUCKET_LAYOUT +} from './session-cwd-bucket-layouts' import { discoverAiVaultSessionSources } from './session-scanner-source-discovery' import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta' import type { @@ -166,7 +170,11 @@ function mergeSessions( // Agents whose on-disk layout names a directory per cwd, so a scope's older // sessions can be found without reading every transcript's header. -const CWD_BUCKET_LAYOUTS = [CLAUDE_CWD_BUCKET_LAYOUT, PI_CWD_BUCKET_LAYOUT] +const CWD_BUCKET_LAYOUTS = [ + CLAUDE_CWD_BUCKET_LAYOUT, + PI_CWD_BUCKET_LAYOUT, + CODEBUDDY_CWD_BUCKET_LAYOUT +] async function scanInScopeSessions(args: { discoveries: SessionFileDiscovery[] diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 6ce4ea4869c..381e4d26b6f 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -19,8 +19,8 @@ import { isGitBashAvailable } from '../git-bash' import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' export type ClaudeCompatibleHookSettings = { - configDirName: '.claude' | '.openclaude' | '.qoder' - scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' + configDirName: '.claude' | '.openclaude' | '.qoder' | '.codebuddy' + scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' | 'codebuddy-hook' usesWindowsCompatLauncher: boolean windowsHookShell?: 'powershell' } diff --git a/src/main/codebuddy/hook-service.test.ts b/src/main/codebuddy/hook-service.test.ts new file mode 100644 index 00000000000..e20ae46e39c --- /dev/null +++ b/src/main/codebuddy/hook-service.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as OsModule from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => sandbox.home +})) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { codebuddyHookService, CODEBUDDY_HOOK_EVENTS } from './hook-service' + +beforeAll(() => { + sandbox.home = mkdtempSync(join(tmpdir(), 'orca-codebuddy-test-')) + mkdirSync(join(sandbox.home, '.codebuddy')) +}) +afterAll(() => rmSync(sandbox.home, { recursive: true, force: true })) + +describe('CodeBuddy managed hooks', () => { + it('preserves user configuration through repeated install and removal', () => { + const path = join(sandbox.home, '.codebuddy', 'settings.json') + const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] } + const settings = { + model: 'custom', + hooks: { Stop: [userHook] }, + statusLine: { command: 'user-status' } + } + writeFileSync(path, JSON.stringify(settings)) + expect(codebuddyHookService.install().state).toBe('installed') + expect(codebuddyHookService.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + for (const event of CODEBUDDY_HOOK_EVENTS) { + expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1) + } + expect(installed.statusLine).toEqual(settings.statusLine) + expect(codebuddyHookService.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject(settings) + }) + + it('leaves malformed user settings untouched', () => { + const path = join(sandbox.home, '.codebuddy', 'settings.json') + writeFileSync(path, '{broken') + expect(codebuddyHookService.install().state).toBe('error') + expect(readFileSync(path, 'utf8')).toBe('{broken') + }) +}) diff --git a/src/main/codebuddy/hook-service.ts b/src/main/codebuddy/hook-service.ts new file mode 100644 index 00000000000..1a7c9619a34 --- /dev/null +++ b/src/main/codebuddy/hook-service.ts @@ -0,0 +1,34 @@ +import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events' +import { ClaudeHookService } from '../claude/hook-service' + +export const CODEBUDDY_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification' +] as const + +export const CODEBUDDY_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: CODEBUDDY_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })), + retire: [], + statusLine: 'leave' +} + +export const codebuddyHookService = new ClaudeHookService({ + agent: 'codebuddy', + source: 'codebuddy', + displayName: 'CodeBuddy', + settings: { + configDirName: '.codebuddy', + scriptBaseName: 'codebuddy-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: CODEBUDDY_MANAGED_HOOK_PLAN +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index caed5202f08..cbbba795d59 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -616,7 +616,8 @@ "zcode_label": "ZCode", "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", - "opencode2_label": "OpenCode 2" + "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 05d26267342..3e2132d6011 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -385,7 +385,8 @@ "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", "muse_label": "Muse", - "zcode_label": "ZCode" + "zcode_label": "ZCode", + "codebuddy_label": "CodeBuddy" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 4f591ca8bf2..53ceaf15c39 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -595,6 +595,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e4a5cd42ee..1b55d87552a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -496,6 +496,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 75f96c732ab..a5aca8c17b6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -496,6 +496,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5d3eadd54d0..47fefd2f0e3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -496,6 +496,7 @@ "mimo_code_label": "MiMo Code", "muse_label": "Muse", "opencode2_label": "OpenCode 2", + "codebuddy_label": "CodeBuddy", "zcode_label": "ZCode" }, "skill": { diff --git a/src/renderer/src/lib/agent-catalog-platform.ts b/src/renderer/src/lib/agent-catalog-platform.ts new file mode 100644 index 00000000000..9a9eac351f0 --- /dev/null +++ b/src/renderer/src/lib/agent-catalog-platform.ts @@ -0,0 +1,13 @@ +export function getCatalogPlatform(): NodeJS.Platform { + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent + if (userAgent.includes('Windows')) { + return 'win32' + } + if (userAgent.includes('Mac')) { + return 'darwin' + } + if (userAgent) { + return 'linux' + } + return typeof process === 'undefined' ? 'linux' : process.platform +} diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 56cd2180c72..325d838552d 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -1,3 +1,4 @@ +import { getCatalogPlatform } from './agent-catalog-platform' import type React from 'react' import { ClaudeIcon, DroidIcon, OpenAIIcon } from '@/components/status-bar/icons' import openClaudeLogoUrl from '../../../../resources/openclaude-logo.png?url' @@ -30,20 +31,6 @@ export type AgentCatalogEntry = { homepageUrl: string } -function getCatalogPlatform(): NodeJS.Platform { - const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent - if (userAgent.includes('Windows')) { - return 'win32' - } - if (userAgent.includes('Mac')) { - return 'darwin' - } - if (userAgent) { - return 'linux' - } - return typeof process === 'undefined' ? 'linux' : process.platform -} - export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => [ { id: 'claude', @@ -349,6 +336,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => cmd: 'openclaw', faviconDomain: 'openclaw.ai', homepageUrl: 'https://github.com/openclaw/openclaw' + }, + { + id: 'codebuddy', + label: translate('auto.lib.agent.catalog.codebuddy_label', 'CodeBuddy'), + cmd: 'codebuddy', + faviconDomain: 'codebuddy.ai', + homepageUrl: 'https://www.codebuddy.ai/cli' } ]) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 41ccc2cc7d7..04ecd728541 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -100,6 +100,7 @@ export { formatAgentTypeLabel } from '../../../shared/agent-type-label' const ICONABLE_AGENT_TYPES: Record = { claude: true, 'claude-agent-teams': true, + codebuddy: true, openclaude: true, codex: true, autohand: true, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 24393546a81..e5b6d8327ae 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, @@ -56,6 +57,7 @@ describe('agentResumeHostAuthorityCapability', () => { ) ).toEqual({ claude: undefined, + codebuddy: AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, codex: undefined, qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index efec3fe38b1..a5cefe6a74c 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, @@ -20,6 +21,7 @@ import { const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { // These shipped inside agent-session.host-authority.v1's enum, so the generic probe covers them. claude: undefined, + codebuddy: AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, codex: undefined, qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts index d8cf0c542cb..dda22ff12fd 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -45,7 +45,7 @@ describe('remote agent-session launch routing', () => { expect(legacy).not.toHaveBeenCalled() }) - it.each(['kimi', 'muse'] as const)( + it.each(['kimi', 'muse', 'codebuddy'] as const)( 'falls back to legacy when an older host lacks the %s resume capability', async (agent) => { const hostAuthority = vi.fn().mockResolvedValue('structured') @@ -70,23 +70,29 @@ describe('remote agent-session launch routing', () => { } ) - it('uses host authority when the host supports Muse resume', async () => { - const hostAuthority = vi.fn().mockResolvedValue('host') - const legacy = vi.fn() - mocks.supportsCapability.mockResolvedValue(true) + it.each(['muse', 'codebuddy'] as const)( + 'uses host authority when the host supports %s resume', + async (agent) => { + const hostAuthority = vi.fn().mockResolvedValue('host') + const legacy = vi.fn() + mocks.supportsCapability.mockResolvedValue(true) - await expect( - runRemoteAgentSessionLaunch({ - environmentId: 'env-1', - hostAuthority, - hostAuthorityCapability: agentResumeHostAuthorityCapability('muse'), - legacy - }) - ).resolves.toBe('host') - expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.muse-resume.v1') - expect(hostAuthority).toHaveBeenCalledOnce() - expect(legacy).not.toHaveBeenCalled() - }) + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority, + hostAuthorityCapability: agentResumeHostAuthorityCapability(agent), + legacy + }) + ).resolves.toBe('host') + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'env-1', + `agent-session.${agent}-resume.v1` + ) + expect(hostAuthority).toHaveBeenCalledOnce() + expect(legacy).not.toHaveBeenCalled() + } + ) it('preserves the exact legacy path when the capability is absent', async () => { const hostAuthority = vi.fn().mockResolvedValue('structured') diff --git a/src/shared/__fixtures__/codebuddy-question-hooks.jsonl b/src/shared/__fixtures__/codebuddy-question-hooks.jsonl new file mode 100644 index 00000000000..81a69a899b6 --- /dev/null +++ b/src/shared/__fixtures__/codebuddy-question-hooks.jsonl @@ -0,0 +1,6 @@ +{"hook_event_name": "UserPromptSubmit", "prompt": "Use AskUserQuestion to ask me to choose red or blue. After I answer, reply with my color and stop.", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "SessionStart", "source": "startup", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "Notification", "notification_type": "permission_prompt", "message": "needs your permission to use AskUserQuestion", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "tool_input": {"questions": [{"header": "Color", "multiSelect": false, "options": [{"description": "Choose red", "label": "Red"}, {"description": "Choose blue", "label": "Blue"}], "question": "Which color do you choose?"}]}, "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "PostToolUse", "tool_name": "AskUserQuestion", "tool_input": {"questions": [{"header": "Color", "multiSelect": false, "options": [{"description": "Choose red", "label": "Red"}, {"description": "Choose blue", "label": "Blue"}], "question": "Which color do you choose?"}]}, "tool_response": " \u00b7 Which color do you choose? \u2192 Red", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} +{"hook_event_name": "Stop", "last_assistant_message": "Red", "permission_mode": "default", "version": "2.159.0", "model": "fast-model", "session_id": "codebuddy-verification", "transcript_path": "/sessions/codebuddy-verification.jsonl"} diff --git a/src/shared/__fixtures__/codebuddy-question-hooks.meta.json b/src/shared/__fixtures__/codebuddy-question-hooks.meta.json new file mode 100644 index 00000000000..28ac3a2d76b --- /dev/null +++ b/src/shared/__fixtures__/codebuddy-question-hooks.meta.json @@ -0,0 +1,6 @@ +{ + "version": "2.159.0", + "platform": "darwin", + "model": "fast-model", + "description": "Live CLI: question notification precedes PreToolUse; PreToolUse follows the answer; SessionStart follows UserPromptSubmit. Host paths and session identifiers replaced." +} diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index f3f67535963..9f742a5355d 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,3 +1,4 @@ +import { isCodebuddyNonInteractiveCommand } from './codebuddy-headless-command' import { isQoderHeadlessCommand } from './qoder-headless-command' import { isAnteHeadlessOneShotCommand } from './ante-headless-command' import { isDshNonInteractiveCommand } from './dsh-launch-command' @@ -18,6 +19,7 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial< > = { qoder: isQoderHeadlessCommand, claude: isPrintModeHeadlessOneShotCommand, + codebuddy: isCodebuddyNonInteractiveCommand, trae: isPrintModeHeadlessOneShotCommand, 'prime-agent': isPrimeAgentHeadlessOneShotCommand, ante: isAnteHeadlessOneShotCommand, diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 067e4552212..ab7e847c4ca 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -1,3 +1,4 @@ +import { normalizeCompatibleLifecycleEvent } from './providers/compatible-lifecycle-events' import { normalizeQoderEvent } from './providers/qoder-events' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' @@ -53,6 +54,16 @@ export function normalizeProviderEvent(input: { let payload: ParsedAgentStatusPayload | null switch (source) { + case 'codebuddy': + payload = normalizeCompatibleLifecycleEvent( + source, + state, + eventName, + promptText, + paneKey, + hookPayload + ) + break case 'claude': payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload) break diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 40e59a1d820..80cd0fdc91c 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -30,6 +30,7 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo // Why: SessionStart lands an idle row (STA-3386) and must also drop stale // tool/prompt caches left by the pane's previous session. return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'codebuddy': case 'kimi': // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' @@ -143,6 +144,7 @@ export function extractToolFields( case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through + case 'codebuddy': case 'kimi': // Muse uses Claude-compatible tool fields. // falls through diff --git a/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts new file mode 100644 index 00000000000..2678dfc5e1e --- /dev/null +++ b/src/shared/agent-hook-listener/providers/compatible-lifecycle-events.ts @@ -0,0 +1,104 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +function readCompatibleState( + eventName: unknown, + payload: Record +): 'working' | 'waiting' | 'done' | null { + switch (eventName) { + case 'SessionStart': + // Compaction restarts the session during an active turn. + return ['startup', 'resume', 'clear', 'new'].includes(String(payload.source)) ? 'done' : null + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + return 'working' + case 'PreToolUse': + return isAskUserQuestionTool(readString(payload, 'tool_name')) ? 'waiting' : 'working' + case 'PermissionRequest': + return 'waiting' + case 'Stop': + case 'StopFailure': + case 'SessionEnd': + return 'done' + case 'PostCompact': + return payload.trigger === 'manual' ? 'done' : null + case 'Notification': + if ( + payload.notification_type === 'permission_prompt' || + payload.notification_type === 'elicitation_dialog' + ) { + return 'waiting' + } + return payload.notification_type === 'idle_prompt' ? 'done' : null + default: + return null + } +} + +export function normalizeCompatibleLifecycleEvent( + source: 'qoder' | 'codebuddy', + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + // CodeBuddy emits SessionStart after UserPromptSubmit, so it cannot settle a turn. + if (source === 'codebuddy' && eventName === 'SessionStart') { + return null + } + const stateName = + source === 'codebuddy' && eventName === 'PreToolUse' + ? 'working' + : readCompatibleState(eventName, hookPayload) + if (!stateName) { + return null + } + const resetOnNewTurn = isNewTurnEvent(source, eventName) + const toolFields = extractToolFields(source, eventName, hookPayload) + if ( + source === 'codebuddy' && + eventName === 'Notification' && + hookPayload.notification_type === 'permission_prompt' && + hookPayload.message === 'needs your permission to use AskUserQuestion' + ) { + toolFields.toolName = 'AskUserQuestion' + } + const snapshot = resolveToolState(state, paneKey, toolFields, { resetOnNewTurn }) + return normalizeAgentStatusPayload({ + state: stateName, + agentType: source, + prompt: resolvePrompt( + state, + paneKey, + source === 'codebuddy' && eventName === 'Notification' ? '' : promptText, + { resetOnNewTurn } + ), + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + sessionBoundary: eventName === 'SessionStart' || eventName === 'PostCompact' ? true : undefined, + interrupted: eventName === 'Stop' && hookPayload.is_interrupt === true ? true : undefined, + mainAgent: + eventName === 'StopFailure' + ? { state: 'done', outcome: 'failure', stateStartedAt: Date.now() } + : undefined + }) +} diff --git a/src/shared/agent-hook-listener/providers/qoder-events.ts b/src/shared/agent-hook-listener/providers/qoder-events.ts index bdf2a622dbe..c83923d58f7 100644 --- a/src/shared/agent-hook-listener/providers/qoder-events.ts +++ b/src/shared/agent-hook-listener/providers/qoder-events.ts @@ -1,51 +1,5 @@ -import { isAskUserQuestionTool } from '../../agent-question-answered-intent' -import { - normalizeAgentStatusPayload, - type ParsedAgentStatusPayload -} from '../../agent-status-types' +import { normalizeCompatibleLifecycleEvent } from './compatible-lifecycle-events' import type { HookListenerState } from '../listener-state' -import { - resolvePrompt, - resolveToolState, - shouldIgnoreCompactContinuationUserPromptSubmit -} from '../prompt-fields' -import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' -import { readString } from '../tool-input-preview' - -function readQoderState( - eventName: unknown, - payload: Record -): 'working' | 'waiting' | 'done' | null { - switch (eventName) { - case 'SessionStart': - // Compaction restarts the session during an active turn. - return ['startup', 'resume', 'clear', 'new'].includes(String(payload.source)) ? 'done' : null - case 'UserPromptSubmit': - case 'PostToolUse': - case 'PostToolUseFailure': - return 'working' - case 'PreToolUse': - return isAskUserQuestionTool(readString(payload, 'tool_name')) ? 'waiting' : 'working' - case 'PermissionRequest': - return 'waiting' - case 'Stop': - case 'StopFailure': - case 'SessionEnd': - return 'done' - case 'PostCompact': - return payload.trigger === 'manual' ? 'done' : null - case 'Notification': - if ( - payload.notification_type === 'permission_prompt' || - payload.notification_type === 'elicitation_dialog' - ) { - return 'waiting' - } - return payload.notification_type === 'idle_prompt' ? 'done' : null - default: - return null - } -} export function normalizeQoderEvent( state: HookListenerState, @@ -53,35 +7,13 @@ export function normalizeQoderEvent( promptText: string, paneKey: string, hookPayload: Record -): ParsedAgentStatusPayload | null { - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - const stateName = readQoderState(eventName, hookPayload) - if (!stateName) { - return null - } - const resetOnNewTurn = isNewTurnEvent('qoder', eventName) - const snapshot = resolveToolState( +) { + return normalizeCompatibleLifecycleEvent( + 'qoder', state, + eventName, + promptText, paneKey, - extractToolFields('qoder', eventName, hookPayload), - { resetOnNewTurn } + hookPayload ) - return normalizeAgentStatusPayload({ - state: stateName, - agentType: 'qoder', - prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }), - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - sessionBoundary: eventName === 'SessionStart' || eventName === 'PostCompact' ? true : undefined, - interrupted: eventName === 'Stop' && hookPayload.is_interrupt === true ? true : undefined, - mainAgent: - eventName === 'StopFailure' - ? { state: 'done', outcome: 'failure', stateStartedAt: Date.now() } - : undefined - }) } diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index 2b7f0147e60..2940685665c 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -6,6 +6,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/claude': 'claude', '/hook/codex': 'codex', '/hook/qoder': 'qoder', + '/hook/codebuddy': 'codebuddy', '/hook/gemini': 'gemini', '/hook/antigravity': 'antigravity', '/hook/amp': 'amp', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 7e0d452a66b..bb2dca99f72 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -38,6 +38,7 @@ const AGENT_HOOK_SOURCES = [ 'claude', 'codex', 'qoder', + 'codebuddy', 'gemini', 'antigravity', 'amp', diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index cc83527fc09..0bc5b67bc28 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -8,6 +8,7 @@ export const AGENT_HOOK_TARGETS = [ 'openclaude', 'codex', 'qoder', + 'codebuddy', 'gemini', 'antigravity', 'amp', diff --git a/src/shared/agent-icons/codebuddy.png b/src/shared/agent-icons/codebuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..ecaab1216f4b1edbee6aa61de58fb83dbfdc888e GIT binary patch literal 3794 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 const TUI_AGENT_KIND_BY_AGENT = { claude: 'claude-code', 'claude-agent-teams': 'claude-agent-teams', + codebuddy: 'codebuddy', openclaude: 'openclaude', codex: 'codex', autohand: 'autohand', diff --git a/src/shared/agent-node-package-entrypoints.ts b/src/shared/agent-node-package-entrypoints.ts index 905d41f95f0..7f8f9abd7cb 100644 --- a/src/shared/agent-node-package-entrypoints.ts +++ b/src/shared/agent-node-package-entrypoints.ts @@ -6,6 +6,7 @@ // new agent's install path is a data edit, as in agent-node-entrypoint-identities.ts. export const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record = { codex: ['node_modules/@openai/codex/'], + codebuddy: ['node_modules/@tencent-ai/codebuddy-code/'], gemini: ['node_modules/@google/gemini-cli/'], // Why: ZCode's npm bin is `dist/zcode.cjs`, so a package install runs as `node …zcode.cjs` // and never shows `zcode` as the foreground name (a SEA build still matches by name). diff --git a/src/shared/agent-session-option-catalog-codebuddy.ts b/src/shared/agent-session-option-catalog-codebuddy.ts new file mode 100644 index 00000000000..d0a8afa04c5 --- /dev/null +++ b/src/shared/agent-session-option-catalog-codebuddy.ts @@ -0,0 +1,44 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +const CODEBUDDY_EFFORT: CatalogOption = { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Extra high' }, + { value: 'max', label: 'Max' } + ], + defaultValue: 'medium' + }, + apply: { + launchArgs: (value) => ['--effort', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']) + } +} + +export const CODEBUDDY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + // Stable CLI aliases let CodeBuddy resolve the account's current model choices. + models: [ + { id: 'default-model', label: 'Default' }, + { id: 'fast-model', label: 'Fast' }, + { id: 'balanced-model', label: 'Balanced' }, + { id: 'primary-model', label: 'Primary' }, + { id: 'deep-model', label: 'Deep' } + ].map((model) => ({ ...model, options: [CODEBUDDY_EFFORT] })), + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']) + }, + unknownModelOptions: [CODEBUDDY_EFFORT] +} diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index a8e26f5a0ed..74a7f384801 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -1,3 +1,4 @@ +import { CODEBUDDY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-codebuddy' import type { AgentType } from './agent-status-types' import { ANTIGRAVITY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-antigravity' import { @@ -34,6 +35,7 @@ export { createClaudeCatalogOptions } const CATALOGS: AgentSessionOptionCatalogMap = { antigravity: ANTIGRAVITY_SESSION_OPTION_CATALOG, claude: CLAUDE_SESSION_OPTION_CATALOG, + codebuddy: CODEBUDDY_SESSION_OPTION_CATALOG, codex: CODEX_SESSION_OPTION_CATALOG, gemini: GEMINI_SESSION_OPTION_CATALOG, cursor: CURSOR_SESSION_OPTION_CATALOG, diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 577492ddefc..397a1a9451f 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -28,6 +28,10 @@ describe('agent session resume metadata', () => { expect(isResumableTuiAgent('kimi')).toBe(true) }) + it('treats CodeBuddy as a resumable TUI agent', () => { + expect(isResumableTuiAgent('codebuddy')).toBe(true) + }) + it.each([ ['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }], ['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }], @@ -70,6 +74,7 @@ describe('agent session resume metadata', () => { it.each([ ['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']], + ['codebuddy', { key: 'session_id', id: 's1' }, ['codebuddy', '--resume', 's1']], ['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']], ['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']], ['antigravity', { key: 'conversation_id', id: 's1' }, ['agy', '--conversation', 's1']], diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 4cbc240c764..40a3699af3c 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -5,6 +5,7 @@ import type { TuiAgent } from './tui-agent' export const RESUMABLE_TUI_AGENTS = [ 'claude', + 'codebuddy', 'codex', 'qoder', 'gemini', @@ -196,6 +197,7 @@ export function extractAgentProviderSession( // since recent Claude Code names the transcript file with a UUID that differs // from the hook session_id (so the id-based glob no longer finds it). case 'qoder': + case 'codebuddy': case 'claude': case 'codex': { const id = readSessionId(payload, ['session_id']) @@ -277,6 +279,8 @@ export function getAgentResumeArgv( ): string[] | null { const id = providerSession.id switch (agent) { + case 'codebuddy': + return providerSession.key === 'session_id' ? ['codebuddy', '--resume', id] : null case 'claude': return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null case 'codex': diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index a4d70a76037..72d1b1b6d63 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -4,6 +4,7 @@ import type { AgentType } from './agent-status-types' // (e.g. native chat's empty state on both surfaces) from one source of truth. const WELL_KNOWN_LABELS: Record = { claude: 'Claude', + codebuddy: 'CodeBuddy', openclaude: 'OpenClaude', codex: 'Codex', qoder: 'Qoder CLI', diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index da8ced39e0e..db89cb41263 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -28,6 +28,17 @@ describe('buildAiVaultResumeCommand', () => { ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') }) + it('resumes CodeBuddy sessions with the Claude-compatible --resume flag', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codebuddy', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + platform: 'darwin' + }) + ).toBe("cd '/repo/app' && codebuddy --resume 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'") + }) + it('builds a direct queued command for a live cmd shell', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index cfff419c95c..efb2ddbe5d3 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -218,6 +218,7 @@ function buildAgentResumeInvocation( return `${baseCommand} resume ${sessionArg}` case 'cline': return `${baseCommand} --id ${sessionArg}` + case 'codebuddy': case 'claude': case 'zcode': case 'cursor': diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index d3c2211ab84..4875ab6e889 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -40,6 +40,7 @@ export const AI_VAULT_DELETABLE_AGENTS = [ 'pi', 'omp', 'claude', + 'codebuddy', 'rovo', 'grok', 'cline' diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 28f93dcfbe1..e337f98d7a0 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', + 'codebuddy', 'codex', 'hermes', 'pi', @@ -51,6 +52,7 @@ export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', + codebuddy: 'CodeBuddy', codex: 'Codex', hermes: 'Hermes', pi: 'Pi', diff --git a/src/shared/codebuddy-agent.test.ts b/src/shared/codebuddy-agent.test.ts new file mode 100644 index 00000000000..6915cf8d573 --- /dev/null +++ b/src/shared/codebuddy-agent.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeAndAccept } from './agent-hook-listener-test-harness' +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { getAgentResumeArgv } from './agent-session-resume' + +describe('CodeBuddy harness', () => { + it('replays the live question lifecycle without settling at late SessionStart', () => { + const state = createHookListenerState() + const events = readFileSync( + join(__dirname, '__fixtures__/codebuddy-question-hooks.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + const rows = events.map((event) => normalizeAndAccept(state, 'codebuddy', event)) + expect(rows.map((row) => row?.payload.state ?? null)).toEqual([ + 'working', + null, + 'waiting', + 'working', + 'working', + 'done' + ]) + expect(rows[2]?.payload).toMatchObject({ agentType: 'codebuddy', toolName: 'AskUserQuestion' }) + expect(rows[5]?.payload).toMatchObject({ agentType: 'codebuddy', lastAssistantMessage: 'Red' }) + expect(rows[5]?.providerSession?.id).toBe('codebuddy-verification') + }) + + it.each([ + 'codebuddy', + 'cbc', + 'C:\\Tools\\codebuddy.cmd', + 'node /usr/lib/node_modules/@tencent-ai/codebuddy-code/dist/codebuddy.js' + ])('recognizes interactive %s', (command) => { + expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('codebuddy') + }) + + it('excludes print runs and resumes the original provider session', () => { + for (const flag of ['--print', '--serve', '--acp', '--bg', '--background']) { + expect(recognizeAgentProcessFromCommandLine(`codebuddy ${flag}`)).toBeNull() + } + expect(recognizeAgentProcessFromCommandLine('codebuddy -- --serve')?.agent).toBe('codebuddy') + expect(getAgentResumeArgv('codebuddy', { key: 'session_id', id: 'session-1' })).toEqual([ + 'codebuddy', + '--resume', + 'session-1' + ]) + }) + + it('passes explicit model and effort preferences through the CLI flags', () => { + const catalog = getAgentSessionOptionCatalog('codebuddy') + expect(catalog?.modelApply.launchArgs?.('fast-model')).toEqual(['--model', 'fast-model']) + expect(catalog?.unknownModelOptions?.[0].apply.launchArgs?.('high')).toEqual([ + '--effort', + 'high' + ]) + expect(catalog?.modelApply.removeAgentArgs?.(['--model', 'old', '--', 'prompt'])).toEqual([ + '--', + 'prompt' + ]) + }) +}) diff --git a/src/shared/codebuddy-headless-command.ts b/src/shared/codebuddy-headless-command.ts new file mode 100644 index 00000000000..5c632b27b3b --- /dev/null +++ b/src/shared/codebuddy-headless-command.ts @@ -0,0 +1,13 @@ +import { agentArgOptionTokens } from './agent-session-option-agent-args' +import { isPrintModeHeadlessOneShotCommand, optionName } from './print-mode-headless-command' + +const NON_INTERACTIVE_FLAGS = new Set(['--serve', '--acp', '--bg', '--background']) + +export function isCodebuddyNonInteractiveCommand(tokens: readonly string[]): boolean { + return ( + isPrintModeHeadlessOneShotCommand(tokens) || + agentArgOptionTokens(tokens) + .slice(1) + .some((token) => NON_INTERACTIVE_FLAGS.has(optionName(token))) + ) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index bd8b3f62a31..3a877a80900 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -236,6 +236,8 @@ export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = 'agent-session.opencode2-resume.v1' as const export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const +export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY = + 'agent-session.codebuddy-resume.v1' as const export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY = 'agent-session.qoder-resume.v1' as const export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY = @@ -402,6 +404,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index f8d1a913172..1955bfa6d58 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -14,6 +14,7 @@ import type { TuiAgent } from './tui-agent' export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { claude: 'claude-code', 'claude-agent-teams': 'claude-code', + codebuddy: 'codebuddy', // Why: Orca states OpenClaude reads Claude-owned roots (native-chat-agent-profiles). openclaude: 'claude-code', codex: 'codex', diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index cee76b677d6..89f62138407 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -10,6 +10,7 @@ import type { GlobalSettings } from './global-settings-types' export const AGENT_KIND_VALUES = [ 'claude-code', 'claude-agent-teams', + 'codebuddy', 'openclaude', 'codex', 'autohand', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 15318913968..27cda1d1979 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -97,6 +97,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', pasteNeedsTypedRequest: true }, + codebuddy: { + detectCmd: 'codebuddy', + detectCmdAliases: ['cbc'], + promptInjectionMode: 'argv' + }, openclaude: { detectCmd: 'openclaude', promptInjectionMode: 'argv', @@ -148,7 +153,6 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'opencode2', // The private server inherits this pane's hook endpoint and identity. launchCmd: 'opencode2 --standalone', - expectedProcess: 'opencode2', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', draftPasteReadyTimeoutMs: 20_000 diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index a90daa3242f..31b6035a953 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -8,6 +8,7 @@ import type { TuiAgent } from './tui-agent' export const TUI_AGENT_DISPLAY_NAMES: Record = { claude: 'Claude', 'claude-agent-teams': 'Claude Agent Teams', + codebuddy: 'CodeBuddy', openclaude: 'OpenClaude', codex: 'Codex', devin: 'Devin', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 3a9912bf066..9fc8eb0a691 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -5,6 +5,7 @@ export type AgentPermissionMode = 'yolo' | 'manual' | 'mixed' export const YOLO_TUI_AGENT_ARGS: Partial> = { claude: '--dangerously-skip-permissions', + codebuddy: '--dangerously-skip-permissions', 'claude-agent-teams': '--dangerously-skip-permissions', openclaude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', diff --git a/src/shared/tui-agent-rest-signal.test.ts b/src/shared/tui-agent-rest-signal.test.ts index f7d11032875..6e573acc0b0 100644 --- a/src/shared/tui-agent-rest-signal.test.ts +++ b/src/shared/tui-agent-rest-signal.test.ts @@ -29,6 +29,7 @@ const EXPECTED_REST_SIGNALS: Record = { grok: 'title', muse: 'ready-body', qoder: 'ready-body', + codebuddy: 'none', autohand: 'none', ante: 'none', trae: 'none', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 85567dcb334..acf305dad59 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -45,7 +45,8 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'rovo', 'hermes', 'devin', - 'openclaw' + 'openclaw', + 'codebuddy' ] as const satisfies readonly TuiAgent[] // Why: fresh installs should expose Claude Agent Teams in agent pickers; the diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 19722220888..a98c45101fd 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -3,6 +3,7 @@ export type TuiAgent = | 'claude' // Claude Code | 'claude-agent-teams' // Claude Code Agent Teams via Orca native panes + | 'codebuddy' // CodeBuddy Code | 'openclaude' // OpenClaude | 'codex' // OpenAI Codex | 'autohand' // Autohand Code CLI From 28942eed5af3410e924f171d230db9452a2c9dc6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:16:37 -0400 Subject: [PATCH 322/387] test(mobile): repin the RPC recording corpus to main after #22762 (#23757) #22762 squash-merged as 29c7d5d9835 with the corpus pinned to its branch commit 03995ae29d2, which the squash left unreachable from main. Repin baseline to main's tip and re-record the full corpus in place; only the baseline header moves on every golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../rpc-foundation/goldens/aivault-history-scan-fulfilled.json | 2 +- .../goldens/aivault-history-scan-unsupported.json | 2 +- .../goldens/aivault-history-scan-worktrees-late.json | 2 +- .../rpc-foundation/goldens/aivault-history-screen-listed.json | 2 +- .../goldens/aivault-history-screen-worktrees.json | 2 +- .../goldens/aivault-resume-launch-create-refused.json | 2 +- .../goldens/aivault-resume-launch-invalid-tab.json | 2 +- mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json | 2 +- mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json | 2 +- .../rpc-foundation/goldens/aivault-resume-prepare-refused.json | 2 +- mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json | 2 +- .../rpc-foundation/goldens/aivault-resume-prepare-skipped.json | 2 +- .../goldens/aivault-resume-prepare-unavailable.json | 2 +- mobile/rpc-foundation/goldens/b1.json | 2 +- mobile/rpc-foundation/goldens/b2.json | 2 +- mobile/rpc-foundation/goldens/b3.json | 2 +- mobile/rpc-foundation/goldens/browser-dialog-accepted.json | 2 +- mobile/rpc-foundation/goldens/browser-dialog-dismissed.json | 2 +- mobile/rpc-foundation/goldens/browser-keyboard-input.json | 2 +- .../rpc-foundation/goldens/browser-pointer-click-accepted.json | 2 +- .../rpc-foundation/goldens/browser-pointer-click-fallback.json | 2 +- mobile/rpc-foundation/goldens/browser-wheel-scrolled.json | 2 +- .../goldens/clipboard-image-attachment-anonymous.json | 2 +- .../goldens/clipboard-image-attachment-blocked-before-send.json | 2 +- .../goldens/clipboard-image-attachment-cancelled.json | 2 +- .../goldens/clipboard-image-attachment-pasted.json | 2 +- .../goldens/clipboard-image-attachment-upload-refused.json | 2 +- .../goldens/clipboard-image-upload-aborts-on-chunk-failure.json | 2 +- .../rpc-foundation/goldens/clipboard-image-upload-chunked.json | 2 +- .../goldens/clipboard-image-upload-single-frame-fallback.json | 2 +- .../goldens/clipboard-image-upload-start-refused.json | 2 +- mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json | 2 +- mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json | 2 +- mobile/rpc-foundation/goldens/components-codex-capability.json | 2 +- mobile/rpc-foundation/goldens/components-setup-ask.json | 2 +- mobile/rpc-foundation/goldens/components-target-local.json | 2 +- mobile/rpc-foundation/goldens/components-target-ssh.json | 2 +- mobile/rpc-foundation/goldens/diff-review-branch-compare.json | 2 +- mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json | 2 +- .../goldens/diff-review-notes-refused-before-compare.json | 2 +- .../rpc-foundation/goldens/diff-review-refused-file-diff.json | 2 +- mobile/rpc-foundation/goldens/diff-review-snapshot.json | 2 +- .../rpc-foundation/goldens/diff-review-status-unavailable.json | 2 +- .../rpc-foundation/goldens/diff-review-worktree-file-diff.json | 2 +- mobile/rpc-foundation/goldens/file-tap-open-refused.json | 2 +- mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json | 2 +- .../goldens/file-tap-previews-absolute-artifact.json | 2 +- mobile/rpc-foundation/goldens/file-tap-resolve-miss.json | 2 +- mobile/rpc-foundation/goldens/file-tap-resolve-refused.json | 2 +- .../rpc-foundation/goldens/files-explorer-legacy-fallback.json | 2 +- mobile/rpc-foundation/goldens/files-explorer-readdir.json | 2 +- mobile/rpc-foundation/goldens/files-ownership-local.json | 2 +- mobile/rpc-foundation/goldens/files-ownership-ssh.json | 2 +- .../rpc-foundation/goldens/files-preview-artifact-direct.json | 2 +- .../goldens/files-preview-artifact-image-read.json | 2 +- mobile/rpc-foundation/goldens/files-preview-artifact-image.json | 2 +- mobile/rpc-foundation/goldens/files-preview-grant-refresh.json | 2 +- .../goldens/files-preview-worktree-image-read.json | 2 +- mobile/rpc-foundation/goldens/files-preview-worktree-image.json | 2 +- .../goldens/files-preview-worktree-text-read.json | 2 +- mobile/rpc-foundation/goldens/files-preview-worktree.json | 2 +- mobile/rpc-foundation/goldens/files-save-blind.json | 2 +- mobile/rpc-foundation/goldens/files-save-verified.json | 2 +- mobile/rpc-foundation/goldens/files-tab-doc-shapes.json | 2 +- mobile/rpc-foundation/goldens/home-host-accounts.json | 2 +- mobile/rpc-foundation/goldens/home-host-stats.json | 2 +- mobile/rpc-foundation/goldens/host-view-settings-sync.json | 2 +- .../goldens/host-worktree-actions-pin-open-delete.json | 2 +- mobile/rpc-foundation/goldens/host-worktree-delete-refused.json | 2 +- mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json | 2 +- .../goldens/interruptions-inventory-lifecycle.json | 2 +- .../goldens/interruptions-settings-bot-overrides-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/inventory-lifecycle.json | 2 +- mobile/rpc-foundation/goldens/inventory-repeat-query.json | 2 +- mobile/rpc-foundation/goldens/lifecycle-b3.json | 2 +- .../rpc-foundation/goldens/lifecycle-inventory-lifecycle.json | 2 +- .../goldens/lifecycle-settings-bot-overrides-fulfilled.json | 2 +- .../goldens/lifecycle-settings-task-hydration-fulfilled.json | 2 +- .../goldens/lifecycle-settings-workspace-context-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/linear-select-workspace.json | 2 +- mobile/rpc-foundation/goldens/live-worktree-name-stream.json | 2 +- ...ix-agentsession.structured-create-agentsession.create-1.json | 2 +- ...tsession.structured-create-agentsession.createsupport-1.json | 2 +- ...tsession.structured-launch-agentsession.createsupport-1.json | 2 +- .../goldens/matrix-aivault.history-aivault.listsessions-1.json | 2 +- .../goldens/matrix-aivault.history-screen-platform-status.json | 2 +- .../goldens/matrix-aivault.history-screen-status.get-2.json | 2 +- .../goldens/matrix-aivault.history-screen-worktree.ps-1.json | 2 +- .../goldens/matrix-aivault.history-status.get-1.json | 2 +- ...rix-aivault.resume-launch-session.tabs.createterminal-1.json | 2 +- .../goldens/matrix-aivault.resume-launch-terminal.send-1.json | 2 +- ...vault.resume-preparation-aivault.preparesessionresume-1.json | 2 +- .../goldens/matrix-browser.dialog-browser.dialogaccept-1.json | 2 +- .../matrix-browser.keyboard-browser.keyboardinserttext-1.json | 2 +- .../goldens/matrix-browser.keyboard-browser.keypress-1.json | 2 +- .../matrix-browser.pointer-click-browser.mouseclick-1.json | 2 +- .../matrix-browser.pointer-click-browser.mousedown-1.json | 2 +- .../matrix-browser.pointer-click-browser.mousemove-1.json | 2 +- .../goldens/matrix-browser.pointer-click-browser.mouseup-1.json | 2 +- .../goldens/matrix-browser.wheel-browser.mousemove-1.json | 2 +- .../goldens/matrix-browser.wheel-browser.mousewheel-1.json | 2 +- ...clipboard.image-attachment-clipboard.startimageupload-1.json | 2 +- ...-clipboard.image-upload-clipboard.saveimageastempfile-1.json | 2 +- ...rix-clipboard.image-upload-clipboard.startimageupload-1.json | 2 +- .../matrix-components.codex-reset-capability-status.get-1.json | 2 +- ...s.codex-reset-credit-accounts.consumecodexresetcredit-1.json | 2 +- ...ponents.execution-target-local-preflight.detectagents-1.json | 2 +- ...ponents.execution-target-preflight.detectremoteagents-1.json | 2 +- .../matrix-components.execution-target-ssh.connect-1.json | 2 +- .../matrix-components.execution-target-ssh.getstate-1.json | 2 +- ...atrix-components.new-workspace-repositories-repo.list-1.json | 2 +- .../goldens/matrix-components.setup-script-repo.hooks-1.json | 2 +- .../goldens/matrix-files.explorer-screen-files.list-1.json | 2 +- .../goldens/matrix-files.explorer-screen-files.readdir-1.json | 2 +- .../goldens/matrix-files.mutation-ownership-ssh.getstate-1.json | 2 +- .../goldens/matrix-files.mutation-ownership-status.get-1.json | 2 +- .../matrix-files.mutation-ownership-worktree.show-1.json | 2 +- ...view-artifact-image-files.readterminalartifactpreview-1.json | 2 +- .../matrix-files.preview-load-files.readterminalartifact-1.json | 2 +- .../matrix-files.preview-load-files.readterminalartifact-2.json | 2 +- .../matrix-files.preview-load-files.resolveterminalpath-1.json | 2 +- .../matrix-files.preview-save-files.readterminalartifact-1.json | 2 +- ...matrix-files.preview-save-files.writeterminalartifact-1.json | 2 +- ...matrix-files.preview-worktree-image-files.readpreview-1.json | 2 +- .../matrix-files.preview-worktree-text-files.read-1.json | 2 +- .../goldens/matrix-files.tab-doc-files.read-1.json | 2 +- .../goldens/matrix-files.tab-doc-files.readpreview-1.json | 2 +- .../rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json | 2 +- .../goldens/matrix-files.terminal-path-tap-files.open-1.json | 2 +- ...rix-files.terminal-path-tap-files.resolveterminalpath-1.json | 2 +- .../matrix-git.base-ref-chain-repo.baserefdefault-1.json | 2 +- .../goldens/matrix-git.base-ref-chain-repo.list-1.json | 2 +- .../goldens/matrix-git.base-ref-chain-worktree.show-1.json | 2 +- .../matrix-git.branch-diff-preview-git.branchdiff-1.json | 2 +- .../goldens/matrix-git.changes-load-git.branchcompare-1.json | 2 +- .../goldens/matrix-git.changes-load-git.status-1.json | 2 +- .../goldens/matrix-git.changes-load-repo.list-1.json | 2 +- .../goldens/matrix-git.changes-load-worktree.show-1.json | 2 +- ...atrix-git.commit-message-ai-git.generatecommitmessage-1.json | 2 +- .../matrix-git.history-commit-files-git.commitcompare-1.json | 2 +- .../goldens/matrix-git.history-commit-files-git.history-1.json | 2 +- .../goldens/matrix-git.history-read-git.history-1.json | 2 +- .../goldens/matrix-git.remote-prerequisite-git.push-1.json | 2 +- .../goldens/matrix-git.review-preparation-git.status-1.json | 2 +- ...rix-github.pr-comment-mutation-github.addissuecomment-1.json | 2 +- ...ub.pr-comment-mutation-github.addprreviewcommentreply-1.json | 2 +- ...ment-mutation-github.project.deleteissuecommentbyslug-1.json | 2 +- ...ment-mutation-github.project.updateissuecommentbyslug-1.json | 2 +- ...github.pr-comment-mutation-github.resolvereviewthread-1.json | 2 +- .../goldens/matrix-github.pr-mutation-github.mergepr-1.json | 2 +- .../matrix-github.pr-mutation-github.removeprreviewers-1.json | 2 +- .../matrix-github.pr-mutation-github.requestprreviewers-1.json | 2 +- .../matrix-github.pr-mutation-github.rerunprchecks-1.json | 2 +- .../matrix-github.pr-mutation-github.setprautomerge-1.json | 2 +- .../matrix-github.pr-mutation-github.updateprstate-1.json | 2 +- .../matrix-github.pr-read-github.listassignableusers-1.json | 2 +- .../goldens/matrix-github.pr-read-github.prcheckdetails-1.json | 2 +- .../goldens/matrix-github.pr-read-github.prchecks-1.json | 2 +- .../goldens/matrix-github.pr-read-github.prforbranch-1.json | 2 +- .../goldens/matrix-github.pr-read-github.reposlug-1.json | 2 +- .../goldens/matrix-github.pr-read-github.workitemdetails-1.json | 2 +- .../goldens/matrix-github.pr-read-hostedreview.forbranch-1.json | 2 +- .../matrix-github.pr-title-mutation-github.updateprtitle-1.json | 2 +- .../goldens/matrix-home.host-accounts-accounts.list-1.json | 2 +- .../goldens/matrix-home.host-stats-stats.summary-1.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-1-1.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-1-2.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-1-3.json | 2 +- ...ost-worktree-refresh-runtime.clientevents.subscribe-2-1.json | 2 +- .../goldens/matrix-host.view-settings-ui.get-1.json | 2 +- .../goldens/matrix-host.view-settings-ui.set-1.json | 2 +- .../matrix-host.worktree-actions-worktree.activate-1.json | 2 +- .../goldens/matrix-host.worktree-actions-worktree.rm-1.json | 2 +- .../goldens/matrix-host.worktree-actions-worktree.set-1.json | 2 +- .../goldens/matrix-hostedreview.create-chain-git.push-1.json | 2 +- .../matrix-hostedreview.create-chain-hostedreview.create-1.json | 2 +- .../matrix-hostedreview.create-chain-worktree.set-1.json | 2 +- .../matrix-hostedreview.create-intent-git.bulkstage-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.commit-1.json | 2 +- ...-hostedreview.create-intent-git.generatecommitmessage-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.push-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-1.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-2.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-3.json | 2 +- .../goldens/matrix-hostedreview.create-intent-git.status-4.json | 2 +- ...matrix-hostedreview.create-intent-hostedreview.create-1.json | 2 +- ...iew.create-intent-hostedreview.getcreationeligibility-1.json | 2 +- ...iew.create-intent-hostedreview.getcreationeligibility-2.json | 2 +- .../matrix-hostedreview.create-intent-worktree.set-1.json | 2 +- ...eview.eligibility-hostedreview.getcreationeligibility-1.json | 2 +- .../goldens/matrix-legacy-inventory-files.searchpaths-1.json | 2 +- .../goldens/matrix-legacy-inventory-files.searchpaths-2.json | 2 +- .../goldens/matrix-legacy-inventory-fresh-inventory.json | 2 +- .../goldens/matrix-legacy-inventory-old-inventory.json | 2 +- .../goldens/matrix-linear-detail-barrier-linear.getissue-1.json | 2 +- .../matrix-linear-detail-barrier-linear.issuecomments-1.json | 2 +- ...linear.select-workspace-picker-linear.selectworkspace-1.json | 2 +- ...x-live-worktree-name-runtime.clientevents.subscribe-1-1.json | 2 +- ...x-live-worktree-name-runtime.clientevents.subscribe-1-2.json | 2 +- ...x-live-worktree-name-runtime.clientevents.subscribe-2-1.json | 2 +- .../goldens/matrix-live-worktree-name-worktree.show-1.json | 2 +- .../goldens/matrix-live-worktree-name-worktree.show-2.json | 2 +- .../goldens/matrix-live-worktree-name-worktree.show-3.json | 2 +- .../goldens/matrix-mobileweb.bundle-fetch-app-js.json | 2 +- .../goldens/matrix-mobileweb.bundle-fetch-index-head.json | 2 +- .../goldens/matrix-mobileweb.bundle-fetch-index-tail.json | 2 +- ...trix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json | 2 +- ...x-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json | 2 +- .../goldens/matrix-nativechat.image-paste-terminal.send-1.json | 2 +- .../goldens/matrix-nativechat.image-paste-terminal.send-2.json | 2 +- ...ix-nativechat.image-upload-clipboard.startimageupload-1.json | 2 +- ...n-option-pick-settings.mutatenativechatsessionoptions-1.json | 2 +- ....terminal-write-orchestration.workerterminaluserinput-1.json | 2 +- .../matrix-nativechat.terminal-write-terminal.send-1.json | 2 +- ...fications.desktop-stream-notifications.getmissedsince-1.json | 2 +- ...otifications.desktop-stream-notifications.subscribe-1-1.json | 2 +- ...otifications.desktop-stream-notifications.subscribe-1-2.json | 2 +- ...otifications.desktop-stream-notifications.unsubscribe-1.json | 2 +- ...ifications.display-test-screen-notifications.testpush-1.json | 2 +- ...fications.push-dismissal-notifications.getmissedsince-1.json | 2 +- ...ications.push-registration-notifications.registerpush-1.json | 2 +- ...ations.push-registration-notifications.unregisterpush-1.json | 2 +- .../goldens/matrix-pairing.pre-profile-direct-status.json | 2 +- .../matrix-pairing.pre-profile-pairing.getendpoints-1.json | 2 +- .../matrix-pairing.pre-profile-pairing.provisionrelay-1.json | 2 +- .../goldens/matrix-pairing.pre-profile-relay-status.json | 2 +- ...oject-explicit-false-github.project.updateissuebyslug-1.json | 2 +- ...matrix-relay.credential-rotation-pairing.getendpoints-1.json | 2 +- ...matrix-relay.credential-rotation-pairing.getendpoints-2.json | 2 +- ...trix-relay.credential-rotation-pairing.provisionrelay-1.json | 2 +- .../matrix-relay.direct-upgrade-pairing.getendpoints-1.json | 2 +- .../matrix-relay.direct-upgrade-pairing.getendpoints-2.json | 2 +- .../matrix-relay.direct-upgrade-pairing.provisionrelay-1.json | 2 +- .../matrix-relay.pairing-recovery-pairing.getendpoints-1.json | 2 +- .../matrix-session.browser-tab-create-browser.tabcreate-1.json | 2 +- .../matrix-session.content-create-files.createfile-1.json | 2 +- .../goldens/matrix-session.content-create-files.open-1.json | 2 +- .../goldens/matrix-session.content-create-status.get-1.json | 2 +- .../goldens/matrix-session.content-create-worktree.show-1.json | 2 +- ...x-session.create-terminal-session.tabs.createterminal-1.json | 2 +- .../goldens/matrix-session.create-terminal-terminal.send-1.json | 2 +- .../goldens/matrix-session.diff-notes-worktree.show-1.json | 2 +- .../matrix-session.diff-review-actions-worktree.set-1.json | 2 +- .../goldens/matrix-session.diff-review-base-ref-show.json | 2 +- .../goldens/matrix-session.diff-review-git.branchcompare-1.json | 2 +- .../goldens/matrix-session.diff-review-git.status-1.json | 2 +- .../goldens/matrix-session.diff-review-repo.list-1.json | 2 +- .../goldens/matrix-session.diff-review-review-show.json | 2 +- .../matrix-session.markdown-disk-fallback-files.read-1.json | 2 +- ...atrix-session.markdown-disk-fallback-markdown.readtab-1.json | 2 +- .../matrix-session.markdown-save-markdown.savetab-1.json | 2 +- ...atrix-session.native-chat-page-nativechat.readsession-1.json | 2 +- ...atrix-session.native-chat-page-nativechat.subscribe-1-1.json | 2 +- ...atrix-session.native-chat-page-nativechat.subscribe-2-1.json | 2 +- .../matrix-session.native-chat-readability-repo.list-1.json | 2 +- ...ative-chat-stop-orchestration.workerterminaluserinput-1.json | 2 +- .../matrix-session.native-chat-stop-terminal.send-1.json | 2 +- .../matrix-session.native-chat-stop-terminal.send-2.json | 2 +- .../matrix-session.pr-branch-context-git.branchcompare-1.json | 2 +- .../goldens/matrix-session.pr-branch-context-git.status-1.json | 2 +- .../goldens/matrix-session.pr-branch-context-repo.list-1.json | 2 +- .../matrix-session.pr-branch-context-worktree.show-1.json | 2 +- .../goldens/matrix-session.pr-sidebar-github.prchecks-1.json | 2 +- .../goldens/matrix-session.pr-sidebar-github.prforbranch-1.json | 2 +- .../matrix-session.pr-sidebar-hostedreview.forbranch-1.json | 2 +- .../goldens/matrix-session.pr-sidebar-worktree.show-1.json | 2 +- .../goldens/matrix-session.pr-triage-agent.launchreplay-1.json | 2 +- .../matrix-session.pr-triage-preflight.detectagents-1.json | 2 +- .../goldens/matrix-session.pr-triage-repo.list-1.json | 2 +- .../goldens/matrix-session.pr-triage-settings.get-1.json | 2 +- .../matrix-session.review-branch-diff-git.branchdiff-1.json | 2 +- .../goldens/matrix-session.review-file-diff-git.diff-1.json | 2 +- .../goldens/matrix-session.review-file-diff-git.diff-2.json | 2 +- .../goldens/matrix-session.review-file-diff-git.diff-3.json | 2 +- .../matrix-session.review-git-mutations-git.discard-1.json | 2 +- .../matrix-session.review-git-mutations-git.stage-1.json | 2 +- .../matrix-session.review-git-mutations-git.stage-2.json | 2 +- .../matrix-session.review-send-sheet-session.tabs.list-1.json | 2 +- .../goldens/matrix-session.startup-worktree.activate-1.json | 2 +- .../goldens/matrix-session.startup-worktree.activate-2.json | 2 +- .../matrix-session.tab-activation-session.tabs.activate-1.json | 2 +- .../goldens/matrix-session.tab-activation-terminal.focus-1.json | 2 +- .../matrix-session.tab-close-session-session.tabs.close-1.json | 2 +- .../goldens/matrix-session.tab-close-terminal.close-1.json | 2 +- .../matrix-session.tab-documents-markdown.readtab-1.json | 2 +- .../goldens/matrix-session.tab-rename-terminal.rename-1.json | 2 +- .../matrix-session.tab-reveal-session.tabs.activate-1.json | 2 +- .../goldens/matrix-session.tab-reveal-session.tabs.list-1.json | 2 +- .../matrix-session.tabs-stream-health-session.tabs.list-1.json | 2 +- ...session.terminal-display-mode-terminal.setdisplaymode-1.json | 2 +- ...l-gesture-input-orchestration.workerterminaluserinput-1.json | 2 +- ...x-session.terminal-gesture-input-terminal.clearbuffer-1.json | 2 +- .../matrix-session.terminal-gesture-input-terminal.send-1.json | 2 +- ...inal-input-send-orchestration.workerterminaluserinput-1.json | 2 +- .../matrix-session.terminal-input-send-terminal.send-1.json | 2 +- .../matrix-session.terminal-inventory-terminal.list-1.json | 2 +- ....terminal-paste-orchestration.workerterminaluserinput-1.json | 2 +- .../goldens/matrix-session.terminal-paste-settings.get-1.json | 2 +- .../goldens/matrix-session.terminal-paste-terminal.send-1.json | 2 +- .../goldens/matrix-session.worktree-connection-repo.list-1.json | 2 +- .../matrix-session.worktree-connection-settings.get-1.json | 2 +- ...trix-settings-agent-read-preflight.detectremoteagents-1.json | 2 +- .../goldens/matrix-settings-agent-read-repo.list-1.json | 2 +- .../goldens/matrix-settings-agent-read-settings.get-1.json | 2 +- .../goldens/matrix-settings-best-effort-settings.update-1.json | 2 +- .../goldens/matrix-settings.bot-overrides-settings.get-1.json | 2 +- .../goldens/matrix-settings.home-providers-linear.status-1.json | 2 +- .../matrix-settings.home-providers-preflight.check-1.json | 2 +- .../goldens/matrix-settings.home-providers-settings.get-1.json | 2 +- ...-settings.new-tab-local-agents-preflight.detectagents-1.json | 2 +- .../matrix-settings.new-tab-local-agents-repo.list-1.json | 2 +- .../matrix-settings.new-tab-local-agents-settings.get-1.json | 2 +- ...ings.quick-commands-settings.getterminalquickcommands-1.json | 2 +- ...s.quick-commands-settings.updateterminalquickcommands-1.json | 2 +- .../goldens/matrix-settings.repo-metadata-host.platform-1.json | 2 +- .../goldens/matrix-settings.repo-metadata-repo.list-1.json | 2 +- .../goldens/matrix-settings.repo-metadata-settings.get-1.json | 2 +- ...matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json | 2 +- .../matrix-settings.resume-metadata-folderworkspace.list-1.json | 2 +- .../matrix-settings.resume-metadata-projectgroup.list-1.json | 2 +- .../goldens/matrix-settings.resume-metadata-repo.list-1.json | 2 +- .../goldens/matrix-settings.resume-metadata-settings.get-1.json | 2 +- .../goldens/matrix-settings.resume-metadata-worktree.ps-1.json | 2 +- .../goldens/matrix-settings.task-hydration-linear.status-1.json | 2 +- .../matrix-settings.task-hydration-preflight.check-1.json | 2 +- .../goldens/matrix-settings.task-hydration-settings.get-1.json | 2 +- .../goldens/matrix-settings.task-hydration-status.get-1.json | 2 +- .../goldens/matrix-settings.task-hydration-ui.get-1.json | 2 +- .../matrix-settings.task-workspace-create-settings.get-1.json | 2 +- ...matrix-settings.task-workspace-create-worktree.create-1.json | 2 +- .../goldens/matrix-settings.task-workspace-settings.get-1.json | 2 +- .../matrix-settings.workspace-context-linear.status-1.json | 2 +- .../matrix-settings.workspace-context-preflight.check-1.json | 2 +- .../matrix-settings.workspace-context-settings.get-1.json | 2 +- .../goldens/matrix-settings.workspace-context-ui.get-1.json | 2 +- .../matrix-settings.workspace-submit-settings.get-1.json | 2 +- .../matrix-speech.dictation-chunk-speech.dictation.chunk-1.json | 2 +- ...trix-speech.dictation-session-speech.dictation.finish-1.json | 2 +- ...atrix-speech.dictation-session-speech.dictation.start-1.json | 2 +- ...matrix-speech.dictation-start-speech.dictation.cancel-1.json | 2 +- .../matrix-speech.dictation-start-speech.dictation.start-1.json | 2 +- .../matrix-speech.setup-sheet-speech.dictation.setup-1.json | 2 +- .../matrix-speech.setup-sheet-speech.models.delete-1.json | 2 +- .../matrix-speech.setup-sheet-speech.models.download-1.json | 2 +- .../goldens/matrix-speech.setup-sheet-speech.models.list-1.json | 2 +- ...rix-tasks.item-checks-files-github.addprreviewcomment-1.json | 2 +- .../matrix-tasks.item-checks-files-github.prfilecontents-1.json | 2 +- .../matrix-tasks.item-checks-files-github.rerunprchecks-1.json | 2 +- ...ix-tasks.item-checks-files-github.resolvereviewthread-1.json | 2 +- ...matrix-tasks.item-checks-files-github.setprfileviewed-1.json | 2 +- ...trix-tasks.item-comment-github-github.addissuecomment-1.json | 2 +- ...trix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json | 2 +- ...trix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json | 2 +- ...atrix-tasks.item-detail-github-github.workitemdetails-1.json | 2 +- ...atrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json | 2 +- .../matrix-tasks.item-detail-linear-linear.getissue-1.json | 2 +- .../matrix-tasks.item-detail-linear-linear.issuecomments-1.json | 2 +- ...tasks.item-detail-metadata-github.listassignableusers-1.json | 2 +- .../matrix-tasks.item-detail-metadata-github.listlabels-1.json | 2 +- .../matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json | 2 +- .../matrix-tasks.item-metadata-github-github.updatepr-1.json | 2 +- .../matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json | 2 +- .../matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json | 2 +- .../matrix-tasks.item-reply-merge-github.addissuecomment-1.json | 2 +- ...tasks.item-reply-merge-github.addprreviewcommentreply-1.json | 2 +- .../goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json | 2 +- .../matrix-tasks.item-reply-merge-linear.updateissue-1.json | 2 +- .../matrix-tasks.item-review-github-github.prchecks-1.json | 2 +- ...ix-tasks.item-review-github-github.requestprreviewers-1.json | 2 +- .../matrix-tasks.item-status-gitlab-github.updateissue-1.json | 2 +- .../matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json | 2 +- ...trix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json | 2 +- .../goldens/matrix-tasks.linear-connect-linear.connect-1.json | 2 +- .../matrix-tasks.linear-item-linear.addissuecomment-1.json | 2 +- .../goldens/matrix-tasks.linear-item-linear.createissue-1.json | 2 +- .../goldens/matrix-tasks.linear-item-linear.getissue-1.json | 2 +- .../matrix-tasks.linear-team-context-linear.listteams-1.json | 2 +- .../matrix-tasks.linear-team-context-linear.teamstates-1.json | 2 +- .../goldens/matrix-tasks.paste-lookup-github.reposlug-1.json | 2 +- .../goldens/matrix-tasks.paste-lookup-github.workitem-1.json | 2 +- .../matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json | 2 +- .../matrix-tasks.paste-lookup-gitlab.workitembypath-1.json | 2 +- ...asks.project-board-load-github.project.listaccessible-1.json | 2 +- ...rix-tasks.project-board-load-github.project.listviews-1.json | 2 +- ...rix-tasks.project-board-load-github.project.listviews-2.json | 2 +- ...ix-tasks.project-board-load-github.project.resolveref-1.json | 2 +- ...rix-tasks.project-board-load-github.project.viewtable-1.json | 2 +- .../matrix-tasks.project-repo-slugs-github.reposlug-1.json | 2 +- ...w-comments-issue-github.project.addissuecommentbyslug-1.json | 2 +- ...t-row-comments-issue-github.project.updateissuebyslug-1.json | 2 +- ...omments-issue-github.project.updateissuecommentbyslug-1.json | 2 +- ...ow-comments-pr-github.project.updatepullrequestbyslug-1.json | 2 +- ...oject-row-detail-github.project.workitemdetailsbyslug-1.json | 2 +- ...asks.project-row-fields-github.project.clearitemfield-1.json | 2 +- ...oject-row-fields-github.project.updateissuetypebyslug-1.json | 2 +- ...sks.project-row-fields-github.project.updateitemfield-1.json | 2 +- ...sks.project-row-files-merge-github.addprreviewcomment-1.json | 2 +- .../matrix-tasks.project-row-files-merge-github.mergepr-1.json | 2 +- ...x-tasks.project-row-files-merge-github.prfilecontents-1.json | 2 +- ...trix-tasks.project-row-files-merge-github.updateissue-1.json | 2 +- ...ix-tasks.project-row-files-merge-github.updateprstate-1.json | 2 +- ...etadata-load-github.project.listassignableusersbyslug-1.json | 2 +- ...row-metadata-load-github.project.listissuetypesbyslug-1.json | 2 +- ...ect-row-metadata-load-github.project.listlabelsbyslug-1.json | 2 +- ...atrix-tasks.project-row-review-checks-github.prchecks-1.json | 2 +- ...s.project-row-review-checks-github.requestprreviewers-1.json | 2 +- ...-tasks.project-row-review-checks-github.rerunprchecks-1.json | 2 +- ...asks.project-row-review-checks-github.setprfileviewed-1.json | 2 +- ...trix-tasks.project-row-threads-github.addissuecomment-1.json | 2 +- ...ks.project-row-threads-github.addprreviewcommentreply-1.json | 2 +- ...t-row-threads-github.project.deleteissuecommentbyslug-1.json | 2 +- ...-tasks.project-row-threads-github.resolvereviewthread-1.json | 2 +- .../matrix-tasks.provider-load-github.countworkitems-1.json | 2 +- .../matrix-tasks.provider-load-github.listworkitems-1.json | 2 +- .../goldens/matrix-tasks.provider-load-linear.listteams-1.json | 2 +- .../goldens/matrix-tasks.provider-load-linear.status-1.json | 2 +- .../goldens/matrix-tasks.provider-load-settings.update-1.json | 2 +- .../goldens/matrix-tasks.route-repo-list-repo.list-1.json | 2 +- ...matrix-tasks.smart-source-search-github.listworkitems-1.json | 2 +- ...matrix-tasks.smart-source-search-gitlab.listworkitems-1.json | 2 +- .../matrix-tasks.smart-source-search-linear.listissues-1.json | 2 +- .../matrix-tasks.smart-source-search-linear.searchissues-1.json | 2 +- .../matrix-tasks.smart-source-search-repo.searchrefs-1.json | 2 +- .../matrix-tasks.task-create-github-github.createissue-1.json | 2 +- .../goldens/matrix-tasks.task-create-github-repo.update-1.json | 2 +- .../matrix-tasks.task-create-gitlab-gitlab.createissue-1.json | 2 +- .../matrix-tasks.task-create-linear-linear.createissue-1.json | 2 +- ...rix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json | 2 +- .../matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json | 2 +- .../matrix-tasks.task-list-linear-linear.listissues-1.json | 2 +- .../matrix-tasks.task-list-linear-linear.searchissues-1.json | 2 +- .../matrix-tasks.workspace-source-repo.searchrefs-1.json | 2 +- .../matrix-tasks.workspace-source-repo.sparsepresets-1.json | 2 +- .../matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json | 2 +- .../goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json | 2 +- ...trix-tasks.workspace-ssh-local-preflight.detectagents-1.json | 2 +- ...trix-tasks.workspace-ssh-preflight.detectremoteagents-1.json | 2 +- .../goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json | 2 +- .../goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json | 2 +- .../goldens/matrix-terminal.query-reply-terminal.send-1.json | 2 +- ...minal.raw-input-orchestration.workerterminaluserinput-1.json | 2 +- .../goldens/matrix-terminal.raw-input-terminal.send-1.json | 2 +- ...takeover-report-orchestration.workerterminaluserinput-1.json | 2 +- ...takeover-report-orchestration.workerterminaluserinput-2.json | 2 +- ...atrix-terminal.viewport-refit-terminal.updateviewport-1.json | 2 +- .../goldens/matrix-transport.capability-probe-status.get-1.json | 2 +- .../matrix-transport.host-status-gates-status.get-1.json | 2 +- .../goldens/matrix-transport.pairing-race-direct-status.json | 2 +- .../goldens/matrix-transport.pairing-race-relay-status.json | 2 +- .../matrix-worktree.agent-launch-create-agent.launch-1.json | 2 +- .../goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json | 2 +- .../goldens/matrix-worktree.create-retry-worktree.create-1.json | 2 +- .../goldens/matrix-worktree.home-catalog-worktree.ps-1.json | 2 +- .../matrix-worktree.hosted-base-worktree.resolvemrbase-1.json | 2 +- .../matrix-worktree.hosted-base-worktree.resolveprbase-1.json | 2 +- ...trix-worktree.retired-names-worktree.listretirednames-1.json | 2 +- .../goldens/matrix-worktree.review-link-worktree.set-1.json | 2 +- .../matrix-worktree.runtime-capabilities-status.get-1.json | 2 +- .../goldens/matrix-worktree.setup-hook-trust-ui.set-1.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-build-changed.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-manifest-read.json | 2 +- .../rpc-foundation/goldens/mobile-web-bundle-unavailable.json | 2 +- .../rpc-foundation/goldens/native-chat-image-paste-single.json | 2 +- .../goldens/native-chat-image-paste-stops-on-rejection.json | 2 +- .../goldens/native-chat-image-paste-trailing-image.json | 2 +- .../goldens/native-chat-image-paste-two-images.json | 2 +- .../goldens/native-chat-image-upload-cancelled.json | 2 +- .../goldens/native-chat-image-upload-second-fails.json | 2 +- .../rpc-foundation/goldens/native-chat-image-upload-single.json | 2 +- .../goldens/native-chat-image-upload-start-refused.json | 2 +- mobile/rpc-foundation/goldens/native-chat-image-upload-two.json | 2 +- mobile/rpc-foundation/goldens/native-chat-page-earlier.json | 2 +- .../goldens/native-chat-readability-local-repo.json | 2 +- .../rpc-foundation/goldens/native-chat-readability-refused.json | 2 +- .../goldens/native-chat-readability-remote-repo.json | 2 +- .../goldens/native-chat-session-option-pick-empty.json | 2 +- .../goldens/native-chat-session-option-pick-refused.json | 2 +- .../goldens/native-chat-session-option-pick-written.json | 2 +- mobile/rpc-foundation/goldens/native-chat-stop-accepted.json | 2 +- .../rpc-foundation/goldens/native-chat-stop-both-rejected.json | 2 +- .../goldens/native-chat-stop-delivery-unknown.json | 2 +- mobile/rpc-foundation/goldens/native-chat-write-accepted.json | 2 +- mobile/rpc-foundation/goldens/native-chat-write-clear-line.json | 2 +- .../goldens/native-chat-write-delivery-unknown.json | 2 +- mobile/rpc-foundation/goldens/native-chat-write-rejected.json | 2 +- .../rpc-foundation/goldens/native-chat-write-typed-command.json | 2 +- mobile/rpc-foundation/goldens/new-tab-local-agents.json | 2 +- .../goldens/new-workspace-repositories-fulfilled.json | 2 +- .../goldens/notifications-desktop-stream-closed.json | 2 +- .../goldens/notifications-desktop-stream-replayed.json | 2 +- mobile/rpc-foundation/goldens/notifications-desktop-stream.json | 2 +- .../goldens/notifications-display-test-accepted.json | 2 +- .../goldens/notifications-display-test-not-registered.json | 2 +- .../goldens/notifications-display-test-rate-limited.json | 2 +- .../goldens/notifications-display-test-unknown-reason.json | 2 +- .../goldens/notifications-push-gateway-rejected.json | 2 +- .../rpc-foundation/goldens/notifications-push-registered.json | 2 +- .../goldens/pairing-pre-profile-direct-wins-and-provisions.json | 2 +- ...ing-pre-profile-provision-unsupported-saves-direct-host.json | 2 +- .../rpc-foundation/goldens/pairing-pre-profile-times-out.json | 2 +- mobile/rpc-foundation/goldens/pr-branch-identity.json | 2 +- mobile/rpc-foundation/goldens/pr-branch-repo-context.json | 2 +- mobile/rpc-foundation/goldens/pr-comment-mutation.json | 2 +- .../rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json | 2 +- mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json | 2 +- mobile/rpc-foundation/goldens/pr-mutation-status.json | 2 +- mobile/rpc-foundation/goldens/pr-read-fork-routing.json | 2 +- mobile/rpc-foundation/goldens/pr-read-surface.json | 2 +- mobile/rpc-foundation/goldens/pr-read-upstream-error.json | 2 +- mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json | 2 +- mobile/rpc-foundation/goldens/pr-sidebar-load.json | 2 +- mobile/rpc-foundation/goldens/pr-title-mutation.json | 2 +- mobile/rpc-foundation/goldens/pr-title-unconfirmed.json | 2 +- mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json | 2 +- mobile/rpc-foundation/goldens/pr-triage-launch.json | 2 +- .../rpc-foundation/goldens/pr-triage-prompt-not-delivered.json | 2 +- mobile/rpc-foundation/goldens/pr-triage-update-required.json | 2 +- mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json | 2 +- .../goldens/probe-new-tab-null-sibling-refused.json | 2 +- .../goldens/probe-new-tab-refused-sibling-rejects.json | 2 +- .../goldens/probe-new-tab-rejects-sibling-refused.json | 2 +- .../rpc-foundation/goldens/push-dismissal-tray-reconciled.json | 2 +- mobile/rpc-foundation/goldens/quick-commands-load-refused.json | 2 +- .../rpc-foundation/goldens/quick-commands-loaded-and-saved.json | 2 +- .../goldens/quick-commands-save-refused-rolls-back.json | 2 +- mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json | 2 +- .../goldens/relay-direct-upgrade-unsupported-host-declines.json | 2 +- .../goldens/relay-pairing-recovery-invite-authorizes.json | 2 +- .../goldens/relay-pairing-recovery-resume-committed.json | 2 +- .../goldens/relay-rotation-installs-and-commits.json | 2 +- .../goldens/relay-rotation-resumes-committed-pending.json | 2 +- mobile/rpc-foundation/goldens/review-branch-diff-shapes.json | 2 +- mobile/rpc-foundation/goldens/review-create-agent-refused.json | 2 +- mobile/rpc-foundation/goldens/review-file-diff-shapes.json | 2 +- mobile/rpc-foundation/goldens/review-git-mutations-run.json | 2 +- .../rpc-foundation/goldens/review-mark-reviewed-persists.json | 2 +- .../rpc-foundation/goldens/review-mark-reviewed-rolls-back.json | 2 +- mobile/rpc-foundation/goldens/review-open-in-session.json | 2 +- .../goldens/review-send-notes-heals-stale-input.json | 2 +- .../goldens/review-send-sheet-lists-terminals.json | 2 +- mobile/rpc-foundation/goldens/review-stage-file.json | 2 +- mobile/rpc-foundation/goldens/review-stage-refused.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-default.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json | 2 +- mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json | 2 +- mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json | 2 +- mobile/rpc-foundation/goldens/sc-changes-loaded.json | 2 +- .../goldens/sc-commit-message-cancel-rejected.json | 2 +- mobile/rpc-foundation/goldens/sc-commit-message-canceled.json | 2 +- mobile/rpc-foundation/goldens/sc-commit-message-generated.json | 2 +- mobile/rpc-foundation/goldens/sc-create-existing-review.json | 2 +- .../goldens/sc-create-intent-stage-commit-push-create.json | 2 +- .../goldens/sc-create-intent-unlisted-provider.json | 2 +- .../goldens/sc-create-link-failure-is-non-fatal.json | 2 +- .../rpc-foundation/goldens/sc-create-pushes-then-creates.json | 2 +- .../rpc-foundation/goldens/sc-create-refused-empty-message.json | 2 +- .../goldens/sc-create-rejected-empty-message.json | 2 +- mobile/rpc-foundation/goldens/sc-eligibility-fetched.json | 2 +- mobile/rpc-foundation/goldens/sc-history-commit-files.json | 2 +- mobile/rpc-foundation/goldens/sc-history-loaded.json | 2 +- mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json | 2 +- mobile/rpc-foundation/goldens/sc-pr-link-read.json | 2 +- mobile/rpc-foundation/goldens/sc-pr-link-set.json | 2 +- .../goldens/sc-prefill-unavailable-on-refusal.json | 2 +- .../goldens/sc-prefill-unavailable-on-rejection.json | 2 +- .../goldens/sc-prerequisite-force-with-lease.json | 2 +- mobile/rpc-foundation/goldens/sc-prerequisite-publish.json | 2 +- mobile/rpc-foundation/goldens/sc-prerequisite-push.json | 2 +- mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json | 2 +- mobile/rpc-foundation/goldens/sc-reveal-first-poll.json | 2 +- mobile/rpc-foundation/goldens/sc-reveal-timeout.json | 2 +- .../rpc-foundation/goldens/sc-review-commit-inner-failure.json | 2 +- .../goldens/sc-review-commit-refused-empty-message.json | 2 +- mobile/rpc-foundation/goldens/sc-review-commit-rejected.json | 2 +- mobile/rpc-foundation/goldens/sc-review-commit.json | 2 +- .../goldens/sc-review-status-entries-not-array.json | 2 +- mobile/rpc-foundation/goldens/sc-review-status-normalized.json | 2 +- mobile/rpc-foundation/goldens/schedules-b3.json | 2 +- .../goldens/schedules-settings-home-providers-fulfilled.json | 2 +- .../rpc-foundation/goldens/schedules-settings-new-tab-ssh.json | 2 +- .../goldens/schedules-settings-repo-metadata-fulfilled.json | 2 +- .../goldens/schedules-settings-resume-metadata-fulfilled.json | 2 +- .../goldens/schedules-settings-task-hydration-fulfilled.json | 2 +- .../goldens/schedules-settings-workspace-context-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/session-browser-tab-created.json | 2 +- .../rpc-foundation/goldens/session-create-browser-refused.json | 2 +- mobile/rpc-foundation/goldens/session-create-browser-tab.json | 2 +- .../goldens/session-create-markdown-name-collision.json | 2 +- mobile/rpc-foundation/goldens/session-create-markdown-note.json | 2 +- ...ssion-create-terminal-ignores-a-second-create-in-flight.json | 2 +- ...session-create-terminal-launches-an-agent-quick-command.json | 2 +- .../rpc-foundation/goldens/session-create-terminal-refused.json | 2 +- .../goldens/session-create-terminal-replaces-active.json | 2 +- .../goldens/session-create-terminal-runs-a-quick-command.json | 2 +- .../goldens/session-create-terminal-with-prompt.json | 2 +- .../goldens/session-create-terminal-without-active-tab.json | 2 +- .../goldens/session-create-terminal-without-handle.json | 2 +- .../rpc-foundation/goldens/session-diff-notes-load-refused.json | 2 +- mobile/rpc-foundation/goldens/session-diff-notes-loaded.json | 2 +- mobile/rpc-foundation/goldens/session-file-tab-read.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-disk-read.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-disk-served.json | 2 +- .../rpc-foundation/goldens/session-markdown-save-conflict.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-saved.json | 2 +- .../goldens/session-markdown-tab-disk-fallback.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-tab-read.json | 2 +- mobile/rpc-foundation/goldens/session-markdown-tab-refused.json | 2 +- .../goldens/session-startup-both-activation-sites.json | 2 +- .../session-startup-floating-route-skips-activation.json | 2 +- .../session-startup-keeps-terminals-visible-on-reconnect.json | 2 +- .../session-startup-refused-tab-load-still-loads-terminals.json | 2 +- .../goldens/session-tab-activation-focus-and-activate.json | 2 +- .../rpc-foundation/goldens/session-tab-activation-refused.json | 2 +- .../goldens/session-tab-activation-transport-error.json | 2 +- .../goldens/session-tab-close-refused-keeps-tab.json | 2 +- .../rpc-foundation/goldens/session-tab-close-session-tab.json | 2 +- mobile/rpc-foundation/goldens/session-tab-close-terminal.json | 2 +- mobile/rpc-foundation/goldens/session-tab-closed.json | 2 +- mobile/rpc-foundation/goldens/session-tab-rename.json | 2 +- mobile/rpc-foundation/goldens/session-tab-renamed.json | 2 +- mobile/rpc-foundation/goldens/session-tabs-health-errored.json | 2 +- .../rpc-foundation/goldens/session-tabs-health-reconciled.json | 2 +- mobile/rpc-foundation/goldens/session-tabs-health-refused.json | 2 +- .../goldens/session-tabs-health-stale-application-revision.json | 2 +- .../goldens/session-terminal-display-mode-auto-take-floor.json | 2 +- ...session-terminal-display-mode-auto-without-device-token.json | 2 +- .../session-terminal-display-mode-auto-without-viewport.json | 2 +- .../session-terminal-display-mode-drops-second-toggle.json | 2 +- .../goldens/session-terminal-display-mode-to-desktop.json | 2 +- .../goldens/session-terminal-list-dedupes-handles.json | 2 +- .../goldens/session-terminal-list-empty-guarded.json | 2 +- mobile/rpc-foundation/goldens/session-terminal-list-merged.json | 2 +- .../rpc-foundation/goldens/session-terminal-list-refused.json | 2 +- .../goldens/settings-bot-overrides-fulfilled.json | 2 +- .../goldens/settings-bot-overrides-refresh-refused.json | 2 +- .../rpc-foundation/goldens/settings-bot-overrides-refused.json | 2 +- .../goldens/settings-bot-overrides-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-home-coalesced.json | 2 +- .../goldens/settings-home-providers-fulfilled.json | 2 +- .../goldens/settings-home-providers-refuse-after-data.json | 2 +- .../rpc-foundation/goldens/settings-home-providers-refused.json | 2 +- .../goldens/settings-home-providers-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-new-tab-refused.json | 2 +- mobile/rpc-foundation/goldens/settings-new-tab-ssh.json | 2 +- .../goldens/settings-new-tab-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json | 2 +- .../goldens/settings-repo-metadata-fulfilled.json | 2 +- mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json | 2 +- .../goldens/settings-repo-metadata-refuse-after-data.json | 2 +- .../rpc-foundation/goldens/settings-repo-metadata-refused.json | 2 +- .../goldens/settings-repo-metadata-single-host.json | 2 +- .../goldens/settings-repo-metadata-transport-error.json | 2 +- .../goldens/settings-resume-metadata-fulfilled.json | 2 +- .../goldens/settings-resume-metadata-refuse-after-data.json | 2 +- .../goldens/settings-resume-metadata-refused.json | 2 +- .../goldens/settings-resume-metadata-transport-error.json | 2 +- .../goldens/settings-task-hydration-fulfilled.json | 2 +- .../goldens/settings-task-hydration-refuse-after-data.json | 2 +- .../rpc-foundation/goldens/settings-task-hydration-refused.json | 2 +- .../goldens/settings-task-hydration-transport-error.json | 2 +- .../goldens/settings-task-workspace-create-linear.json | 2 +- .../goldens/settings-task-workspace-create-pr-start-point.json | 2 +- .../goldens/settings-task-workspace-fulfilled.json | 2 +- .../rpc-foundation/goldens/settings-task-workspace-refused.json | 2 +- .../goldens/settings-task-workspace-transport-error.json | 2 +- mobile/rpc-foundation/goldens/settings-task-write.json | 2 +- .../goldens/settings-workspace-context-fulfilled.json | 2 +- .../goldens/settings-workspace-context-refuse-after-data.json | 2 +- .../goldens/settings-workspace-context-refused.json | 2 +- .../goldens/settings-workspace-context-transport-error.json | 2 +- .../goldens/settings-workspace-submit-fulfilled.json | 2 +- .../goldens/settings-workspace-submit-refused.json | 2 +- .../goldens/settings-workspace-submit-transport-error.json | 2 +- .../rpc-foundation/goldens/speech-audio-chunk-acknowledged.json | 2 +- .../rpc-foundation/goldens/speech-desktop-start-fulfilled.json | 2 +- .../goldens/speech-desktop-start-recording-failed.json | 2 +- .../rpc-foundation/goldens/speech-desktop-start-superseded.json | 2 +- .../goldens/speech-dictation-session-cancelled.json | 2 +- .../goldens/speech-dictation-session-transcript.json | 2 +- .../goldens/speech-setup-sheet-denied-to-mobile.json | 2 +- mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json | 2 +- .../goldens/speech-setup-sheet-legacy-desktop.json | 2 +- .../goldens/speech-setup-sheet-model-vocabulary.json | 2 +- .../goldens/structured-agent-session-created.json | 2 +- mobile/rpc-foundation/goldens/structured-launch-created.json | 2 +- .../goldens/structured-launch-definitive-refusal.json | 2 +- .../goldens/structured-launch-replays-dropped-create.json | 2 +- .../goldens/structured-launch-support-refused.json | 2 +- .../rpc-foundation/goldens/structured-launch-unsupported.json | 2 +- mobile/rpc-foundation/goldens/tasks-route-repo-list.json | 2 +- .../goldens/terminal-gesture-flush-and-clear.json | 2 +- mobile/rpc-foundation/goldens/terminal-input-send-accepted.json | 2 +- mobile/rpc-foundation/goldens/terminal-input-send-refused.json | 2 +- mobile/rpc-foundation/goldens/terminal-live-input-accepted.json | 2 +- mobile/rpc-foundation/goldens/terminal-paste-accepted.json | 2 +- mobile/rpc-foundation/goldens/terminal-paste-refused.json | 2 +- .../rpc-foundation/goldens/terminal-query-reply-accepted.json | 2 +- .../goldens/terminal-query-reply-unsubscribed.json | 2 +- mobile/rpc-foundation/goldens/terminal-raw-input-refused.json | 2 +- mobile/rpc-foundation/goldens/terminal-raw-input-reported.json | 2 +- .../goldens/terminal-takeover-report-accepted.json | 2 +- .../goldens/terminal-takeover-report-retried.json | 2 +- .../rpc-foundation/goldens/terminal-viewport-refit-applied.json | 2 +- .../goldens/terminal-viewport-refit-legacy-desktop.json | 2 +- .../goldens/terminal-worktree-connection-resolved.json | 2 +- mobile/rpc-foundation/goldens/tk-create-github.json | 2 +- mobile/rpc-foundation/goldens/tk-create-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-create-linear.json | 2 +- mobile/rpc-foundation/goldens/tk-item-checks-files.json | 2 +- mobile/rpc-foundation/goldens/tk-item-comment-github.json | 2 +- mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json | 2 +- mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json | 2 +- .../rpc-foundation/goldens/tk-item-detail-github-reactions.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-github.json | 2 +- .../rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-linear.json | 2 +- mobile/rpc-foundation/goldens/tk-item-detail-metadata.json | 2 +- mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-item-metadata-github.json | 2 +- mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json | 2 +- mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-item-reply-merge.json | 2 +- mobile/rpc-foundation/goldens/tk-item-review-github.json | 2 +- mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json | 2 +- mobile/rpc-foundation/goldens/tk-item-status-gitlab.json | 2 +- mobile/rpc-foundation/goldens/tk-linear-connect.json | 2 +- mobile/rpc-foundation/goldens/tk-linear-item.json | 2 +- mobile/rpc-foundation/goldens/tk-linear-team-context.json | 2 +- mobile/rpc-foundation/goldens/tk-list-gitlab-items.json | 2 +- mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json | 2 +- mobile/rpc-foundation/goldens/tk-list-linear.json | 2 +- mobile/rpc-foundation/goldens/tk-project-board-load.json | 2 +- mobile/rpc-foundation/goldens/tk-project-repo-slugs.json | 2 +- .../rpc-foundation/goldens/tk-project-row-comments-issue.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-detail.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-fields.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-files-merge.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-review-checks.json | 2 +- mobile/rpc-foundation/goldens/tk-project-row-threads.json | 2 +- mobile/rpc-foundation/goldens/tk-provider-load.json | 2 +- .../goldens/transport-capability-probe-cutover-reasks-fast.json | 2 +- ...transport-capability-probe-non-string-capabilities-drop.json | 2 +- .../goldens/transport-capability-probe-publishes.json | 2 +- .../goldens/transport-capability-probe-refused-backs-off.json | 2 +- .../transport-host-status-gates-drop-keeps-capabilities.json | 2 +- .../goldens/transport-host-status-gates-ready.json | 2 +- .../goldens/transport-host-status-gates-refused-degrades.json | 2 +- .../goldens/transport-pairing-race-both-refused.json | 2 +- .../goldens/transport-pairing-race-direct-completes-first.json | 2 +- .../goldens/transport-pairing-race-relay-completes-first.json | 2 +- .../transport-pairing-race-relay-wins-when-direct-refused.json | 2 +- mobile/rpc-foundation/goldens/tw-capabilities-advertised.json | 2 +- .../rpc-foundation/goldens/tw-capabilities-cutover-retried.json | 2 +- .../goldens/tw-capabilities-legacy-idempotency.json | 2 +- .../rpc-foundation/goldens/tw-create-retry-agent-launched.json | 2 +- .../goldens/tw-create-retry-ambiguous-after-drop.json | 2 +- .../goldens/tw-create-retry-ambiguous-while-connected.json | 2 +- .../goldens/tw-create-retry-ambiguous-without-idempotency.json | 2 +- mobile/rpc-foundation/goldens/tw-create-retry-created.json | 2 +- .../rpc-foundation/goldens/tw-create-retry-name-collision.json | 2 +- .../goldens/tw-create-retry-unretryable-refusal.json | 2 +- mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json | 2 +- mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json | 2 +- mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json | 2 +- mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json | 2 +- mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json | 2 +- .../goldens/tw-paste-lookup-slug-unsupported.json | 2 +- mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json | 2 +- mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json | 2 +- .../rpc-foundation/goldens/tw-smart-search-all-providers.json | 2 +- .../goldens/tw-smart-search-gitlab-provider-error.json | 2 +- .../rpc-foundation/goldens/tw-smart-search-linear-listed.json | 2 +- .../goldens/tw-task-preferences-resume-write.json | 2 +- .../goldens/tw-workspace-source-presets-refused.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-source-presets.json | 2 +- .../goldens/tw-workspace-sparse-missing-preset.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json | 2 +- .../goldens/tw-workspace-ssh-connect-refused.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json | 2 +- .../rpc-foundation/goldens/tw-workspace-ssh-local-agents.json | 2 +- mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json | 2 +- .../goldens/worktree-catalog-snapshot-unreadable.json | 2 +- mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json | 2 +- mobile/rpc-foundation/goldens/worktree-home-catalog.json | 2 +- mobile/rpc-foundation/goldens/worktree-retired-names.json | 2 +- mobile/rpc-foundation/pilot-scenarios.json | 2 +- 791 files changed, 791 insertions(+), 791 deletions(-) diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index 419193c2634..05d48255545 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index 88e8e31ab90..c49eaddae91 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json index ebcfd83d05e..b83d7aa6db8 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index 9b06260c75c..05c1f3a3d2b 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 123c47efce2..5ede6eb120b 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json index 7273d709642..02164967d24 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json index 588b00e9d27..54b025004f9 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index 66ebfa06180..55d31ba3d5d 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 8906ed097a1..48db573bca7 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index fa0884b1835..091cbba9667 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index 6b10cde0937..722569f79e4 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index 241b9e64da0..ca728868ddd 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index 8697a194d78..cbad473015a 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index e9857f7b6c8..fb50a2faf88 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index 8412643f6a6..df8f39f589b 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index 922c2db368e..19d44634a48 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index 5013ef328de..cabb31e6a66 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index 4e23c9bbfb8..da8036a9aea 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index 0e5aab769a1..11721f74506 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index e0918d56639..bd6c2112a32 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index 2a079f64ab8..83476c28672 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index bcbc82088a6..94ad174a33e 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index e3c40477b44..1f5f6ec7abe 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json index e4a42c97e52..577c84ef759 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index 6ddb969723e..bb828ba1673 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index acaf59fdb3c..b98b684f5eb 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json index 5e6495076e0..2f800d92b7a 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json index 7102d030df0..1a1d3056c2d 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index 5ec35b41464..6f0b9baf187 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json index 11b545e8c87..dc1bad47e5c 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json index c5c41bb2d99..5f05ac6746a 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index ae20906e3b9..f117d764a10 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index c397c9741ac..ba7be42c230 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index bc682d8f7c6..298a0a94eb5 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index 626c1640ff1..583273d6181 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index de63b743df2..cb200b89d4d 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index 6862b608e8c..b76ff775677 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index 2431b6b6bb5..82b54bda98e 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json index 3881b54995e..fe7dd7a58b5 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json index 0d66e1a803c..5bf8483bd59 100644 --- a/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json index 170207631f8..da678dc98e2 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index 56c1562d77a..ad6442801d5 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index 9029cc36b31..7eca0de25a8 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json index c4db11ee782..5202ad86447 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index ddbb1a4028a..78516f75ee7 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json index 9f0c24dc1ca..a50d9ea8e1a 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json index 25480522f09..564f53ff194 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index 8a5ca5a0160..012d31f7fc7 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index ab76b124fb4..8213f3e4961 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index 5c10140877d..b8c88b89a28 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index 658cd1cba1c..fb6bd93a03d 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index f3815b3c7f5..6da2e1bc832 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index 95aac7fc246..94bd352a764 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index 4694bdef9d5..ab8ac02d2a6 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json index dcf696662f1..4a232dcf21e 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index f9934fbb841..50f04a169e7 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index c1ea1ae07af..501314f8808 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json index ab57d829624..797e0f5c7bd 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index fc900664173..3202b46f6d5 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json index 9f189b98b7c..b67eb6d59cc 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index 5fd91b2b517..933942cfdb3 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index dc1209252f7..7dc10c03d1b 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index 3d370939df4..12bb53d22b7 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index 4ba829fd513..0658bd72712 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index b9c58502f53..e702147addf 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index 53128ac55c8..8cf4e0187da 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index ff27183bf08..adadbb81803 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json index 49517faa096..f1de161f46a 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json +++ b/mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index 4d4f1c5da7b..ca8bb14a5df 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index 0e711a5623e..b8ac4ff58b0 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 38f5f2d52db..dabe2fc5872 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index c113f4934a8..100f21460dd 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index c8e58c17a54..63331d4bc2c 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index cca240fab8a..133dd37378e 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index cf04a8e52cd..7e2a3ff2dde 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 7706e5c23b4..28cd115186d 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index c02ba959c78..7ee44f37fc4 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index 233de8a6418..9c5f08c680e 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index 6c7c5a921cd..c72a6b5d824 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index c62341321b8..cfe5c67870f 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index 8aeb392fe07..db71c42b710 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json index 4d2fe5a4e75..8d205778875 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json index c42f108854c..791ede81d7d 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json index 522a7066907..fe99a2b2814 100644 --- a/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json index 53404a56db0..68b76baf798 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json index a2daea98baa..3d60a85ab04 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json index ace3ee82e9e..2fc02ff1861 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json index 6255151ee8e..4f9bbfc16b7 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json index 2dc1ed6dd78..5530b93969e 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json index 512d5c031a1..175556b1b0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json index de17109c622..241e0f99409 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json index fa432bebe88..74f84d2526f 100644 --- a/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json +++ b/mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json index 0bbdd00cb21..6da9cceba8b 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json index cd482560c1a..d943a202d3f 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json index fb7f153f204..1311ac78659 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json index 29a9173986d..2b425803af0 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json index 1e6456b1f02..f4219c2aeb6 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json index d6009d614a7..1e2c03760f2 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json index facb0d19e0e..43bed4caabf 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json index 87ac6b7faaf..ca312d82abb 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json index 2c52ef6ec72..8ee48dd1fd3 100644 --- a/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json index 2e7e89decf2..99d3eb12699 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json index 300c0a1bb4f..ddda539bc4c 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json index 8599f5b2df9..f464ff8ef8e 100644 --- a/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json index 70fa3aaa084..9879f9282a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json index 1829baa8eda..ea1cb61598f 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json index a56cfbad645..cb7bae2e7aa 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json index 4bc97abed12..5b35c3911fb 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json index 53d45631f5f..288f5e269b5 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json index 68684251c5e..86add409131 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json index 19650d12990..d1d9e1bd99e 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json index 18a8f25f594..965623d6156 100644 --- a/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json index e3ae350bcd8..cc88c5ad7b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json index 131d7707a4e..8e1d86de1b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json index 9c6bccc9609..2b192f3aa84 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json index 3b5bec1f7d5..7c1c3ac751c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json index bbf0b385173..5cb190a31ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json index 732fbc1f4b9..a3a8fe88951 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json index 0d0a849b2f8..159404e1b4c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json index 8760719727d..e7b369f7a8f 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json index a25e815bce3..377bcb8ef86 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json index 51b9cf78628..32817b0fb41 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json index 363e3c7dde8..78d0ee50808 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json index 0408a9c3a58..8173a17b650 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json index 27825102bce..34e77850885 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json index 35d97c64c46..8312731b553 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json index 8a458180dd2..738bb631480 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json index c25f92c59f7..a4c5d13082c 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json index 80494c6f26f..e340b90e735 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json index 1eea79733e9..e28a23102bb 100644 --- a/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index aabcd9eac47..ec13c65fb4a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index 02f2730a3ce..25f00c15ba2 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index 81be16d5e78..cbdea59fbc9 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json index 4e9c254248b..8aec0ab46c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json index acf44fb0606..bcf54ece9dd 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json index be1ce32089c..5afd179d85d 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json index 2d358a3e83b..b84b036551c 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json index 9236f81f5d5..a3578fa6780 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index 0ad229114d0..ce4246cb49b 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json index be9e8c3ccbb..32b2fd268db 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json index 43b1e6f6970..675c778ab31 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index 4f3bd614da3..092dd3ae535 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index b1c08de7bfc..c5b8fa1ccec 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index 4bc338b638c..d69605a772c 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json index 806b0ebafb4..aa34ebfeea0 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json index 77d912eb07c..e9d6280e362 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json index 76bc1ef55d1..cecc525a4ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json index 8af2ce1ce22..294c5a2695b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json index 30555b5ae0c..ce8130e211c 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json index 396b4c7439e..bc31d8f4854 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json index ab3f280a28a..2030a3897ea 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json index 28b45178e0d..3fa4c425789 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json index 973e5712cfc..9bdbddba0a7 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json index 336ae110059..f11f6dd43a5 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json index a65428b8e2c..52377ce2bb4 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json index 3ba174a751d..e6031d2899b 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json index bf05e864622..8a746b7d508 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json index a4102369042..aedb53ac141 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json index d51a8e4a893..60936c24937 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json index 29461297379..890ffd08100 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json index 03b1f3af3d6..7c620ace6af 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json index 9dfcea1975d..84e3d8d7ec4 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json index db37ecace10..7b869bba869 100644 --- a/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json +++ b/mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json index 8c3f240d5fa..5e3e117809b 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json index e808bddbe6f..84817f85a31 100644 --- a/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json +++ b/mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json index 8188118fef3..8e35b0ac7e3 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json index 0ffb1635027..7cbe3edb79d 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json index a7d857302e1..94c83f0f737 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json index 122408a75f7..45f37cca989 100644 --- a/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json index 57adfc6c194..6d3c7f6f94f 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json index 2cf5ee33fba..0e7def82628 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json index 0cf874ee84e..404a2f89f26 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json index bf8d61caa05..04f692cca20 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json index ec3546ea274..5098386c4f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index fb33a1c1e7d..e1b7fa432b8 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index b7b4f4e326e..2722a3dc179 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index 39ea426a9f6..e57ec82457a 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index 445b6cee00a..4a2305bcd49 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index 425cb014bb3..f946583aa4f 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index a7ec45c47ea..7463a1bc89b 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index ca99bab6a01..627f7488bde 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index 11263c70c66..831261fe71b 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index 13f402cfa97..c9180917cc6 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index 36e2ddbc10f..b7a96e01c98 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index 4f6eb1557dc..3e26f5eb9e9 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index f3e587029bd..e701009162c 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index 2bc0f6d3f4f..85f85213377 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index 2380040f668..edbb82cccad 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index 2beeb0ed5ab..15bc63967ae 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index c0176e34319..190bdc01828 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index 3c160667be9..b0453b1c0fc 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index 033302a64a2..bc4f8e5dac8 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index 61665af7c47..d71ede67479 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index a75e8c682c5..4c50612fc18 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index 60cc966dac4..e6ff652f014 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index d113789d15e..1dcbcdbfe1b 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json index 4f9be474280..7088f794abf 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json index 29589903a21..9da3e6beaf7 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json index 8f6874667e4..04d990d591c 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json index 79ffed55a50..39f2a2e6386 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json index 454bbe31091..1bc86e91896 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json index 6ec8daed6ed..151808d673b 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json index bba304fd510..5126ef54e29 100644 --- a/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json +++ b/mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json index b917722c798..7573ef7cec0 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json index 79b074049b6..43a231817bd 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json index 338446476fe..29fba15b4da 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json index 19d7ac1f881..fe3ed6f7f9d 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json index 8f7a243fa73..4abd8f4e375 100644 --- a/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json +++ b/mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json index 8952e42cef8..70d9caacc86 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json index 81df706c7ca..bda0f2377a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json index fbf47253e12..6ca1f29ca74 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json index 1f713a67286..de35c7e8e9d 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json index 09928edbaca..4828a3c136a 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json index 2ad6a4c3168..e27b8dedce8 100644 --- a/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json index 08806b12b12..5c96345be12 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json index 2a1c1f9bf05..6906e28b4be 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json index d70c83c9f1b..2d604afd9c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json index 8ac93debb4b..2a43ab0c76e 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json index 0b3f26918a6..afae0c3d35a 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json index 881d94162ed..e06cd59edc4 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json index 58b63cc9169..0792eb91391 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json index 07b3c9a2796..dcf41dad6a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json +++ b/mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json index ebe7a717cc0..51f9cab8a2d 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json index 7810e8b1569..eb571950293 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json index 44781877b88..99d22ea0bd3 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json index e18957b2625..ea72c6502d2 100644 --- a/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index 69986253a7d..668b9c84bf9 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json index bae589eabc7..7c4b581d9c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json index 90602fa2c0e..9eb068188c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json index bc46128a9c9..fb690cf2693 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json index 19896f3e5d6..ac4780fa50f 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json index e249286364c..6d0198b9b6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json index 8450924573e..2ea5793519b 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json index 3aa32fc8d3f..88d32e67d1d 100644 --- a/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json +++ b/mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json index 2dbf9fec5b3..b5dec27ab14 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json index a5ca54c6d42..f5f1a42c141 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json index 44de88f3823..4fc2a9a6a70 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json index 5cfb4c0388a..a2b045ea274 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json index 8ccbb539e5c..3c93c5850f6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json index fec627b879a..301aba46f38 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json index 298adc1fd19..a5b78204514 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json index f79bfbbc9db..250a9e128c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index 5b2fe3a5778..e7b9015c59d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json index 8fecc94c5bc..501976ecc14 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json index a2f96d210d4..8fc3fe1fd41 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json index 692967ede25..fda5a92cfb4 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json index 22dd9acc369..015d2cc0a13 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json index 742924867bf..8b53cc35b21 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json index d284eca5984..e04c8b3ee7e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json index d070a4045a1..e34993cefcf 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json index edd06be0450..f9d5c2d6778 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json index 0c86e7a752c..63577bc9c28 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json index 9145e1b22ca..b4aa358c816 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json index d55f04de28a..9b429d1aec6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json index 70d62a56811..88889306408 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json index c1baafc684e..ec3e90d6492 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json index f4be308503a..7c71d3750a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json index ca1f9ce13db..efed2de7e0b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json index a51a2d5dd12..4578ecc3ad3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json index 3cae83bcc9e..801140db727 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json index 16d9172aae1..24a2b9b0d4c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json index 8e9c61ff5a7..7c9de5929c3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json index db448856290..5537e82363f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json index 37076b4d27b..7102357f0fe 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json index a8e8b1fda9a..c05867fa10f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json index 096afff3ad4..edb26846e0d 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json index d8c4acda08a..7cb300eb411 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-agent.launchreplay-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json index 49674829a77..fcb3330b818 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json index e509c2f8eac..d3f6bbcbd6a 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json index 5aa2ac949f8..793cb1435cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.pr-triage-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json index 202974916ec..32770529f33 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json index ddce30f6207..79dcf7b791f 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json index 98445d79d7c..a15faee5c95 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json index aaecef28a47..90b7d342164 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index f1ed4ede349..add506aecc9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index adab151675c..d79b2df09c7 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index 2088f1a6873..affdd0974f5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index c8a098ebd1d..8e429ea6b72 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json index 9e73c794475..e6122841e48 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json index 9cceccbbca9..4e2a55ecc11 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json index 9c18b6bdac5..7c127588951 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json index ba95e6b4929..8c00e200a5b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json index 47641477c4c..6cfee9aa3e2 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json index 977ff3841f0..77844549a7c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json index f47e18d311a..80c376c3ea6 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json index a5885732844..64a564d7fa5 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index cd84ae0abe6..c6bb3eca2ee 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index 8b0befd6e0d..be97568a3b3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json index 404188bcf84..3d0e30b909e 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json index f61034b9179..cf8cb3ea08b 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json index 44dab067c50..a91abecbfec 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json index 2017c3d2293..9a73b8cb753 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json index ea57143fce5..5e7fe880f0c 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json index 32d753e4d37..f50d92b6500 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json index 1fc9c0d763d..b1214f83f76 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json index c31a784f8fa..9d938aafb56 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json index d22fb0fdc64..7c10f98cf15 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json index 506014b1006..fd21bdcd9e3 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json index 286180c735b..1f4bcaf11fa 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json index 95f4b417020..2b9f88e9767 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json index 60cfe697dbb..5fc76824eb1 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index 397713fe126..b1a05557a62 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index 4e767dd1a33..aed8cd2f466 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index cea0626169f..58eaf3c62c8 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index fd1810fc815..019b7bf7860 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index 9da2716daf3..b29402a60a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index 03876800294..b513a25ec2e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index 00fbe373437..3357d1ad059 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index 4ec63277e7b..697bd7a56dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json index 60b06fdff09..5271ca6e711 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json index 62132561c94..1fa58694ab9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json index 5451b05d18f..0413b5312f0 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json index 89782795daf..781c4453fb3 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json index b73c3a1f849..ed8b968d466 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 75d9f21869d..22f64f961aa 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index 07523015bbc..850aad0c45b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index e09d27b0474..4cc0de8e3c1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index b7ebb1568e8..210ae740e67 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index 3e2592a3ac3..18f97a13d2b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index 8dd1dca24e1..58aa1c29348 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index ed55d3d1d92..f105cd29f96 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index 52ac338f82f..4680068df63 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index 85b57c953e8..4e83e3a031b 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index 0dc0ce5cc96..4f1e509abb0 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index 4cb11452989..c268b5e77fd 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index 66a4bef76dd..981d619a412 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index fefa73e4d26..627a85f4dd2 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index 473d944a426..39f6361a0ea 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json index a2043d11052..2cf32bbcfd4 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json index 0ef8104890f..9178ad4f5e6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index 2d1d3bf86dc..f8027c550d1 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index 7aeed9bed04..aadc58bbff9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index 8efd8dec7ec..96657659b8e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index 02f6a42e0c0..0fd556516d5 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index 77d12ae27d1..8f41fad9637 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index 16ffdd39382..d537ef9a273 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json index cae4f6c8a6c..abb97747284 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json index b9510ec635a..8e9554098bb 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json index 52239937689..1b59a18276f 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json index ff3ed7f91ec..8db821475d5 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json index dcab290e4d8..8f8cdc5dd82 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json index f89d9e16871..5fbae8a9192 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json index b2735a96daf..fb85e5e7d79 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json index 8423a565108..c1fd7934ead 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json index 09a5deb80f1..ef4e546b36f 100644 --- a/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json index 4407db97ff3..5ac35183373 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json index aec93d1a133..f6ddc37e0f7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json index 5d4e010a551..9a921f60ec5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json index 75e7284e2ac..66661fdfe92 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json index b9b3901bf1b..fb73ddfdf91 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json index b91a7a117db..9d268655d89 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json index bcdc8c57017..e898469e765 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json index 3ea4b1565b8..a1967b96b5b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json index 1f503351765..d0aa49fbb85 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json index cebe79fac8c..b13a5344e8c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json index 479b6bb3cb7..82147afb79a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json index 692c92b4a95..83739928034 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json index 217259e6eea..0bf04e34736 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json index 7ecd3c1f64a..8a725b43c2b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json index 8598bb2eb90..f078de63746 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json index 091a41ca2be..07caea1146c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json index 993dcc5bc05..ad5b2516e9a 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json index 0365dac61b3..93e2a9c0138 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json index 54bb28c24d9..a3190dbd8f3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json index d942b505dd7..59f6fc744dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json index 262ac06aa9f..7e924dee560 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json index 3706568bc11..aa1f2f09e61 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json index 9e9f3fdc969..8af1b976c2e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json index fab6368f1cc..22163e9d2eb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json index 201345362fa..3ddb6d68b53 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json index de8d7b29d9c..93102932341 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json index 2313bcada88..ff43beb630c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json index e63fb9d9ec3..58deb7a6c84 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json index 3dc191ea236..79dc781dee0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json index 42a8a9da83e..e4e48b2582c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json index 8c86d3699a3..21bd3a72283 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json index c890e39d3b5..5728d801bfd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json index 9e03b251fad..b466b490213 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json index 931ea2fade2..810768d84b2 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json index 63717318402..400112c00cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json index dc0d62a270b..11d0eab7074 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json index 73bc0b32d4e..427f4b93c04 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json index 74ea4e9fe24..26c3fe0e2cf 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json index ebf2dd2af6f..9747f14435c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json index d48ef4e13a8..049aa7f2a32 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json index 52bdefcb2e6..6e56719868f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json index fe298fad20b..75a2aab11dc 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json index a103ef69b39..869b299858c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json index d44e1706034..2a8b844acb0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json index bfe8fbaa096..64bff9f06bb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json index 839d2b79d00..eed0fd8f95e 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json index 3af7b7c0fcb..2683eefce2c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json index 6952ea391c1..82a9c965719 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json index 9443a4e4e59..4720e2300ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json index c9b7ebe5dd9..d20f5e2f7ef 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json index 9ca91ae714f..f44b7d8524d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json index 7f2ccefaff3..1dca0b1ce70 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json index 95a5d3f8d70..a45018204e3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json index e2ca43b1f7e..fcc8971a6a8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json index 054bbe2bbe6..1d277c901f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json index 343ed063a81..100f1dc72ff 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json index e7063174ca8..dcec771e5a3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json index e6e41eb79ea..2924a6e6c71 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json index 49d83e4c5e1..6a14216104f 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json index c9d08c7b490..1da8e7e377b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json index 923aaa358eb..0b8309956a0 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json index edcc7215424..3f3b07d7f99 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json index 522877d7d81..c81dbc72713 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json index b52daff4a4f..743274c20be 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json index bc67329ebf2..244c36f18be 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json index c05895e52dd..8ce3d502001 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json index 05f4e492aa9..dc1517e6e32 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index 1458e3fccf1..02beb58f0f6 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index 432ad890d8f..8831bfb8fe8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index cd475623810..307bc0427cb 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index c953fc2c0c9..1e59f4bf470 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index 75c390b3017..bc6016bb0f9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json index 4b9a718cf8c..1af1d818b40 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json index d0c8375d5ff..8eed4e13824 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json index be5a8106e91..dbaf0bfb521 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json index ab65ad2d6dc..3b2700086f9 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json index 521c5b19610..16af661d49c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json index f810797ad0e..b73d10a73ce 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json index 21c3b6102d0..a6d36c306f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json index 14d72690a4a..ff972eb7449 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json index 4bcf523af7f..35df73656d4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json index 5c832f18109..d50335aecc3 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json index 96d94fe89b0..cb10520c34b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json index 69ec73d4576..ca43c4fd653 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json index fd06d7c167c..af56926182d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json index bfa64ad35e6..75aeb3a962c 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json index f94ad51fb20..b4254656d45 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json index 33370383b41..361b70033cd 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json index e946c8f2198..53479f698f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json index 3c600719aed..2b77965536d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json index 15bbff4736b..9af3002abb7 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json index e90ddc05134..cab57378ab5 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json index 5f61f25da60..b05d49d4a86 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json index ac44344dfd5..337f7141d89 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json index e3305afb2d6..59e1a75e76e 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json index 9bd2e8592be..9a2cc35f7b3 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json index e88711a965a..160e170c605 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json index e7c7180a762..6754149bda7 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json index 231c885875e..456a924c62d 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json index fd19dde7c14..3cf51bfd2c9 100644 --- a/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json +++ b/mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json index 8b0f00d34da..d8477f733f8 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json index 130b385ed11..f8477b7cd30 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json index 987029285ea..cfcc4ff6da3 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json index 7d12b01799f..e7cc088517f 100644 --- a/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json +++ b/mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json index 1c78ae16d26..9a804d66f48 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json index 9ea63e63408..4526333c823 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json index 5ca6a391527..286c7314ef1 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index 9b9e360cde1..aae6c3f6d6f 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json index 849ec115f43..e2021615a4a 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json index 96a309cf8cf..2c454d91e5e 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json index 99471df513d..069ab19c7e4 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index 70046048638..a2038eb0584 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json index f00cb36ffe9..ab22f058cd5 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json index db230f77b3c..fb04070a8f9 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json index c11ee140d4f..019d9b15aad 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json index 70190efa364..c84cabb898b 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json index dcc0762fae9..53e2810822b 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json index f73605c72b0..ec443d06254 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json index f934aa28847..5750aa66cf6 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json index 0f39c92ab9d..dc80fd6650e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json index 0b8bb809580..fc45d428da4 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json index bce6493283e..044c0be5332 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json index 1074bd2f390..94da28329da 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json index 7115e1b0ea1..9aa0b7cea3e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json index cf18ef24a04..1f339ad76fa 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json index 42628ce2bd7..95586c30d05 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json index eed5822bd38..bf8084aa951 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index fdbf4e6cb29..8eaf7296068 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json index dfea737f9e2..cd84dadae00 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index 82e7ce1df09..2b14e1cf717 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json index 63174dde0ff..697560982a8 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json index bdfaaf078f1..9df01ea4568 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json index 069b6d69e2d..f295cb8c9a0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json index 5c62652b35f..4012a927f30 100644 --- a/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json +++ b/mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index c6404815bb3..6c2ef2da623 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json index 780a884a188..296d4d7b349 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json index 0838364830d..1366bae9a1b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index 5228ed6c3a7..e2e98cfebd4 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json index b3b7b21e1d8..bb10f158648 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json index 5ef0e31f3c4..8653789f0e5 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index d8d9ad5cd33..8b9726edd6e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json index 927f2e222e2..d5232fa46cf 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index b07cb0d02cc..a456b535003 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index 71b41beb8e4..6c076c3555f 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index b48df7e7da2..4cc144feba8 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index d1f2d80cd58..ec67928b983 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index eb1dc29e84d..5ae2f8256bd 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index b2b379a3fdd..64e2c8c1cd9 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json index 6f6eca1bd32..e255279ddc7 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json index edc61beb60c..6495277b7c7 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json index d705ad25f9b..16673d73533 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index fa92cf8b902..4a85cc3448f 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index f49f196bb93..85817d1e175 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json index 6d9e71fefc6..92c3d0be569 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json index 24e83a8eb97..dbaade09b51 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json index d662e971182..703f2d44b8a 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index 68711e3bc42..8b55717c760 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index 4c1f18b5826..fdffc14bf9e 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -3,7 +3,7 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index 98fb81fb43e..5135bf25068 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index e4d974974f7..b51583c5b23 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json index 80210632f25..bdd5c5f00b5 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 9df9ebbc08f..5b9696a333c 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -3,7 +3,7 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index 82ae04bdf19..9c63ea6f1ac 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index beb3caed1aa..000138b09f7 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index 921b277e405..ac5d882d2d5 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -3,7 +3,7 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index d02fb4fe598..0f676a3ec3c 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index 0a6b876e203..b620dbb5ff3 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index 3025207a91b..8b70312a91a 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index 76543bd7347..f3925091c1e 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -3,7 +3,7 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json index 541634775ca..dd9dc7f9932 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch-unconfirmed.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index e98c39ee06a..6409255dde3 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json b/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json index 093aa0d0036..a3a89906998 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json +++ b/mobile/rpc-foundation/goldens/pr-triage-prompt-not-delivered.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/pr-triage-update-required.json b/mobile/rpc-foundation/goldens/pr-triage-update-required.json index 2abc1f20f77..9c177b4f7d8 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-update-required.json +++ b/mobile/rpc-foundation/goldens/pr-triage-update-required.json @@ -3,7 +3,7 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 3a0831c480e..5fc9131610e 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index 8976f58317b..7f025ba6705 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index b4fe98c09e7..c35f9f15bb4 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index 8dd63775a20..ead340e9da1 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index f4055dac19a..efb5cb44258 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index 4b66792f880..9b474b2fe94 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json index db28cbb0b9d..f0a3927eb9d 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json index d2fd843f5b1..f35f31ef011 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json +++ b/mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index 86fff0ea1fc..128b5683855 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json index cbdc1ed6758..49bb0d6916b 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json index e9f548846ff..93d35123826 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json index 462f491de85..01ea2c1dd1c 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json index 516a4c0e51c..df749f8a8ad 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json index c1ba267c15e..9b00ed66ae0 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index 59f4d1f8fa6..111141a241d 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-create-agent-refused.json b/mobile/rpc-foundation/goldens/review-create-agent-refused.json index 46eacedfb03..4b3e87f2df7 100644 --- a/mobile/rpc-foundation/goldens/review-create-agent-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-agent-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index 52d497f4ff6..468c34e78f7 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index 6c7223e8284..acd150c9b2a 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -3,7 +3,7 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index 23eb48c754e..b85c016c15a 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index 38014ac78ee..e99516ff777 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index b76d69a3b93..569e83371df 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index 102af74f4ab..a05b6062e16 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index 6ef0285d606..97ad2e2b3c6 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -3,7 +3,7 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index 1e943bc5d44..c26300ecae4 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index 0a1e6a8a534..ba13c5d523f 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index a01f9f2b924..5942b67df03 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index 3bf23f0d0ba..b8452d82410 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index 8c8eeb7fd8c..50df2e2ddab 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index 02b22c13ae7..ffee9e6aadd 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index fd257bbff10..7b1b824e2bc 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index 898491e315a..e958945db16 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index 5243f30ae1b..2f9d956fb0c 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 82c4dc0b73b..154bc9c8cc1 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index 50e21b49451..8afa223e0f8 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index d924c7310d5..d2ba7926178 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index 9361f82b528..c6b49807d94 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json index f7e1ffd4130..eea93b6b063 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index 12d758d9b8b..f5976e7eec6 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index 53a865285b6..d2a7744e237 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index 95754691012..3500b4a605a 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index be82322d1d8..4246171faf0 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index 5b8de2f15ef..f9cea076c93 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index cc1728d3d82..650632d4a9d 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index cefe8dec69e..6ab05a8ff9a 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index 52f6a72ef9a..dc8de943cb4 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index baa447adcc8..ebb5e7ec15b 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index b03e46c0934..34f35559c0f 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index e450baf4fe5..31773808f1c 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index 22e03e7ae84..d526b09470e 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index c2be3714397..1dbb4649be9 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index 1782c716e34..18a8b027d3b 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index e57f5e8fbd7..319b4979fcf 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index a6a3ccd115a..6d82311a78f 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 4fbc890e10a..a8adca2c4b9 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index e529b87d415..ab99918545d 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index 02bbd1d4f4a..e6aa5a4faa6 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index c4077e2331f..27caff58614 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 00dfc906589..434e9f70cf7 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index 523af3afd4f..da0aa2e6a9f 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index a39edfc8cf8..f63660a3cd9 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 1f1730fbf81..7b5a556643b 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index 38674f2cd5b..4f853b2b96f 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index 90ad47f260d..bea3dafeabd 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index ecdc4157c67..68d4feddea3 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index 8bf76be19f4..ddbc2f475c2 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 659e9c9623d..4eb88063ebf 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index a20253e0834..b2d3830f925 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index 962706318c8..66a95b727a5 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index b9b271def77..b04cda06749 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index 5bef4c17f0a..1d77a1a3c6a 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index 744c50b126c..9e18d6c81d8 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json index 958d728be47..5a53b692c8e 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index 13afb839289..af1f1da0e63 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json index bc49333a750..db66770d615 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json index a5aa4d693f8..f78621dd39b 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index ac3b0146083..8bc650492e4 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json index b5354c9a885..d019905f012 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json index 33c77734018..5dec9e36cef 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json index 4d244639058..a279744b04d 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json index 79dfa0e70c6..382ae3b6658 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json index 7c3df89b6a4..44072a16f7b 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json index 7e41166ae67..486e22ee91f 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index a7bb8ff898a..43ee9a7704c 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index eb7eb1cbc5a..1464aee2675 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index 4d42d84c8a0..33906f96031 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index 5405ef59f38..aff12975952 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index dadd4338f0d..49b8f03cfcf 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index 7572025304e..a55ed7cac7b 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json index 35e69380af4..d4f03e527ca 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index 3aa03329f50..b9426e53cb4 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index 0eaf1fe08c8..db44a855f00 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json index f42c64c4432..05e12d59cd5 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json index f2dc1d61fca..9b75f0a29ee 100644 --- a/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json +++ b/mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json index f877cbf30bd..dc0af2b1c43 100644 --- a/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json +++ b/mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json index 0d7223f52f5..9ba927b4a88 100644 --- a/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json +++ b/mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json index e318448da70..90fc3c39d9a 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index b568ef3c28c..632862d8281 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json index 840aeb36c5f..54bac0ec3e2 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json index d7965799fb6..c567b3dff9a 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json index a0c45cf4c9a..f1ad9234d97 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index 51951642c40..f513fa07b59 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index d69e9d539d5..d68d1c2fe1e 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index 666d9de75f6..9c38aa6d43e 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index b67716fba42..ff8aab7dd9e 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index 099094e7c06..592720b6ebf 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index 6f608d13645..43513fd5d5f 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index 952c1fca8cf..646aeedd242 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json index 0d3f2650bdb..1b38eea06ba 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json index 8bb2d688a1a..eb941532060 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json index 4122e8803ac..22c345648ec 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json index 8240ec9c4a9..b2ed727567b 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json index 08b6c1b0cc6..9d8e0ca1fda 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json index 732087cdc44..3bec41d3d14 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json +++ b/mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json index a076ae6cc9f..819c744595e 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json index b7d2c3e2eed..68a8101eb70 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index 3a893faff39..dd47dfd4a3f 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index 7d034cea715..702e90b09d9 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index b1b174597ab..10aa9bf6fad 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index e1f4e3224ad..cbc26766aba 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index 6d281b4f23b..59f48eb7903 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index 342ac6c4620..af9092a191a 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index 4dd791f1a90..bfd248c1111 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index 90cb00d86a4..8559c33d6f8 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index f1c9d5f27cd..2383aa11981 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index 142f90c69be..09641d9d31f 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index 2b5c55a18bc..7e0cfa9e465 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index 0a6362bc82d..7678703497f 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index dbdb53084d8..5a83681082c 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index 4306706463b..a14c158b06b 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index 30457d0762e..eb4fc941aca 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 523da424577..48d11dcd47b 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index b41a07f9ffa..f2d0e0648ed 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index 9116d51c643..534db6719a7 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 4681e027614..826fa175c36 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 656207c38f8..3ff00bc0066 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index c02040b8b0f..b096dc378ef 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index f339231a881..a3fde54d0e0 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index d6beaa00054..70edb4661de 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index 77d6d3713ca..3daae99cf55 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index 3deb52b2b44..995ad34d24b 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index e634538fa16..9940a7b242c 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index 83efbfab371..a2353d9cd01 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index c0984fe9e71..ef29c952cfc 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index 898195b8983..b03484b95bc 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json index b7df634d490..9331d8c3eb4 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json index ae87f0db151..9088f5d3b1e 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index b24de99905e..703829be684 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index b70fcbd4149..5e433195d9a 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index 849918850b6..1575042ffff 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index c2427e4abd2..c0aa3ba1b23 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index 9fd051522b7..647269388f2 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index 26fd43778bf..5dad315f94d 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index ff80fcec21e..5abf97f8206 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index cb3847b4bec..d7262a8d933 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 77dee7e13d7..c14e5708c0c 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index a05ffe0f8ec..37a2b5ef9cb 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index 5249b50344f..5d7cc485d6b 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index bbe5dfede8f..6a67d75a0f4 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index 87c4248cd47..67ed20e3710 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json index 38c3993f7c5..834ee80e8e1 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index c412fe2e6ba..b95b912f28e 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index 2e9f7373bff..7f0ff48fca1 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index f850611e6c2..7f5f2e8ec5d 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json index fb2d8d4f7ce..76559248ae9 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index 2195bfb30d7..2148cd20762 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json index 30e2fffee62..73973586c8b 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json index 7ac10312b69..224c6e49d73 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index affd710223a..dd1bf6e99a5 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index 37241af0e8c..bf2a2e0f42e 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index 25ba8d1df89..0d19d85ec3b 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json index 5e88d8eac71..2902789d8c9 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index f43915d8e83..e73320d67e3 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index f7faf6b482d..1a87bc4c4ff 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index 3e084c448c7..290bf880385 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", diff --git a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json index 53f0662a808..2029b150b96 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index af9abae2cc6..90d2e5d7b69 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index b56cd3f6394..fc3cbf9ceb1 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index 2dc412990ce..e4cf7f75331 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index 63c315bedae..a11282a2378 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index 49d2d72949c..6ee741a85e6 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index 267c1fba6ee..af4b89b745e 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index ff7f371a4c4..2a4d6c75152 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -3,7 +3,7 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index 26ffb508c4a..c8ec6a1c4c1 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index aee36c3ce7c..daf232e0867 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -3,7 +3,7 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index 123147a10b4..cd7ab9ab2df 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index 00650466139..059ef635246 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -3,7 +3,7 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index bfceb4f8565..581b27eb031 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json index d2517fbbb0d..c60bedf21b2 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index ffbc1b58c98..73dda3c865b 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index 9830170d67b..97b68c2b786 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index 8f1f8a62a98..9e8c31c0a14 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index 2a9126f46c3..cbe96ac449d 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index 114fedc21e0..4cdfd0c51af 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index 937620fb171..2e0bdd7a356 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json index 11fec15f8f1..058313a2328 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index 8a390b7186a..3ebefe16c04 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json index b3d2c0fd311..3e5c3a69a39 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index 6a8dfa8fcf1..2cf15710591 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json index 7588e2b5574..1e93f61396b 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index 57bb99c0af6..6ecbce878a2 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 02a387d6fd0..95534639bb0 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index bd56fe66b9d..44881318422 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index f5ff4f3bfe2..9f8de155696 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index e8db3fab93e..b6da88a9d85 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json index e7574077a8b..ea380eb5a05 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index 75f62fb2bc8..810e824ceec 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index c20fe0cd3ca..c62f847e112 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index 13691d47856..4deb1f092d1 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json index 589b2509aca..b06650daa5a 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index 0575f6fb77b..4b85268372e 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index 88ed753809b..846d956b820 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index 2be2f8b7b8b..a53edae9599 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index f95a5921fb7..3150b00e928 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index 17075a6ac7e..9c91fd929cb 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 105030cfffc..579f12b4e0c 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index e59427608a0..4d092d51f28 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index 34f6c223cb8..943dbd2171a 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index a8da1a688d3..81ffe92128b 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json index e0551419589..949d1dbdbbc 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json index 4dc462a452d..5c9d694994b 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index 77e96fd0a80..044028aef21 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index 4b6aca5c43f..55ad5516365 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json index 3ce1d9fe984..67a69ea0393 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json index 18af22344d0..3904cef4311 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json index a0c52914a8f..da2395d92e5 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index edfa9242eef..70ceddb8092 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index 3d3e4ad8534..7d5ac795b30 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json index cfbce33331d..934a09b8de3 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json index 59e0b1ec0d1..f204e48c500 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index 8cd9d218028..6fe86b71a24 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json index 363f217f67e..7d1c70b77d9 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json index d1912117792..7e3d011ad97 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json index 0d313eb204a..49826dee6bd 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json index daafc2f194a..a10131c2a39 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json index 1b02fe839e6..460002781fd 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json index adcfd69373f..0413371cb08 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json index d65876fc4ec..f1bdfadf093 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json index 86f3ee3ab58..51c4c340c41 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 34dd6a265ca..6cb44346bcd 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 7f8e6c35efe..9c2c0ce90be 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index 3ec385f6f9d..293d4054393 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json index 6d54ac9bd0f..06897da697d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -3,7 +3,7 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json index a339da97815..d0a290e02d5 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json index f3a296d4038..705f673283e 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json index 226e3704417..a9ee18f5fd3 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index 328da8b4663..3d401c51a14 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json index 8e2f0372814..62b80b0b0f2 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json index 79d832aa93c..7704fba5335 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json index 2f6fb9691fe..7e8f49a396e 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index 8a68e2be5b0..ae16a873285 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json index a5a19cb9745..f0814a999a1 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index 9fa9a5c2886..9885cf5025e 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json index 4636455e202..ee51e1f7e20 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json index 893cca287b6..be1437b6558 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json index 267496fd555..1dc9de89672 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json index b7502a2fbd9..29f6508a102 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json index 4495984796d..8efd95cbd83 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json index 0dcb4385826..bc0034b9cf9 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json index 6dba57e250b..732ba4a5fc7 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json index 52720072511..ca77155f5df 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json index b65f14ebf0a..4b8acbb2973 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index e6674e63ca3..d45a2618cd5 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json index d908aa970ff..9a3a7e5b6b9 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index f7c7b9d3509..7dc69202c86 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json index 6b4d1c0fcb4..58893ee5974 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index c93bbbcdef5..1683f8218d2 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json index ccfccc512c3..16c4f4555c9 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json index acd77a24c78..7728d271c72 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index 44cec939c66..8ebe6cbfabb 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index c09e3cbce45..a4a8ae811e5 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index c0b50f6fdb9..3b2f9113feb 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index 14cdb364616..2bbd621adcb 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 254c36272b1..647e4dcad01 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "03995ae29d28dd39bd2546423831987db68985de", + "baseline": "ccdb324b63e4942891942edde1216586a51774bd", "scenarios": [ { "id": "b1", From ec9f35e2eeee0a9adb55282c8e7e7a7c46596b21 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:22:10 -0700 Subject: [PATCH 323/387] perf(ci): plan the unit shards before the static-analysis gate instead of behind it (#23743) A caller's `needs` gate the whole called workflow, so while the plan job lived in unit-tests.yml it could not start until static analysis and typecheck had both finished and passed -- and the shard matrix then waited on it. The two hops were serial when they did not need to be: planning reads the checkout, a git diff against HEAD^1, the import graph and the checked-in timing baseline in config/scripts/ci-shard-timings.json, and consumes nothing that static analysis, typecheck or the native-cache primer produce. Planning moves to its own reusable workflow so pr.yml can run it against code_paths alone, overlapping it with the gate. Measured across 99 runs, the shard matrix is created a median 93s earlier (p25 47s, p90 241s, never later). Planning stays a required predecessor of the shards, so an empty assignment cannot expand the matrix. The gate itself is deliberately left in place. It fires on 22% of runs, and the shard queue wait knees hard above ~9 concurrent ARM jobs -- 4s median below that against 218s at 15-19 -- so admitting 8 doomed shards per failed run would cost more in queue pressure than it returns in latency. Cost is one 37s ubuntu-latest job, which does not touch the ARM pool the shards contend for. A planning failure still fails the PR: the shards are skipped, and verify's check_job requires success whenever the classifier says tests should run, so it reports `test: expected success, got skipped`. --- .github/workflows/node-next-compat.yml | 6 ++- .github/workflows/pr.yml | 16 ++++++- .github/workflows/unit-plan.yml | 48 +++++++++++++++++++ .github/workflows/unit-tests.yml | 33 ++----------- config/scripts/pr-code-change-scope.test.mjs | 7 +++ .../scripts/pr-workflow-parallelism.test.mjs | 10 ++-- 6 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/unit-plan.yml diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml index cb0d303f8c5..fc9b9cedaf9 100644 --- a/.github/workflows/node-next-compat.yml +++ b/.github/workflows/node-next-compat.yml @@ -34,8 +34,12 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} + unit_plan: + uses: ./.github/workflows/unit-plan.yml + test: - needs: [test_native_cache] + needs: [test_native_cache, unit_plan] uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24", "26"]' + shards: ${{ needs.unit_plan.outputs.shards }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba3ba7556ed..2a4065bff47 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -640,12 +640,24 @@ jobs: native-runtime: node node-version: '24' + # Why hoisted out of unit-tests.yml: a caller's `needs` gate the whole called workflow, so + # while planning lived in there it queued behind static analysis and typecheck and the shards + # then queued behind it. Planning needs none of their output, so running it against + # code_paths alone overlaps it with the gate. Measured a median 93s off the matrix's start. + unit_plan: + needs: [code_paths] + if: needs.code_paths.outputs.test == 'true' + uses: ./.github/workflows/unit-plan.yml + with: + selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} + test: - needs: [code_paths, test_native_cache, static_analysis, typecheck] + needs: [code_paths, unit_plan, test_native_cache, static_analysis, typecheck] # Honor cancellation while allowing the optional native-cache primer to skip. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && + needs.unit_plan.result == 'success' && needs.static_analysis.result == 'success' && needs.typecheck.result == 'success' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') @@ -653,7 +665,7 @@ jobs: with: node_versions: '["24"]' runner: ubuntu-24.04-arm - selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} + shards: ${{ needs.unit_plan.outputs.shards }} # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. diff --git a/.github/workflows/unit-plan.yml b/.github/workflows/unit-plan.yml new file mode 100644 index 00000000000..b427908b26d --- /dev/null +++ b/.github/workflows/unit-plan.yml @@ -0,0 +1,48 @@ +name: Unit plan + +# Why its own workflow: the caller's `needs` gate the whole called workflow, so while this lived +# inside unit-tests.yml it waited on static analysis and typecheck before it could even start — +# and the shards then waited on it. Planning needs neither (its inputs are the checkout, a git +# diff against HEAD^1, the import graph, and the checked-in timing baseline), so hoisting it out +# lets it overlap the gate instead of queueing behind it. Measured: a median 93s off the shard +# matrix's start. +on: + workflow_call: + inputs: + selection_mode: + description: Shadow validates selection; selected applies it only to draft PRs. + required: false + default: shadow + type: string + + outputs: + shards: + description: JSON array of shard assignments for the unit matrix. + value: ${{ jobs.plan.outputs.shards }} + +permissions: + contents: read + +jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + shards: ${{ steps.plan.outputs.shards }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Plan unit selection + id: plan + env: + ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }} + run: node config/scripts/ci-unit-plan.mjs + - uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/unit-selection.json + retention-days: 14 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9ed96847677..0090387b75c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -14,48 +14,23 @@ on: default: ubuntu-latest type: string - selection_mode: - description: Shadow validates selection; selected applies it only to draft PRs. - required: false - default: shadow + shards: + description: JSON array of shard assignments, produced by unit-plan.yml. + required: true type: string permissions: contents: read jobs: - plan: - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - shards: ${{ steps.plan.outputs.shards }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - - name: Plan unit selection - id: plan - env: - ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }} - run: node config/scripts/ci-unit-plan.mjs - - uses: actions/upload-artifact@v7 - continue-on-error: true - with: - name: unit-selection-attempt-${{ github.run_attempt }} - path: ci-shards/unit-selection.json - retention-days: 14 - test: - needs: plan name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} runs-on: ${{ inputs.runner }} strategy: fail-fast: false matrix: node: ${{ fromJSON(inputs.node_versions) }} - shard: ${{ fromJSON(needs.plan.outputs.shards) }} + shard: ${{ fromJSON(inputs.shards) }} steps: - name: Checkout diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index c23ea0e1140..1981623d281 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -597,10 +597,17 @@ describe('PR Checks skip wiring', () => { } expect(prWorkflow.jobs.test.needs).toEqual([ 'code_paths', + 'unit_plan', 'test_native_cache', 'static_analysis', 'typecheck' ]) + // Planning is deliberately NOT behind the static-analysis gate: it consumes nothing those + // jobs produce, so gating it only made the shards queue behind it. It still has to succeed + // before the shards run, or the matrix would expand from an empty assignment. + expect(prWorkflow.jobs.unit_plan.needs).toEqual(['code_paths']) + expect(prWorkflow.jobs.unit_plan.if).toBe("needs.code_paths.outputs.test == 'true'") + expect(prWorkflow.jobs.test.if).toContain("needs.unit_plan.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'") diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 6804527fe3c..6054f026144 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -107,8 +107,12 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.on.schedule).toHaveLength(1) expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') - expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(needs.plan.outputs.shards) }}') - expect(sharedTest.needs).toBe('plan') + expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(inputs.shards) }}') + // The shard matrix no longer needs an in-workflow plan job: planning moved to + // unit-plan.yml so it can run before the static-analysis gate clears. + expect(sharedTest.needs).toBeUndefined() + expect(unitTestWorkflow.jobs.plan).toBeUndefined() + expect(unitTestWorkflow.on.workflow_call.inputs.shards.required).toBe(true) expect(installStep.with['node-version']).toBe('${{ matrix.node }}') expect(installStep.with['cache-electron-package']).toBe('true') expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}') @@ -120,7 +124,7 @@ describe('PR workflow parallelism', () => { expect(workflow.jobs.test.needs).toContain('test_native_cache') expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') - expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache']) + expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache', 'unit_plan']) }) it('runs real-shell coverage once outside the general shards', () => { From a134d1259e195862ca66916262fd2c54029e6849 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:28:50 -0400 Subject: [PATCH 324/387] feat(mobile): tell users when a newer app binary is installable (#23755) * feat(mobile): tell users when a newer app binary is installable With OTA page updates, store releases get rare and users stop looking. The shell now asks the channel that installed it. Android sideload reads GitHub's mobile-android-v* tag refs and proves the release has an APK. iOS reads the App Store lookup. A home card above Desktops, dismissible per version, and Settings rows surface the result. The check runs on the desktop updater's cadence: cold start, foreground once 24 h have passed, and a 1 h retry after a failure. The releases atom feed was not used because it lists only the 10 newest releases, which are all desktop builds, so it never carries a mobile tag. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): parse update replies with zod schemas The anti-slop gate refuses Reflect.get on dynamic input. The GitHub refs, the release, the App Store lookup and the stored update record are now parsed into named schemas before they are read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the Android update source reads tag refs Record why the Android source reads tag refs. The releases atom feed and /releases?per_page=100 are both newest-first windows that desktop releases fill. Either would silently report "current" once a run of desktop builds pushes the newest mobile release out. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): load update state once and apply review rulings Every check and dismissal now awaits one shared store load. This replaces the merge that guessed whether a check had landed during the load. A manual check that fails while the store loads therefore keeps its 1 h retry instead of re-checking at once. - checking is derived from the in-flight check. - start() uses a per-start flag, so a StrictMode double start applies one load. - A check that finishes after stop() writes nothing. - A corrupt stored update record loses only itself. - Tag refs are parsed with a single schema. - The runtime wiring is folded into one file, and the card moves to home/. - The recorded App Store fixture is oxfmt-formatted, with the same parsed value. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the update timer armed across a stop and restart A check that stayed in flight across stop and restart returned 'failed' without rescheduling. The restart skipped arming because a check was in flight, which left a live checker with no timer until the next foreground. The stop counter is removed. schedule() already arms nothing while no start is active, and saving a real result after a stop is harmless. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: retrigger CI after the RPC recording repin (#23757) landed on main Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): trim the update checker and Settings rows - The load sets prefs and the due time only. start() re-arms the schedule after it. - The Settings result hides through one effect keyed on the result. - onUpdate receives the URL. - The version row is bound once. - The retry and timeout constants are no longer exported. - The unused AppUpdateChecker type is deleted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run the update check when its timer fires The armed timer is the due time. Re-checking the wall clock when the timer fired meant a clock stepped back skipped the check and re-armed nothing. The due-time guard now applies only on foreground. The binary version still comes from expoConfig.version. SDK 55 removed Constants.nativeAppVersion, so the no-expo-updates invariant is now named in the comment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): recover from a future check time and use Apple's page URL If the device clock was ahead when a check ran and was corrected later, the stored check time is in the future. Cold starts then armed a timer for the whole skew, and foreground never came due. The stored state now reads as never checked in that case. The iOS link is the lookup's trackViewUrl instead of a URL built from trackId. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): fit the future-check-time comment in the print width Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/app/_layout.tsx | 3 + mobile/app/settings.tsx | 2 + .../src/app-update/app-store-update-source.ts | 37 +++ .../src/app-update/app-update-checker.test.ts | 293 ++++++++++++++++++ mobile/src/app-update/app-update-checker.ts | 202 ++++++++++++ mobile/src/app-update/app-update-runtime.ts | 59 ++++ mobile/src/app-update/app-update-source.ts | 24 ++ .../src/app-update/app-update-sources.test.ts | 196 ++++++++++++ .../github-mobile-android-tag-refs.json | 242 +++++++++++++++ ...github-release-mobile-android-v0.0.50.json | 92 ++++++ .../itunes-lookup-com.stably.orca.mobile.json | 189 +++++++++++ .../github-release-update-source.ts | 77 +++++ mobile/src/home/AppUpdateCard.test.tsx | 50 +++ mobile/src/home/AppUpdateCard.tsx | 86 +++++ mobile/src/home/MobileHomeAppUpdateCard.tsx | 27 ++ mobile/src/home/MobileHomeListHeader.tsx | 2 + .../app-update-settings-rows.test.tsx | 90 ++++++ .../src/settings/app-update-settings-rows.tsx | 100 ++++++ .../settings/settings-app-update-section.tsx | 52 ++++ .../storage/app-update-preferences.test.ts | 32 ++ mobile/src/storage/app-update-preferences.ts | 64 ++++ 21 files changed, 1919 insertions(+) create mode 100644 mobile/src/app-update/app-store-update-source.ts create mode 100644 mobile/src/app-update/app-update-checker.test.ts create mode 100644 mobile/src/app-update/app-update-checker.ts create mode 100644 mobile/src/app-update/app-update-runtime.ts create mode 100644 mobile/src/app-update/app-update-source.ts create mode 100644 mobile/src/app-update/app-update-sources.test.ts create mode 100644 mobile/src/app-update/fixtures/github-mobile-android-tag-refs.json create mode 100644 mobile/src/app-update/fixtures/github-release-mobile-android-v0.0.50.json create mode 100644 mobile/src/app-update/fixtures/itunes-lookup-com.stably.orca.mobile.json create mode 100644 mobile/src/app-update/github-release-update-source.ts create mode 100644 mobile/src/home/AppUpdateCard.test.tsx create mode 100644 mobile/src/home/AppUpdateCard.tsx create mode 100644 mobile/src/home/MobileHomeAppUpdateCard.tsx create mode 100644 mobile/src/settings/app-update-settings-rows.test.tsx create mode 100644 mobile/src/settings/app-update-settings-rows.tsx create mode 100644 mobile/src/settings/settings-app-update-section.tsx create mode 100644 mobile/src/storage/app-update-preferences.test.ts create mode 100644 mobile/src/storage/app-update-preferences.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index c65008db7ce..711ee41ffd3 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -24,6 +24,7 @@ import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-n import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' +import { appUpdateChecker } from '../src/app-update/app-update-runtime' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -65,6 +66,8 @@ export default function RootLayout() { // Why: a rolled APNs/FCM token stops delivering silently, so every paired host // has to be re-registered with the new one as soon as the provider hands it over. useEffect(() => startPushTokenSync(), []) + // Cold start, then foreground and timer checks on the desktop updater's cadence. + useEffect(() => appUpdateChecker.start(), []) useEffect(() => startAndroidForegroundPushPresentation(), []) // Why: route `orca://pair?...` deep links to the confirm screen so diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index eb954a8e83d..8b3267e2f86 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -2,6 +2,7 @@ import { Linking } from 'react-native' import { useRouter } from 'expo-router' import SettingsMenuScreen from '../src/settings/settings-menu-screen' import { PendingCredentialCleanupCard } from '../src/settings/pending-credential-cleanup-card' +import { SettingsAppUpdateSection } from '../src/settings/settings-app-update-section' export default function NativeSettingsRoute() { const router = useRouter() @@ -10,6 +11,7 @@ export default function NativeSettingsRoute() { push={(route) => router.push(route)} openExternal={(url) => Linking.openURL(url)} > + ) diff --git a/mobile/src/app-update/app-store-update-source.ts b/mobile/src/app-update/app-store-update-source.ts new file mode 100644 index 00000000000..cad70e0840d --- /dev/null +++ b/mobile/src/app-update/app-store-update-source.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import type { AppUpdateCheckResult, AppUpdateSource } from './app-update-source' +import { isNewerReleaseVersion } from './app-update-source' + +const LOOKUP_URL = 'https://itunes.apple.com/lookup?bundleId=com.stably.orca.mobile' + +type Fetch = typeof fetch + +const lookupSchema = z.looseObject({ results: z.array(z.unknown()) }) +const listingSchema = z.looseObject({ version: z.string(), trackViewUrl: z.string() }) + +/** + * The store listing's version and page. TestFlight installs have no receipt signal without a new + * dependency; they stay quiet because their build is at or ahead of the store's version. + */ +export function parseAppStoreLookup(reply: unknown): { version: string; url: string } | null { + const lookup = lookupSchema.safeParse(reply) + const listing = listingSchema.safeParse(lookup.success ? lookup.data.results[0] : null) + return listing.success ? { version: listing.data.version, url: listing.data.trackViewUrl } : null +} + +export function createAppStoreUpdateSource(fetchImpl: Fetch): AppUpdateSource { + return { + async check(installedVersion, signal): Promise { + const reply = await fetchImpl(LOOKUP_URL, { signal }) + if (!reply.ok) { + throw new Error(`App Store lookup HTTP ${reply.status}`) + } + const listing = parseAppStoreLookup(await reply.json()) + return listing && isNewerReleaseVersion(listing.version, installedVersion) + ? { kind: 'available', version: listing.version, url: listing.url } + : { kind: 'current' } + } + } +} + +export const appStoreUpdateSource = createAppStoreUpdateSource((input, init) => fetch(input, init)) diff --git a/mobile/src/app-update/app-update-checker.test.ts b/mobile/src/app-update/app-update-checker.test.ts new file mode 100644 index 00000000000..c76c7d1be76 --- /dev/null +++ b/mobile/src/app-update/app-update-checker.test.ts @@ -0,0 +1,293 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppUpdatePreferences } from '../storage/app-update-preferences' +import { + APP_UPDATE_CHECK_INTERVAL_MS, + createAppUpdateChecker, + undismissedAppUpdate, + type AppUpdateCheckerDeps +} from './app-update-checker' +import type { AppUpdateCheckResult, AppUpdateSource } from './app-update-source' + +const HOUR = 60 * 60 * 1000 +const T0 = Date.UTC(2026, 8, 28, 12) +const RELEASE_051 = { version: '0.0.51', url: 'https://example.test/0.0.51' } + +type Harness = ReturnType + +function harness(opts: { + stored?: Partial + installedVersion?: string + replies?: (AppUpdateCheckResult | Error | 'hang' | Promise)[] + load?: Promise +}) { + const replies = [...(opts.replies ?? [])] + let foreground: () => void = () => {} + const saves: unknown[] = [] + const source: AppUpdateSource = { + check: vi.fn(async () => { + const reply = replies.shift() ?? { kind: 'current' } + if (reply instanceof Promise) { + return reply + } + if (reply === 'hang') { + return new Promise(() => {}) + } + if (reply instanceof Error) { + throw reply + } + return reply + }) + } + const deps = { + source, + installedVersion: opts.installedVersion ?? '0.0.48', + now: () => Date.now(), + setTimer: (run: () => void, delayMs: number) => setTimeout(run, delayMs), + clearTimer: (handle: ReturnType) => clearTimeout(handle), + subscribeForeground: (listener: () => void) => { + foreground = listener + return () => { + foreground = () => {} + } + }, + loadPreferences: vi.fn(async () => { + await opts.load + return { + lastCheckedAt: null, + latest: null, + dismissedVersion: null, + ...opts.stored + } + }), + saveCheck: async (checkedAt: number, latest: AppUpdatePreferences['latest']) => { + saves.push({ checkedAt, latest }) + }, + saveDismissedVersion: async (version: string) => { + saves.push({ dismissed: version }) + } + } satisfies AppUpdateCheckerDeps + const checker = createAppUpdateChecker(deps) + return { checker, source, saves, deps, foreground: () => foreground() } +} + +const checks = (h: Harness) => vi.mocked(h.source.check).mock.calls.length + +describe('app update checker', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(T0) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('checks on cold start when it has never succeeded, and records the result', async () => { + const h = harness({ replies: [{ kind: 'available', ...RELEASE_051 }] }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(checks(h)).toBe(1) + expect(h.checker.getSnapshot()).toMatchObject({ lastCheckedAt: T0, available: RELEASE_051 }) + expect(h.saves).toEqual([{ checkedAt: T0, latest: RELEASE_051 }]) + }) + + it('skips the cold-start check inside 24 h, then runs it when the day is up', async () => { + const h = harness({ stored: { lastCheckedAt: T0 - 2 * HOUR, latest: RELEASE_051 } }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(checks(h)).toBe(0) + expect(h.checker.getSnapshot().available).toEqual(RELEASE_051) + await vi.advanceTimersByTimeAsync(22 * HOUR - 1) + expect(checks(h)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(checks(h)).toBe(1) + }) + + it('checks on foreground only once the last success is 24 h old', async () => { + const h = harness({ stored: { lastCheckedAt: T0 - 23 * HOUR } }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + h.foreground() + await vi.advanceTimersByTimeAsync(0) + expect(checks(h)).toBe(0) + // Background suspends timers; the system clock still moves. + vi.setSystemTime(T0 + HOUR) + h.foreground() + await vi.advanceTimersByTimeAsync(0) + expect(checks(h)).toBe(1) + }) + + it('retries a failed check after 1 h, not sooner, and keeps the last success time', async () => { + const h = harness({ + stored: { lastCheckedAt: T0 - 30 * HOUR }, + replies: [new Error('offline')] + }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(checks(h)).toBe(1) + expect(h.checker.getSnapshot().lastCheckedAt).toBe(T0 - 30 * HOUR) + h.foreground() + await vi.advanceTimersByTimeAsync(HOUR - 1) + expect(checks(h)).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(checks(h)).toBe(2) + expect(h.checker.getSnapshot().lastCheckedAt).toBe(T0 + HOUR) + }) + + it('bounds a check at 8 s and counts it as failed', async () => { + const h = harness({ stored: { lastCheckedAt: T0 }, replies: ['hang'] }) + h.checker.start() + const outcome = h.checker.checkNow() + await vi.advanceTimersByTimeAsync(8000) + await expect(outcome).resolves.toBe('failed') + expect(h.checker.getSnapshot().checking).toBe(false) + }) + + it('joins a manual check to one already in flight', async () => { + const h = harness({ stored: { lastCheckedAt: T0 }, replies: [{ kind: 'current' }] }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + const [first, second] = [h.checker.checkNow(), h.checker.checkNow()] + await expect(Promise.all([first, second])).resolves.toEqual(['up-to-date', 'up-to-date']) + expect(checks(h)).toBe(1) + }) + + it('manual check reschedules the next automatic check a full day out', async () => { + const h = harness({ stored: { lastCheckedAt: T0 - 23 * HOUR } }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + await h.checker.checkNow() + await vi.advanceTimersByTimeAsync(24 * HOUR - 1) + expect(checks(h)).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(checks(h)).toBe(2) + }) + + it('dismisses one version only; the next release offers again', async () => { + const h = harness({ + stored: { lastCheckedAt: T0 - 25 * HOUR }, + replies: [ + { kind: 'available', ...RELEASE_051 }, + { kind: 'available', version: '0.0.52', url: 'https://example.test/0.0.52' } + ] + }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + h.checker.dismiss('0.0.51') + await vi.advanceTimersByTimeAsync(0) + expect(undismissedAppUpdate(h.checker.getSnapshot())).toBeNull() + expect(h.checker.getSnapshot().available).toEqual(RELEASE_051) + expect(h.saves).toContainEqual({ dismissed: '0.0.51' }) + await h.checker.checkNow() + expect(undismissedAppUpdate(h.checker.getSnapshot())?.version).toBe('0.0.52') + }) + + it('keeps a stored dismissal across restarts', async () => { + const h = harness({ + stored: { lastCheckedAt: T0, latest: RELEASE_051, dismissedVersion: '0.0.51' } + }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(undismissedAppUpdate(h.checker.getSnapshot())).toBeNull() + }) + + it('stops offering a stored update once the binary has been updated to it', async () => { + const h = harness({ + installedVersion: '0.0.51', + stored: { lastCheckedAt: T0, latest: RELEASE_051 } + }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(h.checker.getSnapshot().available).toBeNull() + }) + + it('stops scheduling after stop()', async () => { + const h = harness({ stored: { lastCheckedAt: T0 - 2 * HOUR } }) + const stop = h.checker.start() + await vi.advanceTimersByTimeAsync(0) + stop() + await vi.advanceTimersByTimeAsync(2 * APP_UPDATE_CHECK_INTERVAL_MS) + expect(checks(h)).toBe(0) + }) + + it('keeps the 1 h retry for a manual check that fails while the store is still loading', async () => { + let finishLoad: () => void = () => {} + const h = harness({ + stored: { lastCheckedAt: T0 - 30 * HOUR }, + replies: [new Error('offline')], + load: new Promise((resolve) => { + finishLoad = resolve + }) + }) + h.checker.start() + const outcome = h.checker.checkNow() + finishLoad() + await expect(outcome).resolves.toBe('failed') + await vi.advanceTimersByTimeAsync(HOUR - 1) + expect(checks(h)).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(checks(h)).toBe(2) + }) + + it('applies one load and runs one check across a StrictMode-style double start', async () => { + const h = harness({}) + h.checker.start()() + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(h.deps.loadPreferences).toHaveBeenCalledTimes(1) + expect(checks(h)).toBe(1) + }) + + function deferredReply() { + let answer: (result: AppUpdateCheckResult) => void = () => {} + const reply = new Promise((resolve) => { + answer = resolve + }) + return { reply, answer: (result: AppUpdateCheckResult) => answer(result) } + } + + it('arms no timer for a check that finishes after stop()', async () => { + const pending = deferredReply() + const h = harness({ stored: { lastCheckedAt: T0 }, replies: [pending.reply] }) + const stop = h.checker.start() + await vi.advanceTimersByTimeAsync(0) + const outcome = h.checker.checkNow() + await vi.advanceTimersByTimeAsync(0) + stop() + pending.answer({ kind: 'current' }) + await outcome + expect(vi.getTimerCount()).toBe(0) + }) + + it('schedules the next check when a check in flight across stop and restart completes', async () => { + const pending = deferredReply() + const h = harness({ stored: { lastCheckedAt: T0 }, replies: [pending.reply] }) + const stop = h.checker.start() + await vi.advanceTimersByTimeAsync(0) + const outcome = h.checker.checkNow() + await vi.advanceTimersByTimeAsync(0) + stop() + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + pending.answer({ kind: 'current' }) + await outcome + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(24 * HOUR) + expect(checks(h)).toBe(2) + }) + + it('runs the check when the armed timer fires even if the clock stepped back', async () => { + const h = harness({ stored: { lastCheckedAt: T0 - 2 * HOUR } }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + vi.setSystemTime(Date.now() - 10 * 60 * 1000) + await vi.advanceTimersByTimeAsync(22 * HOUR) + expect(checks(h)).toBe(1) + }) + + it('treats a stored check time ahead of the clock as never checked', async () => { + const h = harness({ stored: { lastCheckedAt: T0 + 365 * 24 * HOUR } }) + h.checker.start() + await vi.advanceTimersByTimeAsync(0) + expect(checks(h)).toBe(1) + }) +}) diff --git a/mobile/src/app-update/app-update-checker.ts b/mobile/src/app-update/app-update-checker.ts new file mode 100644 index 00000000000..7c2855d9373 --- /dev/null +++ b/mobile/src/app-update/app-update-checker.ts @@ -0,0 +1,202 @@ +import type { AppUpdatePreferences, KnownAppUpdate } from '../storage/app-update-preferences' +import { EMPTY_APP_UPDATE_PREFERENCES } from '../storage/app-update-preferences' +import type { AppUpdateSource } from './app-update-source' +import { isNewerReleaseVersion } from './app-update-source' + +// Same cadence as the desktop updater (src/main/updater-events.ts). +export const APP_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +const APP_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +const APP_UPDATE_CHECK_TIMEOUT_MS = 8000 + +type TimerHandle = ReturnType + +export type AppUpdateCheckOutcome = 'available' | 'up-to-date' | 'failed' + +export type AppUpdateState = { + readonly lastCheckedAt: number | null + /** Newer than the installed binary; recomputed from `latest`, so installing it clears this. */ + readonly available: KnownAppUpdate | null + readonly dismissedVersion: string | null + readonly checking: boolean +} + +/** The update home should offer: dismissal hides only the version that was dismissed. */ +export function undismissedAppUpdate(state: AppUpdateState): KnownAppUpdate | null { + return state.available && state.available.version !== state.dismissedVersion + ? state.available + : null +} + +export type AppUpdateCheckerDeps = { + source: AppUpdateSource | null + installedVersion: string | null + now: () => number + setTimer: (run: () => void, delayMs: number) => TimerHandle + clearTimer: (handle: TimerHandle) => void + subscribeForeground: (onForeground: () => void) => () => void + loadPreferences: () => Promise + saveCheck: (checkedAt: number, latest: KnownAppUpdate | null) => Promise + saveDismissedVersion: (version: string) => Promise +} + +export function createAppUpdateChecker(deps: AppUpdateCheckerDeps) { + let prefs = EMPTY_APP_UPDATE_PREFERENCES + let loaded: Promise | null = null + let inFlight: Promise | null = null + let nextDueAt = 0 + let timer: TimerHandle | null = null + let activeStarts = 0 + let snapshot = buildSnapshot() + const listeners = new Set<() => void>() + + function buildSnapshot(): AppUpdateState { + const latest = prefs.latest + const available = + latest && + deps.installedVersion && + isNewerReleaseVersion(latest.version, deps.installedVersion) + ? latest + : null + return { + lastCheckedAt: prefs.lastCheckedAt, + available, + dismissedVersion: prefs.dismissedVersion, + checking: inFlight !== null + } + } + + function publish(): void { + snapshot = buildSnapshot() + for (const listener of listeners) { + listener() + } + } + + function clearScheduled(): void { + if (timer !== null) { + deps.clearTimer(timer) + timer = null + } + } + + function schedule(dueAt: number): void { + nextDueAt = dueAt + clearScheduled() + if (activeStarts > 0) { + timer = deps.setTimer( + () => { + timer = null + // The timer is the due time; re-reading the wall clock would stall on a clock step back. + if (activeStarts > 0 && inFlight === null) { + void checkNow() + } + }, + Math.max(0, dueAt - deps.now()) + ) + } + } + + /** The stored state is read once; every check and dismissal waits for it, so none is overwritten. */ + function ensureLoaded(): Promise { + loaded ??= deps.loadPreferences().then((stored) => { + prefs = stored + // A stored check time ahead of the clock (clock since corrected) would defer the next check. + const checkedAt = stored.lastCheckedAt + nextDueAt = + checkedAt === null || checkedAt > deps.now() ? 0 : checkedAt + APP_UPDATE_CHECK_INTERVAL_MS + publish() + }) + return loaded + } + + function runIfDue(): void { + void ensureLoaded().then(() => { + if (activeStarts > 0 && inFlight === null && deps.now() >= nextDueAt) { + void checkNow() + } + }) + } + + async function runCheck(source: AppUpdateSource, installed: string) { + await ensureLoaded() + const controller = new AbortController() + // Why race: the bound holds even for a request that does not honour the signal. + const timedOut = new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => reject(new Error('update check timed out'))) + }) + const timeout = deps.setTimer(() => controller.abort(), APP_UPDATE_CHECK_TIMEOUT_MS) + try { + const result = await Promise.race([source.check(installed, controller.signal), timedOut]) + const checkedAt = deps.now() + const latest = + result.kind === 'available' ? { version: result.version, url: result.url } : null + prefs = { ...prefs, lastCheckedAt: checkedAt, latest } + schedule(checkedAt + APP_UPDATE_CHECK_INTERVAL_MS) + await deps.saveCheck(checkedAt, latest).catch(() => {}) + return latest ? 'available' : 'up-to-date' + } catch { + schedule(deps.now() + APP_UPDATE_RETRY_INTERVAL_MS) + return 'failed' + } finally { + deps.clearTimer(timeout) + } + } + + /** Runs now whatever the cadence says; joins a check already in flight. */ + function checkNow(): Promise { + const { source, installedVersion } = deps + if (!source || !installedVersion) { + return Promise.resolve('failed') + } + if (inFlight === null) { + inFlight = runCheck(source, installedVersion).finally(() => { + inFlight = null + publish() + }) + publish() + } + return inFlight + } + + /** Cold start: reads what the last run saw, then checks only if the cadence says it is due. */ + function start(): () => void { + let active = true + activeStarts += 1 + const unsubscribe = deps.subscribeForeground(runIfDue) + void ensureLoaded().then(() => { + if (active && inFlight === null) { + schedule(nextDueAt) + } + }) + return () => { + if (!active) { + return + } + active = false + activeStarts -= 1 + unsubscribe() + if (activeStarts === 0) { + clearScheduled() + } + } + } + + function dismiss(version: string): void { + void ensureLoaded().then(() => { + prefs = { ...prefs, dismissedVersion: version } + publish() + void deps.saveDismissedVersion(version).catch(() => {}) + }) + } + + return { + start, + checkNow, + dismiss, + getSnapshot: (): AppUpdateState => snapshot, + subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) + } + } +} diff --git a/mobile/src/app-update/app-update-runtime.ts b/mobile/src/app-update/app-update-runtime.ts new file mode 100644 index 00000000000..aa2d72c7608 --- /dev/null +++ b/mobile/src/app-update/app-update-runtime.ts @@ -0,0 +1,59 @@ +import Constants from 'expo-constants' +import * as Linking from 'expo-linking' +import { useSyncExternalStore } from 'react' +import { AppState, Platform } from 'react-native' +import { + loadAppUpdatePreferences, + saveAppUpdateCheck, + saveDismissedAppUpdateVersion +} from '../storage/app-update-preferences' +import { appStoreUpdateSource } from './app-store-update-source' +import { createAppUpdateChecker, type AppUpdateState } from './app-update-checker' +import type { AppUpdateSource } from './app-update-source' +import { githubReleaseUpdateSource } from './github-release-update-source' + +/** + * Invariant: this app does not use expo-updates, so `expoConfig` is the manifest embedded in the + * binary and its version is the binary's (app.json `version`, Android `versionName`). SDK 55 + * removed `Constants.nativeAppVersion`; adopting expo-updates would make this the update's version. + */ +export const installedAppVersion: string | null = Constants.expoConfig?.version ?? null + +/** The channel that installed this binary. */ +function resolveAppUpdateSource(): AppUpdateSource | null { + if (Platform.OS === 'android') { + return githubReleaseUpdateSource + } + if (Platform.OS === 'ios') { + return appStoreUpdateSource + } + return null +} + +export const appUpdateChecker = createAppUpdateChecker({ + source: resolveAppUpdateSource(), + installedVersion: installedAppVersion, + now: () => Date.now(), + setTimer: (run, delayMs) => setTimeout(run, delayMs), + clearTimer: (handle) => clearTimeout(handle), + subscribeForeground: (onForeground) => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + onForeground() + } + }) + return () => subscription.remove() + }, + loadPreferences: loadAppUpdatePreferences, + saveCheck: saveAppUpdateCheck, + saveDismissedVersion: saveDismissedAppUpdateVersion +}) + +export function useAppUpdateState(): AppUpdateState { + return useSyncExternalStore(appUpdateChecker.subscribe, appUpdateChecker.getSnapshot) +} + +/** Opens the channel's page for the update: the GitHub release (Android) or App Store (iOS). */ +export function openAppUpdate(url: string): void { + void Linking.openURL(url).catch(() => {}) +} diff --git a/mobile/src/app-update/app-update-source.ts b/mobile/src/app-update/app-update-source.ts new file mode 100644 index 00000000000..3fe41b7a1ab --- /dev/null +++ b/mobile/src/app-update/app-update-source.ts @@ -0,0 +1,24 @@ +import { + compareAppVersions, + isPrereleaseAppVersion, + isValidAppVersion +} from '../../../src/shared/app-version' + +export type AppUpdateCheckResult = + | { kind: 'available'; version: string; url: string } + | { kind: 'current' } + +/** One install channel. Rejects when the channel could not be asked; never retries itself. */ +export type AppUpdateSource = { + check(installedVersion: string, signal: AbortSignal): Promise +} + +/** True for a plain release version newer than the installed one; prerelease suffixes never qualify. */ +export function isNewerReleaseVersion(candidate: string, installed: string): boolean { + return ( + isValidAppVersion(candidate) && + isValidAppVersion(installed) && + !isPrereleaseAppVersion(candidate) && + compareAppVersions(candidate, installed) > 0 + ) +} diff --git a/mobile/src/app-update/app-update-sources.test.ts b/mobile/src/app-update/app-update-sources.test.ts new file mode 100644 index 00000000000..22c04b641a7 --- /dev/null +++ b/mobile/src/app-update/app-update-sources.test.ts @@ -0,0 +1,196 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { createAppStoreUpdateSource, parseAppStoreLookup } from './app-store-update-source' +import { isNewerReleaseVersion } from './app-update-source' +import { + createGithubReleaseUpdateSource, + installableReleaseUrl, + parseMobileAndroidTagVersions +} from './github-release-update-source' + +// Recorded once from the live endpoints on 2026-09-28, unedited. +function fixture(name: string): unknown { + return JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) +} +const tagRefs = fixture('github-mobile-android-tag-refs.json') +const release050 = fixture('github-release-mobile-android-v0.0.50.json') +const lookup = fixture('itunes-lookup-com.stably.orca.mobile.json') + +const REFS_URL = + 'https://api.github.com/repos/stablyai/orca/git/matching-refs/tags/mobile-android-v' +const releaseUrl = (version: string) => + `https://api.github.com/repos/stablyai/orca/releases/tags/mobile-android-v${version}` + +function fakeFetch(routes: Record) { + const requested: string[] = [] + const fetchImpl: typeof fetch = async (input) => { + const url = String(input) + requested.push(url) + const route = routes[url] + if (!route) { + throw new Error(`unexpected request ${url}`) + } + return new Response(JSON.stringify(route.body ?? {}), { status: route.status }) + } + return { fetchImpl, requested } +} + +const signal = new AbortController().signal + +describe('version compare', () => { + it('orders plain versions numerically and ignores prerelease or malformed candidates', () => { + expect(isNewerReleaseVersion('0.0.51', '0.0.48')).toBe(true) + expect(isNewerReleaseVersion('0.0.10', '0.0.9')).toBe(true) + expect(isNewerReleaseVersion('0.0.48', '0.0.48')).toBe(false) + expect(isNewerReleaseVersion('0.0.47', '0.0.48')).toBe(false) + expect(isNewerReleaseVersion('0.0.52-rc.1', '0.0.48')).toBe(false) + expect(isNewerReleaseVersion('latest', '0.0.48')).toBe(false) + }) +}) + +describe('GitHub release source (Android sideload)', () => { + it('reads every mobile-android tag from the recorded refs', () => { + const versions = parseMobileAndroidTagVersions(tagRefs) + expect(versions).toHaveLength(24) + expect(versions).toContain('0.0.50') + expect(versions).not.toContain('0.0.49') + }) + + it('accepts the recorded release only because it is published with an APK', () => { + expect(installableReleaseUrl(release050)).toBe( + 'https://github.com/stablyai/orca/releases/tag/mobile-android-v0.0.50' + ) + expect(installableReleaseUrl({ ...Object(release050), draft: true })).toBeNull() + expect(installableReleaseUrl({ ...Object(release050), assets: [] })).toBeNull() + }) + + it('offers the newest tag above the installed version once its release is proven', async () => { + const { fetchImpl, requested } = fakeFetch({ + [REFS_URL]: { status: 200, body: tagRefs }, + [releaseUrl('0.0.50')]: { status: 200, body: release050 } + }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.47', signal) + ).resolves.toEqual({ + kind: 'available', + version: '0.0.50', + url: 'https://github.com/stablyai/orca/releases/tag/mobile-android-v0.0.50' + }) + expect(requested).toEqual([REFS_URL, releaseUrl('0.0.50')]) + }) + + it('falls back past a tag whose build never published a release', async () => { + const release048 = { ...Object(release050), html_url: 'https://example.test/048' } + const { fetchImpl } = fakeFetch({ + [REFS_URL]: { status: 200, body: tagRefs }, + [releaseUrl('0.0.50')]: { status: 404 }, + [releaseUrl('0.0.48')]: { status: 200, body: release048 } + }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.47', signal) + ).resolves.toEqual({ + kind: 'available', + version: '0.0.48', + url: 'https://example.test/048' + }) + }) + + it('probes at most three candidates and then reports current', async () => { + const { fetchImpl, requested } = fakeFetch({ + [REFS_URL]: { status: 200, body: tagRefs }, + [releaseUrl('0.0.50')]: { status: 404 }, + [releaseUrl('0.0.48')]: { status: 404 }, + [releaseUrl('0.0.47')]: { status: 404 } + }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.14', signal) + ).resolves.toEqual({ + kind: 'current' + }) + expect(requested).toHaveLength(4) + }) + + it('makes no release probe when nothing is newer', async () => { + const { fetchImpl, requested } = fakeFetch({ [REFS_URL]: { status: 200, body: tagRefs } }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.51', signal) + ).resolves.toEqual({ + kind: 'current' + }) + expect(requested).toEqual([REFS_URL]) + }) + + it('opens the release page GitHub reports, not one built from the tag', async () => { + const renamed = { + ...Object(release050), + html_url: 'https://github.com/stablyai/orca/releases/tag/renamed-page' + } + const { fetchImpl } = fakeFetch({ + [REFS_URL]: { status: 200, body: tagRefs }, + [releaseUrl('0.0.50')]: { status: 200, body: renamed } + }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.48', signal) + ).resolves.toEqual({ + kind: 'available', + version: '0.0.50', + url: 'https://github.com/stablyai/orca/releases/tag/renamed-page' + }) + }) + + it('rejects on a release probe 5xx instead of moving to an older candidate', async () => { + const { fetchImpl, requested } = fakeFetch({ + [REFS_URL]: { status: 200, body: tagRefs }, + [releaseUrl('0.0.50')]: { status: 502 } + }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.47', signal) + ).rejects.toThrow('release HTTP 502') + expect(requested).toEqual([REFS_URL, releaseUrl('0.0.50')]) + }) + + it('rejects when a release probe cannot reach GitHub', async () => { + const { fetchImpl } = fakeFetch({ [REFS_URL]: { status: 200, body: tagRefs } }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.47', signal) + ).rejects.toThrow('unexpected request') + }) + + it('rejects when GitHub refuses, so the check counts as failed rather than current', async () => { + const { fetchImpl } = fakeFetch({ [REFS_URL]: { status: 403 } }) + await expect( + createGithubReleaseUpdateSource(fetchImpl).check('0.0.47', signal) + ).rejects.toThrow('tag refs HTTP 403') + }) +}) + +describe('App Store source (iOS)', () => { + const LOOKUP = 'https://itunes.apple.com/lookup?bundleId=com.stably.orca.mobile' + + it('reads the store version and App Store page from the recorded lookup', () => { + expect(parseAppStoreLookup(lookup)).toEqual({ + version: '0.0.51', + url: 'https://apps.apple.com/us/app/orca-ide/id6766130217?uo=4' + }) + }) + + it('offers the store version only when it is newer than the installed one', async () => { + const { fetchImpl } = fakeFetch({ [LOOKUP]: { status: 200, body: lookup } }) + const source = createAppStoreUpdateSource(fetchImpl) + await expect(source.check('0.0.48', signal)).resolves.toEqual({ + kind: 'available', + version: '0.0.51', + url: 'https://apps.apple.com/us/app/orca-ide/id6766130217?uo=4' + }) + await expect(source.check('0.0.51', signal)).resolves.toEqual({ kind: 'current' }) + }) + + it('stays quiet when the lookup has no listing for this bundle id', async () => { + const { fetchImpl } = fakeFetch({ + [LOOKUP]: { status: 200, body: { resultCount: 0, results: [] } } + }) + await expect(createAppStoreUpdateSource(fetchImpl).check('0.0.1', signal)).resolves.toEqual({ + kind: 'current' + }) + }) +}) diff --git a/mobile/src/app-update/fixtures/github-mobile-android-tag-refs.json b/mobile/src/app-update/fixtures/github-mobile-android-tag-refs.json new file mode 100644 index 00000000000..8be30db967b --- /dev/null +++ b/mobile/src/app-update/fixtures/github-mobile-android-tag-refs.json @@ -0,0 +1,242 @@ +[ + { + "ref": "refs/tags/mobile-android-v0.0.14", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjE0", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.14", + "object": { + "sha": "3247256da0e73409104e17ebf1c476a5cb60b3cf", + "type": "tag", + "url": "https://api.github.com/repos/stablyai/orca/git/tags/3247256da0e73409104e17ebf1c476a5cb60b3cf" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.15", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjE1", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.15", + "object": { + "sha": "7b8adaf5f943f1b07668a8e38662690fd8cbf0d0", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/7b8adaf5f943f1b07668a8e38662690fd8cbf0d0" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.16", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjE2", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.16", + "object": { + "sha": "20882d7a25c33a43b50ea19462a0e50f2deb11c4", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/20882d7a25c33a43b50ea19462a0e50f2deb11c4" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.17", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjE3", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.17", + "object": { + "sha": "c50391df84ee4034eca2cd5da49ca9ae4c5c4f7d", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/c50391df84ee4034eca2cd5da49ca9ae4c5c4f7d" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.18", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjE4", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.18", + "object": { + "sha": "9256bd39daaeda0fd86b67f1f50e70850e4f0005", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/9256bd39daaeda0fd86b67f1f50e70850e4f0005" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.19", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjE5", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.19", + "object": { + "sha": "d7322ec1f239db245c557f85370600ca67639137", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/d7322ec1f239db245c557f85370600ca67639137" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.21", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjIx", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.21", + "object": { + "sha": "ad13532ab1f8ea31fc434da4d503ba0c93260fb3", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/ad13532ab1f8ea31fc434da4d503ba0c93260fb3" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.22", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjIy", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.22", + "object": { + "sha": "748bced2e61d47444d6536eecadf8484394e4609", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/748bced2e61d47444d6536eecadf8484394e4609" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.24", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjI0", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.24", + "object": { + "sha": "3c963dd285783609ab0367cfd95a6b45de2b1603", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/3c963dd285783609ab0367cfd95a6b45de2b1603" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.25", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjI1", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.25", + "object": { + "sha": "d1cc78d5c5229478fb9f1675a3d3f506d332b367", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/d1cc78d5c5229478fb9f1675a3d3f506d332b367" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.26", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjI2", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.26", + "object": { + "sha": "cf04845f5b4d787292f041b05f839a8e151fa41b", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/cf04845f5b4d787292f041b05f839a8e151fa41b" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.27", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjI3", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.27", + "object": { + "sha": "20ebe858af628f3e2b5f73838310636dec6fe0ed", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/20ebe858af628f3e2b5f73838310636dec6fe0ed" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.30", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjMw", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.30", + "object": { + "sha": "ef87461bfe0346bcef163db971c990ea3adce495", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/ef87461bfe0346bcef163db971c990ea3adce495" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.31", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjMx", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.31", + "object": { + "sha": "1c275336eddf252a1d1378f9b55a6033278999e8", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/1c275336eddf252a1d1378f9b55a6033278999e8" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.32", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjMy", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.32", + "object": { + "sha": "c445f26541a0ecb465fc28a7e1a7b5ee5624a0dd", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/c445f26541a0ecb465fc28a7e1a7b5ee5624a0dd" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.36", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjM2", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.36", + "object": { + "sha": "4543bb68263a89ab520cea62ca69d7ac78330dd3", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/4543bb68263a89ab520cea62ca69d7ac78330dd3" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.37", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjM3", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.37", + "object": { + "sha": "5a2b329d8d7e96c974aecf4d3e12b707abd7d732", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/5a2b329d8d7e96c974aecf4d3e12b707abd7d732" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.42", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjQy", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.42", + "object": { + "sha": "20aeb0cb990a17f328b673f51717824d2f9c10ea", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/20aeb0cb990a17f328b673f51717824d2f9c10ea" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.43", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjQz", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.43", + "object": { + "sha": "cbca291aa7d7270fe8ed8cac5d08fc0b0910c309", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/cbca291aa7d7270fe8ed8cac5d08fc0b0910c309" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.44", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjQ0", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.44", + "object": { + "sha": "ecfc547ece7aa9483547df26661e90927d1983cc", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/ecfc547ece7aa9483547df26661e90927d1983cc" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.46", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjQ2", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.46", + "object": { + "sha": "47d462743c2b990b95942c75fb2c1c2493d7f478", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/47d462743c2b990b95942c75fb2c1c2493d7f478" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.47", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjQ3", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.47", + "object": { + "sha": "38c61379ffd19d3f573877ecfa1099e3616974dd", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/38c61379ffd19d3f573877ecfa1099e3616974dd" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.48", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjQ4", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.48", + "object": { + "sha": "7e3840a0683f83831502e15f583856fab8977685", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/7e3840a0683f83831502e15f583856fab8977685" + } + }, + { + "ref": "refs/tags/mobile-android-v0.0.50", + "node_id": "REF_kwDORpCz1toAIHJlZnMvdGFncy9tb2JpbGUtYW5kcm9pZC12MC4wLjUw", + "url": "https://api.github.com/repos/stablyai/orca/git/refs/tags/mobile-android-v0.0.50", + "object": { + "sha": "b5ee803d6c45dda9cfd4bb49d58aac47a22ebdc3", + "type": "commit", + "url": "https://api.github.com/repos/stablyai/orca/git/commits/b5ee803d6c45dda9cfd4bb49d58aac47a22ebdc3" + } + } +] diff --git a/mobile/src/app-update/fixtures/github-release-mobile-android-v0.0.50.json b/mobile/src/app-update/fixtures/github-release-mobile-android-v0.0.50.json new file mode 100644 index 00000000000..96976684578 --- /dev/null +++ b/mobile/src/app-update/fixtures/github-release-mobile-android-v0.0.50.json @@ -0,0 +1,92 @@ +{ + "url": "https://api.github.com/repos/stablyai/orca/releases/391254643", + "assets_url": "https://api.github.com/repos/stablyai/orca/releases/391254643/assets", + "upload_url": "https://uploads.github.com/repos/stablyai/orca/releases/391254643/assets{?name,label}", + "html_url": "https://github.com/stablyai/orca/releases/tag/mobile-android-v0.0.50", + "id": 391254643, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDORpCz1s4XUhJz", + "tag_name": "mobile-android-v0.0.50", + "target_commitish": "main", + "name": "Orca Mobile Android mobile-android-v0.0.50", + "draft": false, + "immutable": false, + "prerelease": true, + "created_at": "2026-09-18T02:37:26Z", + "updated_at": "2026-09-18T06:19:48Z", + "published_at": "2026-09-18T06:19:48Z", + "assets": [ + { + "url": "https://api.github.com/repos/stablyai/orca/releases/assets/571968911", + "id": 571968911, + "node_id": "RA_kwDORpCz1s4iF42P", + "name": "app-release.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 133555371, + "digest": "sha256:79b285ca678e0fa00859f5d9348bfdd9d291eff7e1261cd12012c21e2234bad1", + "download_count": 10693, + "created_at": "2026-09-18T06:19:42Z", + "updated_at": "2026-09-18T06:19:46Z", + "browser_download_url": "https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk" + } + ], + "tarball_url": "https://api.github.com/repos/stablyai/orca/tarball/mobile-android-v0.0.50", + "zipball_url": "https://api.github.com/repos/stablyai/orca/zipball/mobile-android-v0.0.50", + "body": "## What's Changed\n* Keep the floating workspace above a working native chat pane by @brennanb2025 in https://github.com/stablyai/orca/pull/18692\n* feat(relay-infra): dynamic NAT ports and alerts for the 2026-09-04 stall signals by @Jinwoo-H in https://github.com/stablyai/orca/pull/18693\n* fix(orca-profiles): tell the renderer when a cloud session is revoked by @Jinwoo-H in https://github.com/stablyai/orca/pull/18694\n* feat(relay): tell the phone when its desktop is signed out by @Jinwoo-H in https://github.com/stablyai/orca/pull/18698\n* perf(orchestration): bound the worker terminal archive in linear time by @nwparker in https://github.com/stablyai/orca/pull/18622\n* fix(startup): apply the HTTP/1.1 compatibility toggle for migrated profiles, and stop parsing the settings file pre-ready by @nwparker in https://github.com/stablyai/orca/pull/18621\n* perf(git): overlap the three independent submodule status reads by @nwparker in https://github.com/stablyai/orca/pull/18623\n* test(renderer): pin the runtime-target selector identity stability that #18685 fixed by @nwparker in https://github.com/stablyai/orca/pull/18625\n* perf(terminal): stop copying every PTY chunk for two startup-error detectors by @nwparker in https://github.com/stablyai/orca/pull/18626\n* perf(terminal): gate the Command Code banner scan before building its scan windows by @nwparker in https://github.com/stablyai/orca/pull/18628\n* perf(editor): build the closing-fence pattern once per fence, not once per line by @nwparker in https://github.com/stablyai/orca/pull/18629\n* perf(right-sidebar): cache the active checks status instead of rebuilding its cache keys per store write by @nwparker in https://github.com/stablyai/orca/pull/18631\n* perf(renderer): stop two store selectors allocating on every write by @nwparker in https://github.com/stablyai/orca/pull/18632\n* perf(source-control): stop reconciling selection on every panel render by @nwparker in https://github.com/stablyai/orca/pull/18637\n* perf(sidebar): stop building host projections the row model throws away by @nwparker in https://github.com/stablyai/orca/pull/18638\n* perf(agent-hooks): find spool newlines with Buffer.indexOf, not a per-byte loop by @nwparker in https://github.com/stablyai/orca/pull/18639\n* perf(claude-usage): reject non-assistant transcript lines before parsing them by @nwparker in https://github.com/stablyai/orca/pull/18640\n* perf(orchestration): project task columns so task reads hit the statement cache by @nwparker in https://github.com/stablyai/orca/pull/18641\n* perf(agent-hooks): stop cloning the whole status roster on every hook event by @nwparker in https://github.com/stablyai/orca/pull/18642\n* perf(sidebar): share one natural-worktree-id scan and drop a redundant row-key join by @nwparker in https://github.com/stablyai/orca/pull/18647\n* perf(terminal): use real event-loop yields between chunked writes by @nwparker in https://github.com/stablyai/orca/pull/18627\n* perf(settings): commit free-text account settings on a debounce, not per keystroke by @nwparker in https://github.com/stablyai/orca/pull/18651\n* feat(relay-infra): cell crash-rate alert and incident dashboard by @Jinwoo-H in https://github.com/stablyai/orca/pull/18717\n* feat(relay): let cells dial Cloud SQL over private IP by @Jinwoo-H in https://github.com/stablyai/orca/pull/18720\n* fix(desktop): never replay a refresh token after a timeout; jitter relay lease renewal by @Jinwoo-H in https://github.com/stablyai/orca/pull/18719\n* perf(ports): serve unchanged macOS listeners from remembered metadata by @nwparker in https://github.com/stablyai/orca/pull/18650\n* refactor(agent-session-journal): move the session journal onto SQLite by @brennanb2025 in https://github.com/stablyai/orca/pull/18652\n* fix(relay-ops): a thrown health fetch is not a health reading; auth probe does not require /ready by @Jinwoo-H in https://github.com/stablyai/orca/pull/18723\n* fix(agents): detect agent CLIs installed outside a version manager by @nwparker in https://github.com/stablyai/orca/pull/18336\n* perf(relay): per-cell inventory locks, delta counters, and a pool statement timeout by @Jinwoo-H in https://github.com/stablyai/orca/pull/18722\n* fix(worktrees): stop a failed worktree scan from being recorded as an authoritative empty listing by @nwparker in https://github.com/stablyai/orca/pull/18456\n* fix(native-chat): clarify active progress by @brennanb2025 in https://github.com/stablyai/orca/pull/18705\n* fix(browser-pane): stop a dead client-hosted guest from killing the workbench by @nwparker in https://github.com/stablyai/orca/pull/18334\n* feat(claude): move structured native chat onto the Claude Agent SDK and enable it on macOS and Linux by @brennanb2025 in https://github.com/stablyai/orca/pull/18560\n* Fix Smart create retaining a task checkout hash with Create more by @nwparker in https://github.com/stablyai/orca/pull/18727\n* infra(relay): drop the unapplied region label from runtime log metrics by @Jinwoo-H in https://github.com/stablyai/orca/pull/18734\n* feat(native-chat): expose split and move-to-pane actions in Chat UI mode by @brennanb2025 in https://github.com/stablyai/orca/pull/18714\n* fix(ui): unmount project selector before Add Project handoff by @Jinwoo-H in https://github.com/stablyai/orca/pull/18730\n* fix: show agent commands once without startup polling by @nwparker in https://github.com/stablyai/orca/pull/18729\n* fix(crash-reporting): make the own-Chromium gate a real choke point, and stop a refusal leaking the root by @nwparker in https://github.com/stablyai/orca/pull/18459\n* chore: update in-app Android APK link to 0.0.47 by @AmethystLiang in https://github.com/stablyai/orca/pull/18745\n* Answer the structured-session support probe without installing the host by @brennanb2025 in https://github.com/stablyai/orca/pull/18695\n* fix(native-chat): render Claude structured chat through the same UI as Codex by @brennanb2025 in https://github.com/stablyai/orca/pull/18743\n* fix(orchestration): fence the dispatch CLI preamble so it stops rendering as headings by @brennanb2025 in https://github.com/stablyai/orca/pull/18718\n* fix(relay-ops): retry a failed MIG inventory read once before calling a cell's power state unknown by @Jinwoo-H in https://github.com/stablyai/orca/pull/18740\n* fix(relay-ops): accept monitor evidence from an ancestor commit with identical monitor code by @Jinwoo-H in https://github.com/stablyai/orca/pull/18754\n* fix(terminal): stop a hidden pane's unmeasured 80x24 from overwriting a live PTY's size on reattach by @Jinwoo-H in https://github.com/stablyai/orca/pull/18706\n* fix: use fallback shell readiness and assert fish startup timing by @nwparker in https://github.com/stablyai/orca/pull/18755\n* fix(relay-ops): retry transient admin-endpoint failures in same-cap verify and rehome jobs by @Jinwoo-H in https://github.com/stablyai/orca/pull/18769\n* fix(native-chat): make document paths and links clickable in chat by @brennanb2025 in https://github.com/stablyai/orca/pull/18712\n* fix(native-chat): tell old mobile builds why a structured chat is missing by @brennanb2025 in https://github.com/stablyai/orca/pull/18756\n* fix(relay-ops): retry freshness-only preflight failures on the first same-cap wave too by @Jinwoo-H in https://github.com/stablyai/orca/pull/18778\n* fix(browser): match loading surfaces to the Orca theme by @Jinwoo-H in https://github.com/stablyai/orca/pull/18738\n* fix(browser): present Electron's own user agent so Cloudflare Turnstile clears by @Jinwoo-H in https://github.com/stablyai/orca/pull/18749\n* fix(relay-ops): align the cloud-data freshness bar with Cloud Monitoring publish lag by @Jinwoo-H in https://github.com/stablyai/orca/pull/18798\n* ci(package): retry apt fetches and docker builds behind the Ubuntu mirror by @nwparker in https://github.com/stablyai/orca/pull/18797\n* docs(relay): correct why the ConPTY teardown asset diverges from the desktop patch by @nwparker in https://github.com/stablyai/orca/pull/18636\n* fix: recover from an alternate shell install, and close the relay duplicate-echo gap by @nwparker in https://github.com/stablyai/orca/pull/18796\n* feat(mobile): port the restructured native-chat turn status and live tool progress by @brennanb2025 in https://github.com/stablyai/orca/pull/18761\n* perf(persistence): stop the session write re-scanning and rebuilding unchanged state by @nwparker in https://github.com/stablyai/orca/pull/18739\n* feat(native-chat): render agent file edits as inline diff cards by @brennanb2025 in https://github.com/stablyai/orca/pull/18765\n* fix(recovery): fail a renderer recovery reload that never loads, instead of leaving a dead window by @nwparker in https://github.com/stablyai/orca/pull/18466\n* fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org (STA-6674) by @nwparker in https://github.com/stablyai/orca/pull/18774\n* Bump mobile app.json to 0.0.48 by @brennanb2025 in https://github.com/stablyai/orca/pull/18801\n* fix(sidebar): confirm filter reset before revealing active workspace by @nwparker in https://github.com/stablyai/orca/pull/18708\n* perf(terminals): let idle panes share one process-table capture instead of forking their own by @nwparker in https://github.com/stablyai/orca/pull/18742\n* perf(native-chat): stop rebuilding every transcript row on every stream frame by @nwparker in https://github.com/stablyai/orca/pull/18744\n* perf(terminal): skip the partial-escape-tail walk on ESC-free PTY chunks by @nwparker in https://github.com/stablyai/orca/pull/18748\n* perf(renderer): index four projections that rescanned their inputs per keystroke by @nwparker in https://github.com/stablyai/orca/pull/18747\n* Show Claude background task status in Native Chat by @brennanb2025 in https://github.com/stablyai/orca/pull/18757\n* fix(sidebar): stop ⌘⇧↓ worktree navigation jumping to the first row by @nwparker in https://github.com/stablyai/orca/pull/18804\n* perf(persistence): skip rewriting unchanged terminal scrollback snapshots by @nwparker in https://github.com/stablyai/orca/pull/18764\n* fix(agent-session): refuse a pre-commit structured create with an envelope by @brennanb2025 in https://github.com/stablyai/orca/pull/18697\n* perf(relay): bound the symlink directory probes a remote readDir fans out by @nwparker in https://github.com/stablyai/orca/pull/18752\n* perf(terminal): cheap-tier process inspection for anchored local agent panes by @nwparker in https://github.com/stablyai/orca/pull/18780\n* fix(ssh): let the host say whether it armed the shell-ready marker by @nwparker in https://github.com/stablyai/orca/pull/18802\n* fix(native-chat): say when a structured launch fell back to a terminal by @brennanb2025 in https://github.com/stablyai/orca/pull/18762\n* fix(test): stop detached git maintenance racing the divergence fixture teardown by @nwparker in https://github.com/stablyai/orca/pull/18810\n* fix(cloud): let the same-cap roll isolate Asia cells by @Jinwoo-H in https://github.com/stablyai/orca/pull/18811\n* fix(renderer): stop a Node-only process-table module blanking the app at boot by @nwparker in https://github.com/stablyai/orca/pull/18814\n* feat(i18n): localize onboarding flow to Japanese by @tb-soshiro in https://github.com/stablyai/orca/pull/18787\n* fix(cloud): validate protocol-0 same-cap cell plans without rehome trust lines by @Jinwoo-H in https://github.com/stablyai/orca/pull/18818\n* perf(worktree): skip remote probes with no possible result by @nwparker in https://github.com/stablyai/orca/pull/18821\n* fix(native-chat): stop a settling handoff throwing an unhandled rejection at teardown by @nwparker in https://github.com/stablyai/orca/pull/18824\n* fix(claude): make the recovery barrier cover an exit still climbing the close ladder by @nwparker in https://github.com/stablyai/orca/pull/18826\n* Reduce minimum tab width from 88px to 72px by @AmethystLiang in https://github.com/stablyai/orca/pull/18871\n* perf(automations): reuse collation setup when sorting names by @nwparker in https://github.com/stablyai/orca/pull/18823\n* fix(relay-ops): per-region cell latency bar and attributable preflight failures by @Jinwoo-H in https://github.com/stablyai/orca/pull/18877\n* test(e2e): follow current sidebar project and activity actions by @nwparker in https://github.com/stablyai/orca/pull/18878\n* fix(runtime): bound terminal-wait blocked-prompt rules to the live screen bottom by @Jinwoo-H in https://github.com/stablyai/orca/pull/18817\n* test(e2e): fence SSH recovery and release exited Electron pipes by @nwparker in https://github.com/stablyai/orca/pull/18880\n* feat(perf): lint repeated sort setup and schedule regression contracts by @nwparker in https://github.com/stablyai/orca/pull/18822\n* feat(native-chat): label Codex tool rows by what the command actually did by @brennanb2025 in https://github.com/stablyai/orca/pull/18760\n* test: preserve Docker context in isolated VM recipes by @nwparker in https://github.com/stablyai/orca/pull/18884\n* Fix favicon retention across same-origin navigations by @AmethystLiang in https://github.com/stablyai/orca/pull/18879\n* test: isolate native crash restoration and refresh stale fixtures by @nwparker in https://github.com/stablyai/orca/pull/18883\n* test: pin worker visibility fixture command and handle by @nwparker in https://github.com/stablyai/orca/pull/18897\n* feat(cli): report SSH host platforms by @nwparker in https://github.com/stablyai/orca/pull/18896\n* test: canonicalize setup fixture paths before worktree lookup by @nwparker in https://github.com/stablyai/orca/pull/18912\n* perf(worktree): remove redundant creation and terminal startup work by @nwparker in https://github.com/stablyai/orca/pull/18793\n* test: align desktop platform oracles with native behavior by @nwparker in https://github.com/stablyai/orca/pull/18915\n* test: follow sidebar reveal confirmation with a stable workspace target by @nwparker in https://github.com/stablyai/orca/pull/18921\n* feat(native-chat): model Codex MCP and web-search items instead of leaking opcodes by @brennanb2025 in https://github.com/stablyai/orca/pull/18763\n* fix(native-chat): publish structured session status from the host so the sidebar never goes stale by @brennanb2025 in https://github.com/stablyai/orca/pull/18776\n* feat(native-chat): stop monitored tasks individually by @brennanb2025 in https://github.com/stablyai/orca/pull/18807\n* fix(native-chat): tell a pre-SQLite chat how to carry on by @brennanb2025 in https://github.com/stablyai/orca/pull/18808\n* fix(agent-session): never open a sibling terminal on an unproven create by @brennanb2025 in https://github.com/stablyai/orca/pull/18735\n* refactor(agent-session): consolidate wire type imports below lint limit by @nwparker in https://github.com/stablyai/orca/pull/18930\n* test(e2e): stabilize terminal launch and rename menu fixtures by @nwparker in https://github.com/stablyai/orca/pull/18928\n* fix(automation): keep explicit background launches off screen by @nwparker in https://github.com/stablyai/orca/pull/18898\n* feat(automations): restore column sorting on the list by @nwparker in https://github.com/stablyai/orca/pull/18885\n* test: pin terminal Codex home to an explicit managed account by @nwparker in https://github.com/stablyai/orca/pull/18935\n* test: deliver real terminal input and preserve setup reports by @nwparker in https://github.com/stablyai/orca/pull/18939\n* test: isolate skill cloud fixture ports across workers by @nwparker in https://github.com/stablyai/orca/pull/18942\n* test: align Source Control AI generation fixtures by @nwparker in https://github.com/stablyai/orca/pull/18941\n* ci: reduce runner overhead and disposable package compression by @nwparker in https://github.com/stablyai/orca/pull/18948\n* test: keep artifact share fixtures unexpired across calendar dates by @nwparker in https://github.com/stablyai/orca/pull/18955\n* docs(relay): 2026-09 reconnect findings, checklist, roadmap, and Roll 2 plan by @Jinwoo-H in https://github.com/stablyai/orca/pull/18958\n* test: make SSH artifact regression fixtures reliable at narrow widths by @nwparker in https://github.com/stablyai/orca/pull/18947\n* test: order restart fixture readiness around daemon recovery by @nwparker in https://github.com/stablyai/orca/pull/18949\n* fix(orchestration): typed error codes for dispatch and worker-start refusals by @Jinwoo-H in https://github.com/stablyai/orca/pull/18902\n* ci: skip idle Mac allocations and redundant native compiler setup by @nwparker in https://github.com/stablyai/orca/pull/18954\n* test: isolate Source Control generation from shared repository remotes by @nwparker in https://github.com/stablyai/orca/pull/18962\n* fix(relay): abandon dead client accepts, jitter and lengthen the control lease, fail direct probes fast by @Jinwoo-H in https://github.com/stablyai/orca/pull/18959\n* fix(hooks): register the Claude hook script directly on Windows (#18875) by @OrcaWin in https://github.com/stablyai/orca/pull/18905\n* fix(ci): store vetted refs in a reftable so case-twin branches don't fail the fetch by @nwparker in https://github.com/stablyai/orca/pull/18970\n* test: drain project menu focus restoration before teardown by @nwparker in https://github.com/stablyai/orca/pull/18971\n* ci: reduce dependency, checkout, and test deadline overhead by @nwparker in https://github.com/stablyai/orca/pull/18968\n* ci: preserve case-twin release refs through checkout and verify trust by @nwparker in https://github.com/stablyai/orca/pull/18980\n* test: honor background mode in paired client window helpers by @nwparker in https://github.com/stablyai/orca/pull/18978\n* test: bound release checkout fixtures and gate delayed imports by @nwparker in https://github.com/stablyai/orca/pull/18981\n* fix(native-chat): repair a structured chat tab permanently fenced by an inherited publication epoch by @brennanb2025 in https://github.com/stablyai/orca/pull/18906\n* ci: expose existing E2E spec selection for manual dispatch by @nwparker in https://github.com/stablyai/orca/pull/18987\n* test: isolate window mocks from inherited launch flags by @nwparker in https://github.com/stablyai/orca/pull/18989\n* test: match explorer filenames independently of git badges by @nwparker in https://github.com/stablyai/orca/pull/18997\n* test: synchronize large repository recovery with Retry request by @nwparker in https://github.com/stablyai/orca/pull/18999\n* perf(relay): drain fragmented frame buffers in linear time by @nwparker in https://github.com/stablyai/orca/pull/18891\n* perf(repos): avoid quadratic icon source scans by @nwparker in https://github.com/stablyai/orca/pull/18892\n* perf(browser): assemble fragmented tunnel frames once by @nwparker in https://github.com/stablyai/orca/pull/18893\n* perf(editor): reuse Markdown source blocks while positioning review notes by @nwparker in https://github.com/stablyai/orca/pull/18895\n* perf(editor): reuse live Markdown search matches across unrelated renders by @nwparker in https://github.com/stablyai/orca/pull/18903\n* perf: decode fragmented CLI replies without repeated scans by @nwparker in https://github.com/stablyai/orca/pull/18909\n* perf: avoid file-to-file scans when selecting deletion roots by @nwparker in https://github.com/stablyai/orca/pull/18913\n* perf: find mobile Markdown placeholder prefixes in one scan by @nwparker in https://github.com/stablyai/orca/pull/18914\n* perf: avoid repeated Quick Open exclusion string allocations by @nwparker in https://github.com/stablyai/orca/pull/18916\n* perf: skip unrelated shared symlink probes during Git status by @nwparker in https://github.com/stablyai/orca/pull/18918\n* perf: avoid splitting every path during file autocomplete by @nwparker in https://github.com/stablyai/orca/pull/18919\n* perf: avoid rescanning emitted source in analysis guards by @nwparker in https://github.com/stablyai/orca/pull/18920\n* perf(cli): skip feature formatters during help and error startup by @nwparker in https://github.com/stablyai/orca/pull/18923\n* perf(explorer): remove redundant dotfile path filter allocation by @nwparker in https://github.com/stablyai/orca/pull/18929\n* perf(watcher): skip remaining filesystem checks after cancellation by @nwparker in https://github.com/stablyai/orca/pull/18931\n* perf(hooks): use native reverse search for transcript lines by @nwparker in https://github.com/stablyai/orca/pull/18936\n* perf(skills): skip symlink probes beyond discovery depth by @nwparker in https://github.com/stablyai/orca/pull/18937\n* perf(mobile): cancel direct probes when their owner stops by @nwparker in https://github.com/stablyai/orca/pull/18940\n* perf(jira): preserve replacement attachment download singleflight by @nwparker in https://github.com/stablyai/orca/pull/18944\n* perf(speech): reuse the model download idle timer by @nwparker in https://github.com/stablyai/orca/pull/18945\n* perf(ssh): reuse and release relay startup buffers by @nwparker in https://github.com/stablyai/orca/pull/18953\n* perf(ssh): reuse streamed response idle timers by @nwparker in https://github.com/stablyai/orca/pull/18956\n* perf(browser): reuse decoded single-chunk upload buffers by @nwparker in https://github.com/stablyai/orca/pull/18960\n* perf(tabs): index saved tab order during hydration repair by @nwparker in https://github.com/stablyai/orca/pull/18964\n* perf(palette): reuse allowed quality arrays during matching by @nwparker in https://github.com/stablyai/orca/pull/18966\n* perf: remember equivalent session tab source identities by @nwparker in https://github.com/stablyai/orca/pull/18976\n* perf(cli): skip impossible typo distance comparisons by @nwparker in https://github.com/stablyai/orca/pull/18977\n* fix(tests): stabilize divider viewport and pointer-capture event ordering by @nwparker in https://github.com/stablyai/orca/pull/19004\n* fix(mobile): stop host streams after relay subscription cancellation by @nwparker in https://github.com/stablyai/orca/pull/18926\n* fix(tests): provide a window manager for Linux Electron CI by @nwparker in https://github.com/stablyai/orca/pull/19007\n* fix(windows): copy the daemon host exe verbatim instead of renaming it (MDE T1036) by @OrcaWin in https://github.com/stablyai/orca/pull/17865\n* fix(computer-use): run the Windows runtime as one persistent helper by @OrcaWin in https://github.com/stablyai/orca/pull/17858\n* fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell by @OrcaWin in https://github.com/stablyai/orca/pull/17880\n* fix(windows): read command lines from the kernel, not each process's PEB by @OrcaWin in https://github.com/stablyai/orca/pull/17886\n* fix(windows): scan ports natively instead of encoded PowerShell by @OrcaWin in https://github.com/stablyai/orca/pull/17861\n* fix(security): apply the Windows path-hardening ACL that never ran by @OrcaWin in https://github.com/stablyai/orca/pull/17884\n* perf: avoid repeated whitespace scans in diagnostic redaction by @nwparker in https://github.com/stablyai/orca/pull/18908\n* Improve notification view by @AmethystLiang in https://github.com/stablyai/orca/pull/18699\n* test(child-process): lower DIRECT_IMPORTER_PIN to the ground two PRs took by @OrcaWin in https://github.com/stablyai/orca/pull/19009\n* fix(windows): resolve npm/pnpm .cmd shims past cmd.exe by @OrcaWin in https://github.com/stablyai/orca/pull/17869\n* test: lower child-process import pin after ACL migration by @nwparker in https://github.com/stablyai/orca/pull/19012\n* Open open new link should not navigate away from current link by @AmethystLiang in https://github.com/stablyai/orca/pull/18873\n* perf(windows): split the process table into two flag sets by @OrcaWin in https://github.com/stablyai/orca/pull/17866\n* fix(release): stop shipping an unsigned elevate.exe on Windows by @OrcaWin in https://github.com/stablyai/orca/pull/18044\n* fix(windows): sign the NSIS uninstaller via SignPath by @OrcaWin in https://github.com/stablyai/orca/pull/17868\n* fix(tests): complete hidden SSH dialog exits during cleanup by @nwparker in https://github.com/stablyai/orca/pull/18993\n* Support updating existing draft releases when regenerating notes by @AmethystLiang in https://github.com/stablyai/orca/pull/19014\n* fix(windows): drop no-op -ExecutionPolicy Bypass from -Command spawns by @OrcaWin in https://github.com/stablyai/orca/pull/17873\n* perf(search): assemble fragmented subprocess lines incrementally by @nwparker in https://github.com/stablyai/orca/pull/18973\n* test: use a valid repo filter in workspace reveal coverage by @nwparker in https://github.com/stablyai/orca/pull/19017\n* test: cover native X11 Hangul-plus-digit PTY bytes in CI by @nwparker in https://github.com/stablyai/orca/pull/19013\n* Stop evicted and expired worktree preparations by @nwparker in https://github.com/stablyai/orca/pull/18951\n* Let worktree creation proceed during stale preparation reclamation by @nwparker in https://github.com/stablyai/orca/pull/18967\n* fix(release): revalidate draft state before patching generated notes by @AmethystLiang in https://github.com/stablyai/orca/pull/19019\n* perf(worktree): overlap base refresh with prepared checkout by @nwparker in https://github.com/stablyai/orca/pull/18998\n* test: await fresh inventory after headless terminal materialization by @nwparker in https://github.com/stablyai/orca/pull/19028\n* fix(sidebar): stop a missed pointerup from hiding a remote host section by @nwparker in https://github.com/stablyai/orca/pull/19032\n* test: wait for board pointer readiness before marquee selection by @nwparker in https://github.com/stablyai/orca/pull/19029\n* fix(build): pin config/relay-assets LF so one release is one relay hash by @OrcaWin in https://github.com/stablyai/orca/pull/19024\n* docs(relay): record Roll 2 phase 0/1 (code merge, image, director deploy) by @Jinwoo-H in https://github.com/stablyai/orca/pull/18979\n* feat(github-projects): render Roadmap project views as a timeline by @NaoyaTatetsu in https://github.com/stablyai/orca/pull/17795\n* fix(mobile): preserve iPad hardware keyboard focus by @ramarivera in https://github.com/stablyai/orca/pull/12772\n* test: await reattach replay before checking mouse reset by @nwparker in https://github.com/stablyai/orca/pull/19039\n* test: require recorded Git activity in polling regression by @nwparker in https://github.com/stablyai/orca/pull/19041\n* fix(native-chat): resume a structured chat from Agent Session History by @OrcaWin in https://github.com/stablyai/orca/pull/18933\n* fix(source-control): stack Retry below the too-many-changes message by @nwparker in https://github.com/stablyai/orca/pull/19037\n* test: preserve Windows golden command failures by @nwparker in https://github.com/stablyai/orca/pull/19047\n* test: canonicalize Windows fresh-profile fixture path by @nwparker in https://github.com/stablyai/orca/pull/19049\n* test: repair Windows paste setup and newline expectations by @nwparker in https://github.com/stablyai/orca/pull/19050\n* fix(source-control): stack the Create PR notice's settings link below its message by @nwparker in https://github.com/stablyai/orca/pull/19046\n* test: align Windows shell icons with project runtime ownership by @nwparker in https://github.com/stablyai/orca/pull/19053\n* test: exercise supported ConPTY keyboard protocol reset by @nwparker in https://github.com/stablyai/orca/pull/19054\n* fix(native-chat): honor structured routing with saved options by @brennanb2025 in https://github.com/stablyai/orca/pull/19040\n* fix(packaging): ship Claude agent SDK with desktop builds by @brennanb2025 in https://github.com/stablyai/orca/pull/19042\n* test: reject unsupported app-server in golden agent fixture by @nwparker in https://github.com/stablyai/orca/pull/19056\n* test: reject unsupported app-server in WSL golden stub by @nwparker in https://github.com/stablyai/orca/pull/19062\n* test: canonicalize native Windows paths during repository teardown by @nwparker in https://github.com/stablyai/orca/pull/19064\n* test: run real WSL terminal launch and paste in PR CI by @nwparker in https://github.com/stablyai/orca/pull/19072\n* test: restore SSH bulk-open freeze coverage in headed CI by @nwparker in https://github.com/stablyai/orca/pull/19081\n* test: enable direct and client-hosted SSH browser coverage by @nwparker in https://github.com/stablyai/orca/pull/19090\n* test: reconnect after replacing same-ID runtime pairing by @nwparker in https://github.com/stablyai/orca/pull/19094\n* test: enable Docker SSH browser network route coverage in CI by @nwparker in https://github.com/stablyai/orca/pull/19095\n* docs(relay): Roll 2 cell roll record and checklist ticks by @Jinwoo-H in https://github.com/stablyai/orca/pull/19096\n* test: repair nested SSH fixture after HUB restart by @nwparker in https://github.com/stablyai/orca/pull/19098\n* test: enable localhost SSH terminal and hook journey in CI by @nwparker in https://github.com/stablyai/orca/pull/19097\n* Bump mobile Android versionCode to 16 to match the 0.0.48 release by @brennanb2025 in https://github.com/stablyai/orca/pull/19101\n* test: reuse authoritative SSH connection readiness in localhost fixture by @nwparker in https://github.com/stablyai/orca/pull/19102\n* test: follow retained Activity sidebar during pane selection by @nwparker in https://github.com/stablyai/orca/pull/19107\n* test: isolate source-control generation repositories per scenario by @nwparker in https://github.com/stablyai/orca/pull/19105\n* test: cover SSH reattach replay and enable deterministic Codex CI by @nwparker in https://github.com/stablyai/orca/pull/19106\n* fix(chat): stop terminal focus recovery from stealing Cmd+C in the Chat UI by @brennanb2025 in https://github.com/stablyai/orca/pull/18751\n* Add persistent turn-scoped chat activity indicator by @brennanb2025 in https://github.com/stablyai/orca/pull/19044\n* feat(orchestration): make multi-agent workflows durable by @Jinwoo-H in https://github.com/stablyai/orca/pull/16904\n* fix(ssh-relay): daemon owns the endpoint credential; a losing start never rotates it by @Jinwoo-H in https://github.com/stablyai/orca/pull/19052\n* Recover branch compare on visibility change by @AmethystLiang in https://github.com/stablyai/orca/pull/19021\n* fix(codex): keep large app-server replies alive by @brennanb2025 in https://github.com/stablyai/orca/pull/18590\n* docs(orchestration): never pick a worker model the user did not name by @Jinwoo-H in https://github.com/stablyai/orca/pull/19109\n* feat(mobile): structured native Claude chat by @brennanb2025 in https://github.com/stablyai/orca/pull/18741\n* Seed Cmd-J filter from sidebar scope by @AmethystLiang in https://github.com/stablyai/orca/pull/19036\n* skills: rewrite and trim the seven non-orchestration guides (do not merge without Jinwoo) by @Jinwoo-H in https://github.com/stablyai/orca/pull/18724\n* fix(ci): stop Android release notes exceeding the GitHub body limit by @brennanb2025 in https://github.com/stablyai/orca/pull/19114\n* Update mobile 0.0.48 Android download links by @brennanb2025 in https://github.com/stablyai/orca/pull/19117\n* fix(orchestration): keep worker lineage across app restart (STA-6366) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19121\n* fix(browser): select full URL on initial address bar click by @Jinwoo-H in https://github.com/stablyai/orca/pull/19118\n* fix(windows): unblock structured native chat by exposing process creation time by @brennanb2025 in https://github.com/stablyai/orca/pull/18986\n* Revert \"skills: rewrite the seven non-orchestration guides to one outcome-first standard (#18724)\" by @Jinwoo-H in https://github.com/stablyai/orca/pull/19126\n* perf(store): keep the repo list's identity through workspace hydration by @nwparker in https://github.com/stablyai/orca/pull/19057\n* perf(tabs): keep tab-model identity when reconciliation changed something else by @nwparker in https://github.com/stablyai/orca/pull/19063\n* perf(mobile): skip the agent-status projection join when nothing changed by @nwparker in https://github.com/stablyai/orca/pull/19115\n* perf(worktrees): stop worktree removal from replacing maps it never touched by @nwparker in https://github.com/stablyai/orca/pull/19058\n* perf(selectors): stop two always-mounted selectors allocating per store write by @nwparker in https://github.com/stablyai/orca/pull/19113\n* perf(terminals): stop closing a tab from replacing maps it never touched by @nwparker in https://github.com/stablyai/orca/pull/19060\n* perf(terminals): keep shutdown maps' identity when there is nothing to clear by @nwparker in https://github.com/stablyai/orca/pull/19112\n* perf(store): detect Zustand rerender churn the current audit cannot see by @nwparker in https://github.com/stablyai/orca/pull/19059\n* fix(native-chat): stop seeding a stray terminal beside a chat create by @brennanb2025 in https://github.com/stablyai/orca/pull/19123\n* Improve cmd j ranking by @AmethystLiang in https://github.com/stablyai/orca/pull/19005\n* feat(native-chat): offer the link-action popover for chat links by @brennanb2025 in https://github.com/stablyai/orca/pull/19130\n* fix(ui): keep source control headers readable in narrow sidebars by @nwparker in https://github.com/stablyai/orca/pull/19146\n* fix(gh): log when gh/glab is killed at its deadline by @nwparker in https://github.com/stablyai/orca/pull/18555\n* fix: restore the full sidebar agent row for structured native chat by @brennanb2025 in https://github.com/stablyai/orca/pull/19137\n* fix(native-chat): suppress provider user echoes in Claude and Codex by @brennanb2025 in https://github.com/stablyai/orca/pull/19136\n* fix(native-chat): settle a structured send the provider proves it received after the ack window by @brennanb2025 in https://github.com/stablyai/orca/pull/19140\n* Show provider activity in chat turn tails by @brennanb2025 in https://github.com/stablyai/orca/pull/19055\n* fix(ui): ignore the persistent workspace list when detecting overlays by @nwparker in https://github.com/stablyai/orca/pull/18881\n* fix(windows): reject stale parent PID links in shutdown snapshots by @brennanb2025 in https://github.com/stablyai/orca/pull/19149\n* fix(native-chat): auto-rename the workspace on a structured chat's first turn by @brennanb2025 in https://github.com/stablyai/orca/pull/19138\n* Fix native chat completion sorting and restored activity timestamps by @brennanb2025 in https://github.com/stablyai/orca/pull/19144\n* test: exercise packaged browser compatibility in scheduled CI by @nwparker in https://github.com/stablyai/orca/pull/19157\n* test: refresh palette identities and structured-session journal fixtures by @nwparker in https://github.com/stablyai/orca/pull/19165\n* fix(terminal): preserve ordinary foreground command names by @nwparker in https://github.com/stablyai/orca/pull/18882\n* fix(cmd-j): pass browser tab ownership into palette search by @nwparker in https://github.com/stablyai/orca/pull/18925\n* fix: keep macOS shell ownership proof within recovery budget by @nwparker in https://github.com/stablyai/orca/pull/18932\n* fix: open editor rename after context menu releases focus by @nwparker in https://github.com/stablyai/orca/pull/18934\n* fix: avoid starting browser helpers just to reset absent sessions by @nwparker in https://github.com/stablyai/orca/pull/18952\n* fix: prefer retained provider snapshots during hidden terminal recovery by @nwparker in https://github.com/stablyai/orca/pull/18972\n* fix: preserve overlay focus during terminal mounting and layout by @nwparker in https://github.com/stablyai/orca/pull/18982\n* fix: preserve terminal command probes across control frames by @nwparker in https://github.com/stablyai/orca/pull/19006\n* fix: preserve paired host sessions during startup residue cleanup by @nwparker in https://github.com/stablyai/orca/pull/18922\n* fix: preserve renderer browser publication during client-hosted page updates by @nwparker in https://github.com/stablyai/orca/pull/18961\n* fix: honor remote terminal insertion in the calling client by @nwparker in https://github.com/stablyai/orca/pull/18995\n* fix: keep paired tab updates live after runtime terminal fallback by @nwparker in https://github.com/stablyai/orca/pull/19022\n* fix: avoid credit deadlock during large SSH PTY recovery by @nwparker in https://github.com/stablyai/orca/pull/19026\n* fix: recognize working WSL1 without a WSL2 kernel by @nwparker in https://github.com/stablyai/orca/pull/19061\n* fix(ports): coalesce advertised URL refresh bursts by @nwparker in https://github.com/stablyai/orca/pull/19150\n* perf(agent-status): stop no-op retirements rebuilding the retired-pane-key map by @nwparker in https://github.com/stablyai/orca/pull/19142\n* perf(worktrees): stop worktree teardown replacing arrays and maps it never touched by @nwparker in https://github.com/stablyai/orca/pull/19145\n* fix(cmd-j): remove duplicate browser ownership inputs by @nwparker in https://github.com/stablyai/orca/pull/19172\n* fix: recognize the kimi-code process as the kimi agent by @Aladex in https://github.com/stablyai/orca/pull/18634\n* fix: release floating terminal WebGL contexts while closed by @nwparker in https://github.com/stablyai/orca/pull/19000\n* fix: fence browser recovery to attach inventory placements by @nwparker in https://github.com/stablyai/orca/pull/18910\n* test: refresh paired palette host-qualified row locators by @nwparker in https://github.com/stablyai/orca/pull/19175\n* test: cover native Wayland Hangul in isolated CI by @nwparker in https://github.com/stablyai/orca/pull/19174\n* test(e2e): scope paired preview link checks to confirmation by @nwparker in https://github.com/stablyai/orca/pull/18924\n* test: await rendered remote agent placement before checking mirrors by @nwparker in https://github.com/stablyai/orca/pull/18983\n* test: scope selected runtime project assertions to the sidebar by @nwparker in https://github.com/stablyai/orca/pull/19003\n* test: confirm running-command prompts when closing tabs by @nwparker in https://github.com/stablyai/orca/pull/18965\n* Fix MiniMax China usage routing and credential handling by @weekbin in https://github.com/stablyai/orca/pull/14929\n* fix(rate-limits): stop reporting Grok usage as 0% when the API omits the percent by @TimothyVang in https://github.com/stablyai/orca/pull/17936\n* test: enable software WebGL for Linux CI headful specs by @nwparker in https://github.com/stablyai/orca/pull/19001\n* test: restore snapshot path before draining hydration FIFO by @nwparker in https://github.com/stablyai/orca/pull/19186\n* test: return paired browser state from the successful poll by @nwparker in https://github.com/stablyai/orca/pull/19189\n* feat: real background push notifications for the mobile app (#8129) by @Jinwoo-H in https://github.com/stablyai/orca/pull/18554\n* test: align worker recovery fixture with ownership policy by @nwparker in https://github.com/stablyai/orca/pull/19190\n* feat(orchestration): orchestrate native-born structured chat sessions by @brennanb2025 in https://github.com/stablyai/orca/pull/18827\n* fix(native-chat): remember structured chat model and effort picks by @brennanb2025 in https://github.com/stablyai/orca/pull/19147\n* fix(native-chat): list the slash commands and skills a structured Claude session actually loaded by @brennanb2025 in https://github.com/stablyai/orca/pull/19127\n* skills: rewrite and trim the seven non-orchestration guides by @Jinwoo-H in https://github.com/stablyai/orca/pull/19128\n* feat(chat): support structured /clear and /compact commands by @brennanb2025 in https://github.com/stablyai/orca/pull/19164\n* revert: hold mobile push feature for user testing by @Jinwoo-H in https://github.com/stablyai/orca/pull/19203\n* fix(editor): support Shift+wheel scrolling in combined diffs by @brohoya in https://github.com/stablyai/orca/pull/11756\n* feat(native-chat): resume an Agent Session History row into a new structured chat by @brennanb2025 in https://github.com/stablyai/orca/pull/19176\n* fix(mobile): stop double-scaling commit timestamps in history rows by @blade035 in https://github.com/stablyai/orca/pull/17731\n* fix(runtime): apply the structured-chat setting to every RPC caller by @brennanb2025 in https://github.com/stablyai/orca/pull/18700\n* fix(native-chat): settle structured chat turns stranded by a restart by @brennanb2025 in https://github.com/stablyai/orca/pull/19122\n* fix: eliminate quadratic whitespace trimming during fullscreen scrolling by @nwparker in https://github.com/stablyai/orca/pull/19214\n* Restore independent push gateway deployment by @Jinwoo-H in https://github.com/stablyai/orca/pull/19225\n* Fix push deployment source archive path by @Jinwoo-H in https://github.com/stablyai/orca/pull/19231\n* Reorganize MiniMax modules and de-duplicate shared test state by @nwparker in https://github.com/stablyai/orca/pull/19197\n* fix: repaint hidden output overflow after answered restore deadline by @nwparker in https://github.com/stablyai/orca/pull/18904\n* fix: retain MSYS shell descendants in their terminal job by @nwparker in https://github.com/stablyai/orca/pull/19068\n* fix(test): unbreak main — admit the adoption-replay create fixture through the structured-chat gate by @nwparker in https://github.com/stablyai/orca/pull/19246\n* Fix MiniMax credential-expiry reporting, region sync, and refresh by @nwparker in https://github.com/stablyai/orca/pull/19250\n* fix(orchestration): recover Codex idle after completion title race by @Jinwoo-H in https://github.com/stablyai/orca/pull/19243\n* feat(relay): time successful client accepts and control round trips by @Jinwoo-H in https://github.com/stablyai/orca/pull/19232\n* fix(relay): rehome hosts to their preferred region in either direction by @Jinwoo-H in https://github.com/stablyai/orca/pull/19241\n* perf(mobile): cut the relay reconnect critical path and admit dead sockets faster by @Jinwoo-H in https://github.com/stablyai/orca/pull/19236\n* feat(mobile): draw the last known tab strip while a session reconnects by @Jinwoo-H in https://github.com/stablyai/orca/pull/19258\n* feat(relay): alert on far-cell placement and skewed region hints by @Jinwoo-H in https://github.com/stablyai/orca/pull/19253\n* fix(test): give federation tests a real read-after-write sync barrier by @Jinwoo-H in https://github.com/stablyai/orca/pull/19262\n* revert(mobile): hold the relay reconnect path and cache-first reconnect for a separate mobile pass by @Jinwoo-H in https://github.com/stablyai/orca/pull/19265\n* relay: give the asia-east2 cells the regional rehome identity by @Jinwoo-H in https://github.com/stablyai/orca/pull/19239\n* fix(relay): emit pending-conn details to hosts that advertise the capability (cell side) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19266\n* fix(relay): bound control RTT samples per ping and per flush window by @Jinwoo-H in https://github.com/stablyai/orca/pull/19268\n* fix(relay-ops): let the rehome trust probe approve the asia-east2 cells by @Jinwoo-H in https://github.com/stablyai/orca/pull/19275\n* fix(codex): distinguish personal and enterprise accounts sharing an email by @nwparker in https://github.com/stablyai/orca/pull/19279\n* test(relay): prove the pending-conn capability header reaches acceptControl by @Jinwoo-H in https://github.com/stablyai/orca/pull/19274\n* feat(windows): enable structured Codex chat on native Windows by @brennanb2025 in https://github.com/stablyai/orca/pull/18519\n* test(ssh): isolate the MFA fixture from the developer's real ~/.ssh by @brennanb2025 in https://github.com/stablyai/orca/pull/19300\n* Add localization for activity view and sidebar by @OrcaWin in https://github.com/stablyai/orca/pull/18589\n* fix(native-chat): make structured chat tabs renameable by @brennanb2025 in https://github.com/stablyai/orca/pull/19153\n* feat(native-chat): show Codex subagent activity instead of opcode rows by @brennanb2025 in https://github.com/stablyai/orca/pull/18773\n* fix(agent-status): stop a stale self-authored agent title from faking a pending question by @brennanb2025 in https://github.com/stablyai/orca/pull/19237\n* fix(terminal): remount a pane left unbound by a spawn that returned no PTY id by @brennanb2025 in https://github.com/stablyai/orca/pull/19223\n* fix(relay): stop rejecting the near region on a cold first probe by @Jinwoo-H in https://github.com/stablyai/orca/pull/19233\n* perf(mobile): cut the relay reconnect critical path and admit dead sockets faster (mobile pass) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19280\n* perf(mobile): open a session with parallel startup RPCs and a pre-warmed terminal engine by @Jinwoo-H in https://github.com/stablyai/orca/pull/19260\n* fix(relay): attach a phone whose accept straddles a control rebind (desktop side) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19238\n* tools: add a phone-vantage relay connect benchmark by @Jinwoo-H in https://github.com/stablyai/orca/pull/19251\n* feat(mobile): draw the last known tab strip while a session reconnects (mobile pass) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19281\n* feat(relay): log the region probe and name the assigned cell by @Jinwoo-H in https://github.com/stablyai/orca/pull/19307\n* perf(mobile): race the direct and relay dials from t=0 on every reconnect by @Jinwoo-H in https://github.com/stablyai/orca/pull/19308\n* feat(mobile): time relay dial stages so diagnostics say where a slow connect went by @Jinwoo-H in https://github.com/stablyai/orca/pull/19245\n* Persist agents sidebar search visibility as pairing-local preference by @AmethystLiang in https://github.com/stablyai/orca/pull/19313\n* feat(chat): add structured session rewind backend by @brennanb2025 in https://github.com/stablyai/orca/pull/19235\n* Defer inactive browser pages across worktree switches by @AmethystLiang in https://github.com/stablyai/orca/pull/19326\n* fix(native-chat): stop an unanswered host from reading as one that refuses structured chat by @brennanb2025 in https://github.com/stablyai/orca/pull/19321\n* fix(native-chat): scope composer file drops to the pane that received them by @brennanb2025 in https://github.com/stablyai/orca/pull/19328\n* revert(mobile): pull the relay connect-speed mobile pass pending a smaller, verified re-land by @Jinwoo-H in https://github.com/stablyai/orca/pull/19348\n* fix(relay): never cache a region hint from a one-region catalog by @Jinwoo-H in https://github.com/stablyai/orca/pull/19349\n* Show native chat message timestamps on hover by @brennanb2025 in https://github.com/stablyai/orca/pull/19218\n* feat(native-chat): add execution details and tool row identity by @brennanb2025 in https://github.com/stablyai/orca/pull/19226\n* chore(mobile): remove stale max-lines exceptions by @nwparker in https://github.com/stablyai/orca/pull/19366\n* fix(native-chat): render compaction notices, plan documents, and images by @brennanb2025 in https://github.com/stablyai/orca/pull/19228\n* fix(sidebar): reveal collapsed workspaces without clearing filters by @nwparker in https://github.com/stablyai/orca/pull/19398\n* fix(orchestration): avoid full message scans on working terminal titles by @brennanb2025 in https://github.com/stablyai/orca/pull/19390\n* fix(deps): update desktop parsers for security and bounded resource use by @OrcaWin in https://github.com/stablyai/orca/pull/19361\n* fix(deps): harden cloud HTTP, WebSocket and build dependencies by @OrcaWin in https://github.com/stablyai/orca/pull/19362\n* chore(mobile): patch XML parser security in plist tooling by @OrcaWin in https://github.com/stablyai/orca/pull/19380\n* chore(docs): patch brace expansion resource exhaustion fixes by @OrcaWin in https://github.com/stablyai/orca/pull/19382\n* fix(deps): update react-i18next for TypeScript 7 and translation parsing by @OrcaWin in https://github.com/stablyai/orca/pull/19378\n* fix(deps): update DOMPurify for sanitizer and document-context fixes by @OrcaWin in https://github.com/stablyai/orca/pull/19377\n* fix(ci): read the changed-path list past the first pipe buffer by @nwparker in https://github.com/stablyai/orca/pull/19409\n* fix(xterm): remove scrollback decorations by identity by @bbingz in https://github.com/stablyai/orca/pull/13178\n* feat(native-chat): read a tool batch as a group by @brennanb2025 in https://github.com/stablyai/orca/pull/19372\n* fix(deps): upgrade Electron to 43.6 for startup performance and crash fixes by @OrcaWin in https://github.com/stablyai/orca/pull/19369\n* fix: preserve user input during terminal scrollback replay by @nwparker in https://github.com/stablyai/orca/pull/19075\n* Unify sidebar create actions into single dropdown menu by @AmethystLiang in https://github.com/stablyai/orca/pull/19375\n* fix: preserve terminal retirement proof across renderer publications by @nwparker in https://github.com/stablyai/orca/pull/19002\n* perf(renderer): avoid per-second spinner animation events by @OrcaWin in https://github.com/stablyai/orca/pull/19407\n* fix(pi): show input modals as waiting instead of working by @mmarabel in https://github.com/stablyai/orca/pull/18836\n* Fix sidebar create menu E2E races by @AmethystLiang in https://github.com/stablyai/orca/pull/19448\n* test: cover input in five simultaneously flooding SSH panes by @nwparker in https://github.com/stablyai/orca/pull/19071\n* fix(runtime): stop a first status publication retiring in-flight worktree scans by @nwparker in https://github.com/stablyai/orca/pull/19357\n* fix(clipboard): route runtime-owned SSH image paste through the runtime by @nwparker in https://github.com/stablyai/orca/pull/19352\n* fix(sidebar): label pinned rows with their host on a multi-host sidebar by @nwparker in https://github.com/stablyai/orca/pull/19351\n* Log error details on session kill failure by @AmethystLiang in https://github.com/stablyai/orca/pull/19381\n* Stabilize scrollbar gutter to prevent message list layout shift by @AmethystLiang in https://github.com/stablyai/orca/pull/19332\n* Rank activity status groups by attention level by @AmethystLiang in https://github.com/stablyai/orca/pull/19329\n* perf: index selected team IDs and choose primary team in one pass by @OrcaWin in https://github.com/stablyai/orca/pull/19504\n* perf: skip Git-status indexing without eligible editors by @OrcaWin in https://github.com/stablyai/orca/pull/19437\n* perf: stop filesystem authorization at the first matching root by @OrcaWin in https://github.com/stablyai/orca/pull/19438\n* perf: index editor ownership and restored workspace projections by @OrcaWin in https://github.com/stablyai/orca/pull/19444\n* fix: avoid duplicate repository groups during catalog refresh by @kiendle in https://github.com/stablyai/orca/pull/19170\n* Fix native PTY I/O failures disabling session termination by @AmethystLiang in https://github.com/stablyai/orca/pull/19523\n* perf: index project table option and iteration order by @OrcaWin in https://github.com/stablyai/orca/pull/19476\n* perf: stop clone URL discovery at the first usable source by @OrcaWin in https://github.com/stablyai/orca/pull/19477\n* perf: clone only changed work-item pages by @OrcaWin in https://github.com/stablyai/orca/pull/19478\n* perf: use counted membership for worker transcript roster twins by @OrcaWin in https://github.com/stablyai/orca/pull/19484\n* perf: skip sorting when all final automation runs fit by @OrcaWin in https://github.com/stablyai/orca/pull/19485\n* perf: parse Git history headers without splitting commit bodies by @OrcaWin in https://github.com/stablyai/orca/pull/19486\n* perf: index forgotten Codex turns for bounded ordinal retention by @OrcaWin in https://github.com/stablyai/orca/pull/19488\n* perf: cache matched and unmatched Claude usage cwd attribution by @OrcaWin in https://github.com/stablyai/orca/pull/19489\n* perf: sum omitted workspace sizes without intermediate objects by @OrcaWin in https://github.com/stablyai/orca/pull/19491\n* fix(activity): show working agents under \"unread only\" by giving working turns a read receipt by @AmethystLiang in https://github.com/stablyai/orca/pull/19535\n* test: fix automation and browser reconciliation e2e tests by @AmethystLiang in https://github.com/stablyai/orca/pull/19530\n* perf: index VM feature restoration and precompute sorting identities by @OrcaWin in https://github.com/stablyai/orca/pull/19457\n* perf: reuse normalized path matchers for batch deletion by @OrcaWin in https://github.com/stablyai/orca/pull/19458\n* perf: normalize only retained browser history candidates by @OrcaWin in https://github.com/stablyai/orca/pull/19460\n* perf: index prior memberships during project identity succession by @OrcaWin in https://github.com/stablyai/orca/pull/19463\n* perf: precompute Jira priority and timestamp sorting keys by @OrcaWin in https://github.com/stablyai/orca/pull/19472\n* perf: parse external automation dates once per run by @OrcaWin in https://github.com/stablyai/orca/pull/19474\n* Verify failure causality in PR checks fix prompt before making changes by @AmethystLiang in https://github.com/stablyai/orca/pull/19435\n* fix(remote): keep terminal tabs syncing after orphan recovery by @shaharmor in https://github.com/stablyai/orca/pull/19065\n* perf(native-chat): bound journal reads during paged catch-up by @nwparker in https://github.com/stablyai/orca/pull/19360\n* perf(native-chat): preserve historical tool rows while streaming by @nwparker in https://github.com/stablyai/orca/pull/19364\n* perf: memoize ancestry when selecting foreground agents by @OrcaWin in https://github.com/stablyai/orca/pull/19502\n* fix(terminal): fold-safe CAN/SUB and double-ESC handling in partial-escape tail by @nwparker in https://github.com/stablyai/orca/pull/19521\n* perf: lazily index case-insensitive Windows environment keys by @OrcaWin in https://github.com/stablyai/orca/pull/19483\n* perf: select highest usage totals without full sorting by @OrcaWin in https://github.com/stablyai/orca/pull/19490\n* fix(agent-hooks): guard every Windows missing-target fallback before it reads stdin by @nwparker in https://github.com/stablyai/orca/pull/19415\n* perf: count GitLab diff line prefixes without splitting all lines by @OrcaWin in https://github.com/stablyai/orca/pull/19505\n* fix(terminal): remove host-retired ghost panes in paired remote splits (#17770) by @nwparker in https://github.com/stablyai/orca/pull/19365\n* feat(terminal): make the contrast floor user-configurable (#10754) by @nwparker in https://github.com/stablyai/orca/pull/18126\n* fix(orchestration): file federated worker mail under the coordinator Run by @Jinwoo-H in https://github.com/stablyai/orca/pull/19542\n* fix(orchestration): refuse Task re-open under a live worker; allow stop re-issue on a stranded row by @Jinwoo-H in https://github.com/stablyai/orca/pull/19551\n* fix(i18n): drop orphan TerminalPane.minimumContrast entries that broke main static analysis by @nwparker in https://github.com/stablyai/orca/pull/19566\n* Remove settled-worker automatic resume and hibernation fences by @Jinwoo-H in https://github.com/stablyai/orca/pull/19544\n* fix(pi): finish the dialog-wait signal on every surface by @nwparker in https://github.com/stablyai/orca/pull/19533\n* Organize activity menu into filter and view sections by @AmethystLiang in https://github.com/stablyai/orca/pull/19547\n* fix(orchestration): own a worker terminal from creation, not after the boot wait by @Jinwoo-H in https://github.com/stablyai/orca/pull/19608\n* fix(orchestration): fence worker release on mobile keystrokes by @Jinwoo-H in https://github.com/stablyai/orca/pull/19337\n* fix(native-chat): place effort to the right of model picker by @brennanb2025 in https://github.com/stablyai/orca/pull/19617\n* Show picker-selected native chat skills as pills by @brennanb2025 in https://github.com/stablyai/orca/pull/19616\n* fix(native-chat): reveal message chrome on keyboard focus, not any focus by @brennanb2025 in https://github.com/stablyai/orca/pull/19426\n* Unify native chat and workspace tab command dispatch by @brennanb2025 in https://github.com/stablyai/orca/pull/19621\n* fix(browser): load newly opened background tabs by @brennanb2025 in https://github.com/stablyai/orca/pull/19633\n* fix(renderer): align native chat pane layering by @brennanb2025 in https://github.com/stablyai/orca/pull/19626\n* Summarize turn file changes and preserve resolved prompt receipts by @brennanb2025 in https://github.com/stablyai/orca/pull/19229\n* perf: track pane alias singleton or ambiguity without copying buckets by @OrcaWin in https://github.com/stablyai/orca/pull/19479\n* perf: preserve store state on unchanged document titles by @OrcaWin in https://github.com/stablyai/orca/pull/19459\n* perf: index document history workspace and path identities by @OrcaWin in https://github.com/stablyai/orca/pull/19461\n* perf: index unified tabs for browser palette entries by @OrcaWin in https://github.com/stablyai/orca/pull/19464\n* perf: append to owned project source lists without repeated copying by @OrcaWin in https://github.com/stablyai/orca/pull/19465\n* perf: lazily index workspace tabs during terminal binding replay by @OrcaWin in https://github.com/stablyai/orca/pull/19466\n* perf: retire exhausted buckets from host-balanced listings by @OrcaWin in https://github.com/stablyai/orca/pull/19493\n* perf: replay pending chat asks with a FIFO cursor by @OrcaWin in https://github.com/stablyai/orca/pull/19496\n* perf: cache same-axis pane subtree weights during equalization by @OrcaWin in https://github.com/stablyai/orca/pull/19500\n* perf: group chat history sequences lazily within result windows by @OrcaWin in https://github.com/stablyai/orca/pull/19501\n* perf: measure sidebar row geometry once before sorting delete anchors by @OrcaWin in https://github.com/stablyai/orca/pull/19503\n* perf: concatenate only carried JSONL line prefixes by @OrcaWin in https://github.com/stablyai/orca/pull/19506\n* perf: index discovered skill IDs and names for batch selection by @OrcaWin in https://github.com/stablyai/orca/pull/19507\n* perf: skip fuzzy ranking when exact file matches fill the window by @OrcaWin in https://github.com/stablyai/orca/pull/19509\n* perf: index tab membership when rebasing authoritative sessions by @OrcaWin in https://github.com/stablyai/orca/pull/19510\n* perf: reuse collators when sorting discovered skills by @OrcaWin in https://github.com/stablyai/orca/pull/19440\n* perf: reuse collators when scanning Warp themes by @OrcaWin in https://github.com/stablyai/orca/pull/19441\n* perf: avoid repeated pending SOCKS payload copies by @OrcaWin in https://github.com/stablyai/orca/pull/19439\n* perf(terminal): avoid quadratic status-marker scans in output bursts by @nwparker in https://github.com/stablyai/orca/pull/19373\n* perf: index selected-host SSH leases by PTY by @OrcaWin in https://github.com/stablyai/orca/pull/19467\n* perf: index selected ancestor coverage during sidebar drag by @OrcaWin in https://github.com/stablyai/orca/pull/19443\n* perf: index Resource Manager labels and accumulated workspace rows by @OrcaWin in https://github.com/stablyai/orca/pull/19480\n* perf: drop oversized diagnostic records before retaining serialized text by @OrcaWin in https://github.com/stablyai/orca/pull/19445\n* perf: index selected skills when reporting bundle failures by @OrcaWin in https://github.com/stablyai/orca/pull/19481\n* perf: bound tool pairing and avoid unused attribution arrays by @OrcaWin in https://github.com/stablyai/orca/pull/19468\n* perf: scope activation inventory to the owning host and workspace by @OrcaWin in https://github.com/stablyai/orca/pull/19447\n* perf: index closed browser page IDs during focus cleanup by @OrcaWin in https://github.com/stablyai/orca/pull/19449\n* perf: prune metadata caches only when an entry can expire by @OrcaWin in https://github.com/stablyai/orca/pull/19450\n* perf: index observable skill installations by locked name by @OrcaWin in https://github.com/stablyai/orca/pull/19451\n* perf: skip folder-scope construction for separate repository imports by @OrcaWin in https://github.com/stablyai/orca/pull/19452\n* perf: validate terminal adoption MRU membership with group sets by @OrcaWin in https://github.com/stablyai/orca/pull/19482\n* perf: index terminal neighbor order and remaining membership by @OrcaWin in https://github.com/stablyai/orca/pull/19453\n* perf: index usage session breakdowns during aggregation and merge by @OrcaWin in https://github.com/stablyai/orca/pull/19487\n* perf: reuse AI-vault path matchers and query and sort keys by @OrcaWin in https://github.com/stablyai/orca/pull/19469\n* perf: cache AI-vault project attribution by host and cwd by @OrcaWin in https://github.com/stablyai/orca/pull/19470\n* perf: select checks-panel workspace attribution in one pass by @OrcaWin in https://github.com/stablyai/orca/pull/19471\n* perf: cache update timestamps for Linear and Jira result sorting by @OrcaWin in https://github.com/stablyai/orca/pull/19473\n* perf: reuse naturally ordered unique Codex trust ranges by @OrcaWin in https://github.com/stablyai/orca/pull/19492\n* perf: count command-line escapes without regex match arrays by @OrcaWin in https://github.com/stablyai/orca/pull/19499\n* perf: check backfill date cardinality before expanding ranges by @OrcaWin in https://github.com/stablyai/orca/pull/19456\n* perf: reuse normalized task-page repository selection by @OrcaWin in https://github.com/stablyai/orca/pull/19454\n* fix(native-chat): unify launch routing and support structured worker placement by @brennanb2025 in https://github.com/stablyai/orca/pull/19431\n* feat(native-chat): show Claude subagent activity on the shared carrier by @brennanb2025 in https://github.com/stablyai/orca/pull/18806\n* fix(orchestration): unify structured-session status across worktree ps and sidebar by @brennanb2025 in https://github.com/stablyai/orca/pull/19217\n* fix(native-chat): expand Claude slash commands sent with an attachment by @brennanb2025 in https://github.com/stablyai/orca/pull/19654\n* fix(native-chat): keep collapsible chevrons beside their header text by @brennanb2025 in https://github.com/stablyai/orca/pull/19656\n* fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance by @brennanb2025 in https://github.com/stablyai/orca/pull/19423\n* test(orchestration): pin the Run-required contract for unbound direct mail by @brennanb2025 in https://github.com/stablyai/orca/pull/19684\n* fix(orchestration): accept a missing Run id from older federation coordinators by @brennanb2025 in https://github.com/stablyai/orca/pull/19689\n* Render task checklists with update diffs and a composer progress panel by @brennanb2025 in https://github.com/stablyai/orca/pull/19230\n* fix(lint): preserve deliberate collator benchmark baselines by @brennanb2025 in https://github.com/stablyai/orca/pull/19686\n* fix(native-chat): merge duplicate native-chat-types import by @Jinwoo-H in https://github.com/stablyai/orca/pull/19698\n* fix(orchestration): file mail from terminals in no Run under an unbound Run by @Jinwoo-H in https://github.com/stablyai/orca/pull/19696\n* feat(native-chat): report Codex background tasks in the chat strip by @brennanb2025 in https://github.com/stablyai/orca/pull/19346\n* Unify tab surface selection across workspace activation by @brennanb2025 in https://github.com/stablyai/orca/pull/19635\n* refactor(ai-vault): split the session scanner into a transcript reader and consumers by @Jinwoo-H in https://github.com/stablyai/orca/pull/19666\n* perf: stop queued metadata work after watcher cancellation by @OrcaWin in https://github.com/stablyai/orca/pull/19446\n* perf(runtime): skip hibernation inventories without completed agents by @nwparker in https://github.com/stablyai/orca/pull/19391\n* perf(workspaces): skip unchanged heartbeat subscriber work by @nwparker in https://github.com/stablyai/orca/pull/19392\n* perf: probe requested pane keys instead of enumerating records by @OrcaWin in https://github.com/stablyai/orca/pull/19494\n* perf: resolve current-workspace document addresses before catalog scans by @OrcaWin in https://github.com/stablyai/orca/pull/19495\n* perf: materialize only the requested recent plugin audit lines by @OrcaWin in https://github.com/stablyai/orca/pull/19498\n* test(ci): replace fixed teardown waits with bounded polls by @nwparker in https://github.com/stablyai/orca/pull/19720\n* perf: stop merging OS-opened files at the pending queue cap by @OrcaWin in https://github.com/stablyai/orca/pull/19508\n* perf(terminal): skip plain text between partial escape sequences by @nwparker in https://github.com/stablyai/orca/pull/19393\n* perf(terminal): release oversized backing strings behind pending controls by @nwparker in https://github.com/stablyai/orca/pull/19396\n* perf(terminal): skip ordinary output in preview control scans by @nwparker in https://github.com/stablyai/orca/pull/19400\n* perf(terminal): own retained tail rows so they stop pinning their chunks by @nwparker in https://github.com/stablyai/orca/pull/19528\n* perf: check tunnel queue capacity before copying frame payloads by @OrcaWin in https://github.com/stablyai/orca/pull/19497\n* Replace the sidebar create dropdown with two direct action buttons by @AmethystLiang in https://github.com/stablyai/orca/pull/19653\n* fix(native-chat): keep worktree active during chat creation by @Jinwoo-H in https://github.com/stablyai/orca/pull/19753\n* fix(push): isolate deployment and validate candidates before activation by @Jinwoo-H in https://github.com/stablyai/orca/pull/19771\n* fix(native-chat): show Claude working from the send, not the provider echo by @brennanb2025 in https://github.com/stablyai/orca/pull/19822\n* fix(mobile): enable patch-free iOS text selection in native chat by @brennanb2025 in https://github.com/stablyai/orca/pull/19769\n* perf(terminal): resume OSC terminator search past the carried frame by @nwparker in https://github.com/stablyai/orca/pull/19839\n* perf(native-chat): bound retained items on the structured session path by @nwparker in https://github.com/stablyai/orca/pull/19841\n* perf(terminal): share one ESC dispatch table between the two scanners by @nwparker in https://github.com/stablyai/orca/pull/19842\n* fix(native-chat): one `/` picker for every agent, anywhere in the prompt by @brennanb2025 in https://github.com/stablyai/orca/pull/19832\n* feat(native-chat): the background-tasks strip says what is running by @brennanb2025 in https://github.com/stablyai/orca/pull/19311\n* fix(i18n): drop the stale TerminalPane.minimumContrast entries from the runtime catalog (main red again) by @nwparker in https://github.com/stablyai/orca/pull/19575\n* fix(native-chat): keep an older page from punching a hole in the transcript by @nwparker in https://github.com/stablyai/orca/pull/19845\n* refactor(agent-status): publish structured sessions into the hook server store by @brennanb2025 in https://github.com/stablyai/orca/pull/19683\n* fix(orchestration): scope @ group addresses to the sender's Run by @Jinwoo-H in https://github.com/stablyai/orca/pull/19783\n* feat(native-chat): show live background work and name each row by kind by @brennanb2025 in https://github.com/stablyai/orca/pull/19705\n* feat(sidebar): show native-chat subagents as sidebar child rows, like CLI agents already do by @brennanb2025 in https://github.com/stablyai/orca/pull/19807\n* feat(native-chat): focus the message box when a chat appears by @brennanb2025 in https://github.com/stablyai/orca/pull/19868\n* fix(relay): keep failed rehome polls out of the durable failure budget by @Jinwoo-H in https://github.com/stablyai/orca/pull/19915\n* fix(orchestration): revalidate an attempted Enter instead of resending it by @brennanb2025 in https://github.com/stablyai/orca/pull/19911\n* test(native-chat): split structured question fixtures by @brennanb2025 in https://github.com/stablyai/orca/pull/19924\n* Make the structured turn lifecycle row durable so completed durations survive by @brennanb2025 in https://github.com/stablyai/orca/pull/19695\n* refactor(native-chat): unify agent session launch and open drafts in structured chat by @brennanb2025 in https://github.com/stablyai/orca/pull/19681\n* feat(cloud): native push gateway and dedicated infrastructure (1/3) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19912\n* fix(browser): restore the Chrome-shaped browser identity (STA-7147) by @brennanb2025 in https://github.com/stablyai/orca/pull/19927\n* refactor(mobile): extract settings, diagnostics and editor-document screens from their routes by @Jinwoo-H in https://github.com/stablyai/orca/pull/19675\n* fix(native-chat): never adopt an unlisted model as the launch default by @brennanb2025 in https://github.com/stablyai/orca/pull/19854\n* fix(native-chat): settle a structured send on admission, not on the provider echo by @brennanb2025 in https://github.com/stablyai/orca/pull/19863\n* feat(desktop): native mobile push integration (2/3) by @Jinwoo-H in https://github.com/stablyai/orca/pull/19935\n* fix(sidebar): let an agent row show the provider's own session title by @brennanb2025 in https://github.com/stablyai/orca/pull/19936\n* fix(editor): make markdown images inline so a paragraph stays schema-valid by @OrcaWin in https://github.com/stablyai/orca/pull/19746\n* feat(native-chat): record the provider's name for an agent session by @brennanb2025 in https://github.com/stablyai/orca/pull/19908\n* fix(release): port three release-gate fixes to main so cuts stop re-inheriting them by @brennanb2025 in https://github.com/stablyai/orca/pull/19945\n* refactor(renderer): share path head elision by @AmethystLiang in https://github.com/stablyai/orca/pull/19938\n* feat(cmd-j): compact palette location layout by @AmethystLiang in https://github.com/stablyai/orca/pull/19939\n* refactor(mobile): name the RPC acceptance policies call sites hand-rolled by @Jinwoo-H in https://github.com/stablyai/orca/pull/19960\n* fix: prompt unexpectedly signed-out Cloud users once per version by @Jinwoo-H in https://github.com/stablyai/orca/pull/19966\n* feat(ai-vault-search): session search index as a transcript reader consumer by @Jinwoo-H in https://github.com/stablyai/orca/pull/19687\n* fix(native-chat): pass agent-implemented slash commands through to the agent by @brennanb2025 in https://github.com/stablyai/orca/pull/19929\n* perf(terminal): stop rebuilding per-workspace collections on a worktree switch by @nwparker in https://github.com/stablyai/orca/pull/19975\n* feat(rpc): generate a shared params catalog from the host registry, gated on parse parity by @Jinwoo-H in https://github.com/stablyai/orca/pull/19961\n* feat(native-chat): describe slash commands from the provider's own report by @brennanb2025 in https://github.com/stablyai/orca/pull/19928\n* perf(terminal): stop spending reveal-restore frames on panes that replay nothing by @nwparker in https://github.com/stablyai/orca/pull/19972\n* i18n: Make file reveal labels translatable by @OrcaWin in https://github.com/stablyai/orca/pull/20010\n* perf(native-chat): mount only the transcript rows near the viewport by @brennanb2025 in https://github.com/stablyai/orca/pull/19869\n* feat(mobile): receive native push notifications from paired desktops by @Jinwoo-H in https://github.com/stablyai/orca/pull/19951\n* fix(workspaces): seed shells only for blank selection by @brennanb2025 in https://github.com/stablyai/orca/pull/19940\n* Hide desktop theme imports from paired web clients by @OrcaWin in https://github.com/stablyai/orca/pull/20015\n* feat(ai-vault-search): session search query engine over the index by @Jinwoo-H in https://github.com/stablyai/orca/pull/19750\n* refactor(shared): derive WellKnownAgentType from TuiAgent by @AmethystLiang in https://github.com/stablyai/orca/pull/19645\n* Revert \"feat(ai-vault-search): session search query engine over the index (#19750)\" by @Jinwoo-H in https://github.com/stablyai/orca/pull/20023\n* feat(ai-vault-search): session search indexer library owning backfill, reconcile and retention by @Jinwoo-H in https://github.com/stablyai/orca/pull/19751\n* refactor(rpc): make defineMethod preserve the method name and handler result by @Jinwoo-H in https://github.com/stablyai/orca/pull/20016\n* fix(daemon): pin owner-only modes on persisted terminal history by @nwparker in https://github.com/stablyai/orca/pull/19954\n* Revert mobile push rollout pending delivery investigation by @Jinwoo-H in https://github.com/stablyai/orca/pull/20040\n* fix(pi): retire stale spinner and prompt timers across reloads by @nwparker in https://github.com/stablyai/orca/pull/20035\n* fix(terminal): stop the recovery budget erasing itself during a remount by @OrcaWin in https://github.com/stablyai/orca/pull/19745\n* fix(monaco): bound embedded-language recursion in svelte/astro/vue grammars by @OrcaWin in https://github.com/stablyai/orca/pull/19748\n* fix(runtime): agent-neutral wait-blocked reasons by @OrcaWin in https://github.com/stablyai/orca/pull/19749\n* fix(worktrees): close an idle structured chat on delete instead of refusing by @brennanb2025 in https://github.com/stablyai/orca/pull/19762\n* fix(runtime): let connections own host status recovery by @Jinwoo-H in https://github.com/stablyai/orca/pull/20003\n* fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning by @brennanb2025 in https://github.com/stablyai/orca/pull/19977\n* perf(terminal): mount only the visible pane on a worktree switch by @nwparker in https://github.com/stablyai/orca/pull/20034\n* test(monaco): drive the real Monarch tokenizer instead of walking rule tables by @nwparker in https://github.com/stablyai/orca/pull/19981\n* test(runtime): capture real Antigravity transcripts — the detector is inverted on live output by @nwparker in https://github.com/stablyai/orca/pull/19983\n* fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) by @brennanb2025 in https://github.com/stablyai/orca/pull/16631\n* fix(pi): load extension providers for source control generation by @nwparker in https://github.com/stablyai/orca/pull/20070\n* fix(terminal): keep a deliberately slept workspace cold until it is woken by @nwparker in https://github.com/stablyai/orca/pull/20075\n* perf(daemon): stop scanning every cell for OSC links that cannot exist by @nwparker in https://github.com/stablyai/orca/pull/20077\n* fix(terminal): move the recovery ledger onto the tab row and gate it on observed outcome by @nwparker in https://github.com/stablyai/orca/pull/20025\n* fix(push): preserve distinct Android alerts while offline by @Jinwoo-H in https://github.com/stablyai/orca/pull/20066\n* feat(native-chat): show when a Codex goal is set, changed, or cleared by @brennanb2025 in https://github.com/stablyai/orca/pull/19923\n* Fix remote hosted review browser routing by @Jinwoo-H in https://github.com/stablyai/orca/pull/20030\n* fix(mobile): surface host create warnings and terminal-create errors by @brennanb2025 in https://github.com/stablyai/orca/pull/20125\n* feat(relay): correct regional placement only when the source is idle by @Jinwoo-H in https://github.com/stablyai/orca/pull/20105\n* test(relay): harden regional rehome race coverage by @Jinwoo-H in https://github.com/stablyai/orca/pull/20136\n* feat(desktop): measure relay regions and reconnect after idle cutover by @Jinwoo-H in https://github.com/stablyai/orca/pull/20106\n* refactor(agent-status): delete the runtime's retained row store (PR 1b) by @brennanb2025 in https://github.com/stablyai/orca/pull/19785\n* fix(deps): take Electron 43.7.0 for the glibc environ use-after-free (#20081) by @nwparker in https://github.com/stablyai/orca/pull/20089\n* fix(browser): decode Chromium SameSite storage values (135 cookies silently lost per profile) by @brennanb2025 in https://github.com/stablyai/orca/pull/20076\n* fix(terminal): re-run deferred tab admission when a plan installs mid-activation by @brennanb2025 in https://github.com/stablyai/orca/pull/20176\n* fix(workspaces): complete a worktree create when a post-create step throws by @brennanb2025 in https://github.com/stablyai/orca/pull/20175\n* feat(relay): support protocol 3 in cell rollout gates by @Jinwoo-H in https://github.com/stablyai/orca/pull/20174\n* fix(e2e): remove four real flake sources and one caret race by @brennanb2025 in https://github.com/stablyai/orca/pull/20169\n* fix(source-control): pass the Antigravity prompt to agy --print by @nwparker in https://github.com/stablyai/orca/pull/20147\n* fix(e2e): run worktree first-paint probe on a mapped window by @brennanb2025 in https://github.com/stablyai/orca/pull/20197\n* fix(relay): bound idle rehome polling and skip disabled scans by @Jinwoo-H in https://github.com/stablyai/orca/pull/20203\n* fix(hooks): stop orphaned managed markers from consuming user TOML by @nwparker in https://github.com/stablyai/orca/pull/20148\n* fix(codex): reconcile marketplace and plugin tables through the config mirror by @nwparker in https://github.com/stablyai/orca/pull/20150\n* fix(relay): reuse canary across completed rollout batches by @Jinwoo-H in https://github.com/stablyai/orca/pull/20214\n* Restore mobile push and fix cold-start dismissals by @Jinwoo-H in https://github.com/stablyai/orca/pull/20068\n* fix(cloud): tolerate sparse director errors in rollout monitor by @Jinwoo-H in https://github.com/stablyai/orca/pull/20238\n* feat(ai-vault-search): session search query engine over the index by @Jinwoo-H in https://github.com/stablyai/orca/pull/20029\n* feat(mobile): add typed RPC operations and fence raw requests by @Jinwoo-H in https://github.com/stablyai/orca/pull/20018\n* fix(native-chat): shrink skill pill text by @brennanb2025 in https://github.com/stablyai/orca/pull/20254\n* fix(claude): refuse a structured model the provider does not list by @brennanb2025 in https://github.com/stablyai/orca/pull/19946\n* perf: defer closed menus to speed up cold worktree switching by @nwparker in https://github.com/stablyai/orca/pull/20311\n* test: replace fixed UI waits with observable readiness by @nwparker in https://github.com/stablyai/orca/pull/20369\n* ci: reuse immutable package setup across shutdown checks by @nwparker in https://github.com/stablyai/orca/pull/20368\n* perf: index Hermes session correlation instead of rescanning history by @nwparker in https://github.com/stablyai/orca/pull/20275\n* fix: prevent infinite loops in mobile Markdown rendering by @nwparker in https://github.com/stablyai/orca/pull/20313\n* perf: eliminate mobile review table delimiter backtracking by @nwparker in https://github.com/stablyai/orca/pull/20317\n* perf: eliminate repeated mobile Markdown link suffix scans by @nwparker in https://github.com/stablyai/orca/pull/20322\n* ci: narrow pnpm cache keys and shallow development checkouts by @nwparker in https://github.com/stablyai/orca/pull/20370\n* perf(browser): fill plain fields in one stdin edit by @nwparker in https://github.com/stablyai/orca/pull/20364\n* Fix landing footer menu placement by @nwparker in https://github.com/stablyai/orca/pull/20360\n* ci: balance existing unit and E2E shards using recorded timings by @nwparker in https://github.com/stablyai/orca/pull/20367\n* fix(cloud): diagnose wrapped relay trust probe failures by @Jinwoo-H in https://github.com/stablyai/orca/pull/20403\n* refactor(renderer): improve IPC error handling with clamped and unclamped variants by @AmethystLiang in https://github.com/stablyai/orca/pull/20340\n* Make sidebar nesting easier while preserving animated reordering by @nwparker in https://github.com/stablyai/orca/pull/20412\n* refactor(preload): drop the unused raw electron IPC bridge by @nwparker in https://github.com/stablyai/orca/pull/20419\n* fix(browser): move cookie scoping off psl's stale suffix list by @nwparker in https://github.com/stablyai/orca/pull/20421\n* fix(claude-accounts): stop path resolution from creating the config directory by @makoto-developer in https://github.com/stablyai/orca/pull/12309\n* perf: normalize title evidence once per label search by @nwparker in https://github.com/stablyai/orca/pull/20331\n* fix(runtime): GC expired proven-absent leaf PTY verdicts by @innocarpe in https://github.com/stablyai/orca/pull/12810\n* fix(ai-vault): close OpenCode handles when query_only setup fails by @bbingz in https://github.com/stablyai/orca/pull/14134\n* perf(wsl): release environment probe deadline timers by @OrcaWin in https://github.com/stablyai/orca/pull/20207\n* perf(daemon): bound immediate flush queue scans by @OrcaWin in https://github.com/stablyai/orca/pull/20209\n* perf(mobile): normalize history scope paths once per candidate by @nwparker in https://github.com/stablyai/orca/pull/20210\n* perf(automations): skip sorting count-only Hermes history by @OrcaWin in https://github.com/stablyai/orca/pull/20213\n* perf(chat): reuse streamed delta byte lengths by @OrcaWin in https://github.com/stablyai/orca/pull/20215\n* perf(files): skip unused Quick Open byte accounting by @OrcaWin in https://github.com/stablyai/orca/pull/20216\n* perf(relay): reuse host data attachment owner without inventory copies by @nwparker in https://github.com/stablyai/orca/pull/20219\n* perf(mobile): avoid materializing input characters on backspace by @OrcaWin in https://github.com/stablyai/orca/pull/20220\n* perf(browser): skip unneeded inventory compaction planning by @OrcaWin in https://github.com/stablyai/orca/pull/20221\n* perf(usage): skip session scans for empty breakdowns by @OrcaWin in https://github.com/stablyai/orca/pull/20222\n* perf(session): reuse persisted tab membership set by @OrcaWin in https://github.com/stablyai/orca/pull/20223\n* perf(diff): skip unused extension extraction during search by @OrcaWin in https://github.com/stablyai/orca/pull/20225\n* perf(mobile): bound autocomplete substring retention by @OrcaWin in https://github.com/stablyai/orca/pull/20226\n* perf: index pending terminal reconnect tab lookups by @OrcaWin in https://github.com/stablyai/orca/pull/20229\n* perf: skip ordinary strings in JSON structure checks by @OrcaWin in https://github.com/stablyai/orca/pull/20232\n* perf: avoid rescanning partial browser sidecar responses by @OrcaWin in https://github.com/stablyai/orca/pull/20242\n* perf: count local RPC request bytes incrementally by @OrcaWin in https://github.com/stablyai/orca/pull/20243\n* perf(history): cache numeric transcript timeline bounds by @OrcaWin in https://github.com/stablyai/orca/pull/20244\n* perf: stop heading lookup at the requested occurrence by @OrcaWin in https://github.com/stablyai/orca/pull/20246\n* perf: defer browser search row formatting until matched by @OrcaWin in https://github.com/stablyai/orca/pull/20248\n* perf: stop preparing over-limit palette queries by @OrcaWin in https://github.com/stablyai/orca/pull/20250\n* perf: avoid duplicate repository directory probes by @OrcaWin in https://github.com/stablyai/orca/pull/20253\n* perf: compile nested repository ignore patterns once by @OrcaWin in https://github.com/stablyai/orca/pull/20256\n* perf: avoid repeated model pricing name normalization by @OrcaWin in https://github.com/stablyai/orca/pull/20257\n* perf(skills): index source ownership counts per discovery by @OrcaWin in https://github.com/stablyai/orca/pull/20259\n* perf(plugins): stop collection traversal at message size limit by @nwparker in https://github.com/stablyai/orca/pull/20260\n* perf(history): scan legacy alternate-screen markers linearly by @OrcaWin in https://github.com/stablyai/orca/pull/20267\n* perf: reuse skill file digest when normalized bytes match by @OrcaWin in https://github.com/stablyai/orca/pull/20268\n* perf(native): avoid sanitizing snapshot values twice by @nwparker in https://github.com/stablyai/orca/pull/20271\n* perf: encode skill Git tree sort keys once by @OrcaWin in https://github.com/stablyai/orca/pull/20272\n* perf: skip decoding discarded diagnostic error bodies by @OrcaWin in https://github.com/stablyai/orca/pull/20276\n* perf: avoid flattening tabs for smart-sort liveness check by @OrcaWin in https://github.com/stablyai/orca/pull/20279\n* perf: accept UTF-8 text that fits the worst-case byte bound by @OrcaWin in https://github.com/stablyai/orca/pull/20286\n* perf: bound rich Markdown comment detection and preservation scans by @nwparker in https://github.com/stablyai/orca/pull/20287\n* perf(ipc): bound SSH ACK batch selection scans by @nwparker in https://github.com/stablyai/orca/pull/20288\n* perf: avoid copying completed terminal source ranges by @OrcaWin in https://github.com/stablyai/orca/pull/20289\n* perf(ssh): skip terminal source spans awaiting ACK publication by @OrcaWin in https://github.com/stablyai/orca/pull/20290\n* perf: reuse diagnostic bundle byte count by @OrcaWin in https://github.com/stablyai/orca/pull/20292\n* perf: normalize manifest text files once by @OrcaWin in https://github.com/stablyai/orca/pull/20294\n* perf: reuse formatter within reset timestamp conversion by @OrcaWin in https://github.com/stablyai/orca/pull/20295\n* perf: defer scrollback pruning indexes until needed by @OrcaWin in https://github.com/stablyai/orca/pull/20297\n* perf(daemon): reuse encoded unsplit NDJSON frames by @nwparker in https://github.com/stablyai/orca/pull/20298\n* perf(browser): index iframe session refs for snapshot lookup by @nwparker in https://github.com/stablyai/orca/pull/20300\n* perf: reuse computed project identity in projection by @OrcaWin in https://github.com/stablyai/orca/pull/20303\n* perf: stop archive size scan once over budget by @OrcaWin in https://github.com/stablyai/orca/pull/20304\n* perf: avoid per-item arrays in journal projection by @OrcaWin in https://github.com/stablyai/orca/pull/20305\n* perf: reuse Windows PATH normalization within merges by @OrcaWin in https://github.com/stablyai/orca/pull/20309\n* perf: find path basename without splitting all segments by @OrcaWin in https://github.com/stablyai/orca/pull/20312\n* perf(ssh): copy cleanup tab map only once by @OrcaWin in https://github.com/stablyai/orca/pull/20315\n* perf: fast-path Windows arguments without escapes by @OrcaWin in https://github.com/stablyai/orca/pull/20318\n* perf(checks): bound log excerpt byte-limit checks by @OrcaWin in https://github.com/stablyai/orca/pull/20319\n* perf: use native newline search in rich Markdown code stripping by @nwparker in https://github.com/stablyai/orca/pull/20320\n* perf: stop building chat diff sections beyond preview limit by @OrcaWin in https://github.com/stablyai/orca/pull/20321\n* perf: count chat tool calls without a filtered array by @OrcaWin in https://github.com/stablyai/orca/pull/20328\n* perf: reject file-count labels before parsing edit patches by @OrcaWin in https://github.com/stablyai/orca/pull/20330\n* perf(terminal): reuse bounded native snapshot byte checks by @OrcaWin in https://github.com/stablyai/orca/pull/20333\n* perf: select matching model IDs without sorting by @OrcaWin in https://github.com/stablyai/orca/pull/20334\n* perf: remove duplicate empty-file filtering from search finalization by @OrcaWin in https://github.com/stablyai/orca/pull/20335\n* perf: avoid repeated history page byte serialization by @nwparker in https://github.com/stablyai/orca/pull/20337\n* perf: collect terminal snapshot replay without wrapper arrays by @OrcaWin in https://github.com/stablyai/orca/pull/20338\n* perf: reuse accepted project group IDs for parent validation by @OrcaWin in https://github.com/stablyai/orca/pull/20341\n* perf: compute check severity once per sort by @OrcaWin in https://github.com/stablyai/orca/pull/20342\n* perf: count bot comments without a filtered array by @OrcaWin in https://github.com/stablyai/orca/pull/20343\n* perf: index relay drain migration inventories once by @nwparker in https://github.com/stablyai/orca/pull/20344\n* perf: avoid typed-array iteration in shared SHA-256 blocks by @OrcaWin in https://github.com/stablyai/orca/pull/20346\n* perf: reuse shared SHA-256 for renderer fallback by @OrcaWin in https://github.com/stablyai/orca/pull/20347\n* perf(computer): avoid rescanning fragmented native provider replies by @OrcaWin in https://github.com/stablyai/orca/pull/20348\n* perf: skip nonmatching spans in Quick Open scoring by @nwparker in https://github.com/stablyai/orca/pull/20349\n* perf: skip dominated quick command search fields by @nwparker in https://github.com/stablyai/orca/pull/20352\n* perf: reuse normalized executable during agent recognition by @OrcaWin in https://github.com/stablyai/orca/pull/20353\n* perf(localization): prefilter absent brands before regex matching by @OrcaWin in https://github.com/stablyai/orca/pull/20354\n* perf: avoid copying single-chunk process output by @OrcaWin in https://github.com/stablyai/orca/pull/20355\n* perf: scan chat activity text from its last line by @nwparker in https://github.com/stablyai/orca/pull/20356\n* perf(mobile): reuse UTF-8 prefix truncation for diagnostics by @OrcaWin in https://github.com/stablyai/orca/pull/20358\n* perf(checks): bound earlier error context collection by @OrcaWin in https://github.com/stablyai/orca/pull/20211\n* perf(mobile): reuse numeric collators across source control sorts by @nwparker in https://github.com/stablyai/orca/pull/20224\n* perf(mobile): precompute task sort keys and reuse repository collation by @nwparker in https://github.com/stablyai/orca/pull/20233\n* perf(agents): avoid cloning lineage ancestor sets by @OrcaWin in https://github.com/stablyai/orca/pull/20236\n* perf(plugins): reconcile shortcut conflicts once per owner by @nwparker in https://github.com/stablyai/orca/pull/20237\n* perf: avoid rescanning partial transcript records by @OrcaWin in https://github.com/stablyai/orca/pull/20239\n* perf: use native newline search for Markdown review excerpts by @nwparker in https://github.com/stablyai/orca/pull/20245\n* perf(ai-vault): deduplicate session scan batches incrementally by @OrcaWin in https://github.com/stablyai/orca/pull/20255\n* perf: stop review acknowledgement summaries at the first readable line by @nwparker in https://github.com/stablyai/orca/pull/20263\n* perf(git): classify bulk discard paths once by @nwparker in https://github.com/stablyai/orca/pull/20274\n* perf: eliminate quadratic palette phrase placement scans by @nwparker in https://github.com/stablyai/orca/pull/20306\n* perf: avoid repeated unclosed-tag scans in mobile reviews by @nwparker in https://github.com/stablyai/orca/pull/20329\n* perf(plugins): index contributed keybindings by command by @nwparker in https://github.com/stablyai/orca/pull/20241\n* perf(mobile): precompute Linear issue sort keys by @nwparker in https://github.com/stablyai/orca/pull/20249\n* perf(chat): join transcript line fragments at record boundaries by @OrcaWin in https://github.com/stablyai/orca/pull/20278\n* perf(cmd-j): scan phrase word boundaries monotonically by @nwparker in https://github.com/stablyai/orca/pull/20282\n* perf(ai-vault): count recent sessions for scan cutoffs by @OrcaWin in https://github.com/stablyai/orca/pull/20301\n* perf: remove repeated sibling scans from cyclic agent lineage cleanup by @nwparker in https://github.com/stablyai/orca/pull/20302\n* perf: reuse pending filesystem watcher debounce timers by @OrcaWin in https://github.com/stablyai/orca/pull/20264\n* fix(editor): eliminate multi-second Markdown blank-run scans by @nwparker in https://github.com/stablyai/orca/pull/20231\n* perf: avoid rescanning partial Windows desktop responses by @OrcaWin in https://github.com/stablyai/orca/pull/20235\n* fix(chat): bound journal replay memory by live history by @nwparker in https://github.com/stablyai/orca/pull/20247\n* perf(android): queue fragmented scrcpy video packets by @OrcaWin in https://github.com/stablyai/orca/pull/20230\n* perf(mobile): skip successful duplicate connection log saves by @nwparker in https://github.com/stablyai/orca/pull/20252\n* perf: skip impossible inline HTML comment matches during encoding by @nwparker in https://github.com/stablyai/orca/pull/20293\n* chore: remove 20.7 MiB of duplicate and unused media by @nwparker in https://github.com/stablyai/orca/pull/20416\n* perf: bound whitespace normalization for tool previews by @nwparker in https://github.com/stablyai/orca/pull/20332\n* fix(tabs): cancel split drags when the window loses focus by @OrcaWin in https://github.com/stablyai/orca/pull/20323\n* fix(updater): open background check errors from the status bar by @OrcaWin in https://github.com/stablyai/orca/pull/20270\n* perf(relay): sort latency samples once per percentile pair by @nwparker in https://github.com/stablyai/orca/pull/20427\n* perf(skills): test agent ownership without building a deduped list by @nwparker in https://github.com/stablyai/orca/pull/20428\n* perf(git): reject non-HEAD refs before sorting remotes by @nwparker in https://github.com/stablyai/orca/pull/20429\n* perf(mobile): release consumed terminal write-queue slots by @nwparker in https://github.com/stablyai/orca/pull/20430\n* perf(git): normalize tracked discard paths once per operation by @nwparker in https://github.com/stablyai/orca/pull/20299\n* refactor(windows): vendor the registry addon as @orca/windows-registry by @nwparker in https://github.com/stablyai/orca/pull/20438\n* fix(resource-manager): show saved folder workspace names and groups by @OrcaWin in https://github.com/stablyai/orca/pull/20324\n* test(relay): differential coverage for the single-pass host-data owner lookup by @nwparker in https://github.com/stablyai/orca/pull/20426\n* perf(mobile): reuse Linear grouping between list and board by @nwparker in https://github.com/stablyai/orca/pull/20431\n* chore(deps): pin serve-sim to an exact version by @nwparker in https://github.com/stablyai/orca/pull/20446\n* Add code quality lint for type assertions by @AmethystLiang in https://github.com/stablyai/orca/pull/19462\n* fix(ci): cache the vendored addon where node-gyp actually writes it by @nwparker in https://github.com/stablyai/orca/pull/20445\n* perf: accelerate cancellable remote transcript line scanning by @OrcaWin in https://github.com/stablyai/orca/pull/20351\n* fix(ci): match the truncated windows-process-tree virtual store dir by @nwparker in https://github.com/stablyai/orca/pull/20447\n* build(macos): parallelize native helpers with complete cancellation by @espetro in https://github.com/stablyai/orca/pull/19651\n* perf: skip WSL discovery when filtering native-only paths by @OrcaWin in https://github.com/stablyai/orca/pull/20266\n* perf(mobile): reuse Linear ordering for list and board grouping by @nwparker in https://github.com/stablyai/orca/pull/20314\n* Reduce native dependency installs to the host platform by @nwparker in https://github.com/stablyai/orca/pull/20420\n* perf(ai-vault): bound per-row bookkeeping in unlimited session scans by @nwparker in https://github.com/stablyai/orca/pull/20291\n* perf(tooling): reuse directory entry types in source scans by @OrcaWin in https://github.com/stablyai/orca/pull/20212\n* fix(skills): evict removed runtime discovery cache by @bbingz in https://github.com/stablyai/orca/pull/11489\n* fix(automations): isolate the scheduler tick and refuse oversized cron steps by @nwparker in https://github.com/stablyai/orca/pull/20152\n* fix(runtime): stop a name-only agent title from satisfying tui-idle by @nwparker in https://github.com/stablyai/orca/pull/20155\n* fix(agent-hooks): stop navigation Escape from inferring completion by @nwparker in https://github.com/stablyai/orca/pull/20149\n* fix(mobile): reuse current workspace on notification taps by @Jinwoo-H in https://github.com/stablyai/orca/pull/20310\n* fix(ai-vault): re-read a transcript rewritten to its previous size by @Jinwoo-H in https://github.com/stablyai/orca/pull/20261\n* fix(sidebar): show agent activity before workspace activation by @Jinwoo-H in https://github.com/stablyai/orca/pull/20398\n* test(rpc): add a compile-time params catalog parity gate by @Jinwoo-H in https://github.com/stablyai/orca/pull/20281\n* test(scripts): widen the Windows shim ratchet to catch package bin spawns by @Jinwoo-H in https://github.com/stablyai/orca/pull/20285\n* refactor(native-chat): give each structured dispatch state exactly one meaning by @brennanb2025 in https://github.com/stablyai/orca/pull/20133\n* feat(native-chat): decide a restart-stranded send against provider history by @brennanb2025 in https://github.com/stablyai/orca/pull/20139\n* fix(native-chat): wait for the runtime capability probe before resolving the creation launch route by @brennanb2025 in https://github.com/stablyai/orca/pull/19819\n* fix(worktrees): retire the chat tab of a chat with no child when its workspace goes by @brennanb2025 in https://github.com/stablyai/orca/pull/19970\n* feat(ai-vault-search): public session search contract and transports by @Jinwoo-H in https://github.com/stablyai/orca/pull/20277\n* refactor(mobile): migrate settings reads to RpcOperation by @Jinwoo-H in https://github.com/stablyai/orca/pull/20499\n* fix(mobile): give native chat one tail-follow owner so streaming stops jumping by @brennanb2025 in https://github.com/stablyai/orca/pull/20493\n* fix(mobile): preserve delivery ambiguity across transport cutover by @Jinwoo-H in https://github.com/stablyai/orca/pull/20280\n* fix(runtime): reject malformed file Base64 padding by @Jinwoo-H in https://github.com/stablyai/orca/pull/20283\n* fix(tasks): preserve repository results under GitHub search quota by @OrcaWin in https://github.com/stablyai/orca/pull/20460\n* fix(ai-vault): stream oversized remote session transcripts by @OrcaWin in https://github.com/stablyai/orca/pull/20455\n* fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude by @brennanb2025 in https://github.com/stablyai/orca/pull/20507\n* fix(browser): press keys through a US-layout CDP key table instead of a subprocess per keystroke by @atreidesend in https://github.com/stablyai/orca/pull/15310\n* fix(rpc): validate provider-specific fields in TaskProviderIdentity by @Jinwoo-H in https://github.com/stablyai/orca/pull/20284\n* perf(terminal): batch file-link checks on their owning host by @OrcaWin in https://github.com/stablyai/orca/pull/20463\n* feat(native-chat): support file drag and drop by @brennanb2025 in https://github.com/stablyai/orca/pull/20494\n\n## New Contributors\n* @tb-soshiro made their first contribution in https://github.com/stablyai/orca/pull/18787\n* @NaoyaTatetsu made their first contribution in https://github.com/stablyai/orca/pull/17795\n* @ramarivera made their first contribution in https://github.com/stablyai/orca/pull/12772\n* @Aladex made their first contribution in https://github.com/stablyai/orca/pull/18634\n* @weekbin made their first contribution in https://github.com/stablyai/orca/pull/14929\n* @TimothyVang made their first contribution in https://github.com/stablyai/orca/pull/17936\n* @brohoya made their first contribution in https://github.com/stablyai/orca/pull/11756\n* @blade035 made their first contribution in https://github.com/stablyai/orca/pull/17731\n* @kiendle made their first contribution in https://github.com/stablyai/orca/pull/19170\n* @espetro made their first contribution in https://github.com/stablyai/orca/pull/19651\n* @atreidesend made their first contribution in https://github.com/stablyai/orca/pull/15310\n\n**Full Changelog**: https://github.com/stablyai/orca/compare/mobile-android-v0.0.48...mobile-android-v0.0.50\n", + "reactions": { + "url": "https://api.github.com/repos/stablyai/orca/releases/391254643/reactions", + "total_count": 18, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 14, + "rocket": 4, + "eyes": 0 + }, + "mentions_count": 21 +} diff --git a/mobile/src/app-update/fixtures/itunes-lookup-com.stably.orca.mobile.json b/mobile/src/app-update/fixtures/itunes-lookup-com.stably.orca.mobile.json new file mode 100644 index 00000000000..f4de826ef72 --- /dev/null +++ b/mobile/src/app-update/fixtures/itunes-lookup-com.stably.orca.mobile.json @@ -0,0 +1,189 @@ +{ + "resultCount": 1, + "results": [ + { + "isGameCenterEnabled": false, + "features": ["iosUniversal"], + "supportedDevices": [ + "iPhone5s-iPhone5s", + "iPadAir-iPadAir", + "iPadAirCellular-iPadAirCellular", + "iPadMiniRetina-iPadMiniRetina", + "iPadMiniRetinaCellular-iPadMiniRetinaCellular", + "iPhone6-iPhone6", + "iPhone6Plus-iPhone6Plus", + "iPadAir2-iPadAir2", + "iPadAir2Cellular-iPadAir2Cellular", + "iPadMini3-iPadMini3", + "iPadMini3Cellular-iPadMini3Cellular", + "iPodTouchSixthGen-iPodTouchSixthGen", + "iPhone6s-iPhone6s", + "iPhone6sPlus-iPhone6sPlus", + "iPadMini4-iPadMini4", + "iPadMini4Cellular-iPadMini4Cellular", + "iPadPro-iPadPro", + "iPadProCellular-iPadProCellular", + "iPadPro97-iPadPro97", + "iPadPro97Cellular-iPadPro97Cellular", + "iPhoneSE-iPhoneSE", + "iPhone7-iPhone7", + "iPhone7Plus-iPhone7Plus", + "iPad611-iPad611", + "iPad612-iPad612", + "iPad71-iPad71", + "iPad72-iPad72", + "iPad73-iPad73", + "iPad74-iPad74", + "iPhone8-iPhone8", + "iPhone8Plus-iPhone8Plus", + "iPhoneX-iPhoneX", + "iPad75-iPad75", + "iPad76-iPad76", + "iPhoneXS-iPhoneXS", + "iPhoneXSMax-iPhoneXSMax", + "iPhoneXR-iPhoneXR", + "iPad812-iPad812", + "iPad834-iPad834", + "iPad856-iPad856", + "iPad878-iPad878", + "iPadMini5-iPadMini5", + "iPadMini5Cellular-iPadMini5Cellular", + "iPadAir3-iPadAir3", + "iPadAir3Cellular-iPadAir3Cellular", + "iPodTouchSeventhGen-iPodTouchSeventhGen", + "iPhone11-iPhone11", + "iPhone11Pro-iPhone11Pro", + "iPadSeventhGen-iPadSeventhGen", + "iPadSeventhGenCellular-iPadSeventhGenCellular", + "iPhone11ProMax-iPhone11ProMax", + "iPhoneSESecondGen-iPhoneSESecondGen", + "iPadProSecondGen-iPadProSecondGen", + "iPadProSecondGenCellular-iPadProSecondGenCellular", + "iPadProFourthGen-iPadProFourthGen", + "iPadProFourthGenCellular-iPadProFourthGenCellular", + "iPhone12Mini-iPhone12Mini", + "iPhone12-iPhone12", + "iPhone12Pro-iPhone12Pro", + "iPhone12ProMax-iPhone12ProMax", + "iPadAir4-iPadAir4", + "iPadAir4Cellular-iPadAir4Cellular", + "iPadEighthGen-iPadEighthGen", + "iPadEighthGenCellular-iPadEighthGenCellular", + "iPadProThirdGen-iPadProThirdGen", + "iPadProThirdGenCellular-iPadProThirdGenCellular", + "iPadProFifthGen-iPadProFifthGen", + "iPadProFifthGenCellular-iPadProFifthGenCellular", + "iPhone13Pro-iPhone13Pro", + "iPhone13ProMax-iPhone13ProMax", + "iPhone13Mini-iPhone13Mini", + "iPhone13-iPhone13", + "iPadMiniSixthGen-iPadMiniSixthGen", + "iPadMiniSixthGenCellular-iPadMiniSixthGenCellular", + "iPadNinthGen-iPadNinthGen", + "iPadNinthGenCellular-iPadNinthGenCellular", + "iPhoneSEThirdGen-iPhoneSEThirdGen", + "iPadAirFifthGen-iPadAirFifthGen", + "iPadAirFifthGenCellular-iPadAirFifthGenCellular", + "iPhone14-iPhone14", + "iPhone14Plus-iPhone14Plus", + "iPhone14Pro-iPhone14Pro", + "iPhone14ProMax-iPhone14ProMax", + "iPadTenthGen-iPadTenthGen", + "iPadTenthGenCellular-iPadTenthGenCellular", + "iPadPro11FourthGen-iPadPro11FourthGen", + "iPadPro11FourthGenCellular-iPadPro11FourthGenCellular", + "iPadProSixthGen-iPadProSixthGen", + "iPadProSixthGenCellular-iPadProSixthGenCellular", + "iPhone15-iPhone15", + "iPhone15Plus-iPhone15Plus", + "iPhone15Pro-iPhone15Pro", + "iPhone15ProMax-iPhone15ProMax", + "iPadAir11M2-iPadAir11M2", + "iPadAir11M2Cellular-iPadAir11M2Cellular", + "iPadAir13M2-iPadAir13M2", + "iPadAir13M2Cellular-iPadAir13M2Cellular", + "iPadPro11M4-iPadPro11M4", + "iPadPro11M4Cellular-iPadPro11M4Cellular", + "iPadPro13M4-iPadPro13M4", + "iPadPro13M4Cellular-iPadPro13M4Cellular", + "iPhone16-iPhone16", + "iPhone16Plus-iPhone16Plus", + "iPhone16Pro-iPhone16Pro", + "iPhone16ProMax-iPhone16ProMax", + "iPadMiniA17Pro-iPadMiniA17Pro", + "iPadMiniA17ProCellular-iPadMiniA17ProCellular", + "iPhone16e-iPhone16e", + "iPadA16-iPadA16", + "iPadA16Cellular-iPadA16Cellular", + "iPadAir11M3-iPadAir11M3", + "iPadAir11M3Cellular-iPadAir11M3Cellular", + "iPadAir13M3-iPadAir13M3", + "iPadAir13M3Cellular-iPadAir13M3Cellular", + "iPhone17Pro-iPhone17Pro", + "iPhone17ProMax-iPhone17ProMax", + "iPhone17-iPhone17", + "iPhoneAir-iPhoneAir", + "iPadPro11M5-iPadPro11M5", + "iPadPro11M5Cellular-iPadPro11M5Cellular", + "iPadPro13M5-iPadPro13M5", + "iPadPro13M5Cellular-iPadPro13M5Cellular", + "iPhone17e-iPhone17e", + "iPadAir11M4-iPadAir11M4", + "iPadAir11M4Cellular-iPadAir11M4Cellular", + "iPadAir13M4-iPadAir13M4", + "iPadAir13M4Cellular-iPadAir13M4Cellular", + "iPhone18Pro-iPhone18Pro", + "iPhone18ProMax-iPhone18ProMax", + "iPhoneDuo-iPhoneDuo" + ], + "advisories": [], + "kind": "software", + "artistViewUrl": "https://apps.apple.com/us/developer/lovecast-llc/id1498017204?uo=4", + "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple221/v4/89/b3/29/89b32922-c086-9d30-fd7c-ac48e1392aa6/AppIcon-0-0-1x_U007epad-0-1-85-220.png/60x60bb.jpg", + "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple221/v4/89/b3/29/89b32922-c086-9d30-fd7c-ac48e1392aa6/AppIcon-0-0-1x_U007epad-0-1-85-220.png/100x100bb.jpg", + "appletvScreenshotUrls": [], + "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple221/v4/89/b3/29/89b32922-c086-9d30-fd7c-ac48e1392aa6/AppIcon-0-0-1x_U007epad-0-1-85-220.png/512x512bb.jpg", + "screenshotUrls": [ + "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource211/v4/7d/c0/9e/7dc09e7d-e1df-c528-41c6-2eff0bac4b9b/01-terminal.png/320x480bb.jpg", + "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource221/v4/93/6f/1f/936f1fe8-a0fc-cfaa-2ecb-89d7c71f02f3/03-homepage.png/320x480bb.jpg", + "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource221/v4/d6/e2/9f/d6e29f00-bdd8-c29c-f533-97a4a217be03/02-workspace.png/320x480bb.jpg", + "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource211/v4/3e/82/15/3e8215b7-80db-f4b3-3942-bc634899c394/04-create-wt.png/320x480bb.jpg" + ], + "ipadScreenshotUrls": [ + "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource221/v4/e5/01/a2/e501a2f6-8e2c-11a2-ad6a-c9b42f49e905/apple-ipadpro13-spacegrey-portrait-promo-opaque.png/360x480bb.jpg" + ], + "releaseNotes": "Clearer chat while agents work\n• A proposed plan now appears as a plan you can read, not a generic approval prompt.\n• Approval requests show what the agent actually asked for instead of raw tool data.\n• The working timer counts from the message that started the turn.\n\nSteadier workspace setup\n• Creating a workspace no longer risks starting two if the reply is lost on the way back.\n• A worktree that launches an agent on creation opens straight into its chat.\n• A newly created tab stays put when the host list refreshes.\n\nFixes\n• Pairing no longer reports a declined permission as a missing feature.\n• The notification opt-in screen is clearer about what you are turning on.\n• Sign-in now accepts enterprise-managed GitHub accounts.", + "bundleId": "com.stably.orca.mobile", + "genres": ["Developer Tools"], + "price": 0.0, + "artistId": 1498017204, + "artistName": "Lovecast LLC", + "primaryGenreName": "Developer Tools", + "primaryGenreId": 6026, + "releaseDate": "2026-05-07T07:00:00Z", + "isVppDeviceBasedLicensingEnabled": true, + "trackName": "Orca IDE", + "trackId": 6766130217, + "sellerName": "Lovecast LLC", + "genreIds": ["6026"], + "currentVersionReleaseDate": "2026-09-19T07:09:57Z", + "version": "0.0.51", + "wrapperType": "software", + "currency": "USD", + "description": "Orca Mobile is the companion app for Orca, the open-source agentic coding workspace for macOS, Linux, and Windows. Pair your iPhone to your desktop in seconds and pick up a terminal session from the couch, the kitchen, or across the office.\n\nWHAT IT DOES\n\n• Live mirror of your desktop terminal, including running AI agent sessions (Claude Code, Codex, custom CLIs).\n• Full keyboard input — type, tab-complete, Ctrl-C, scroll back, all from the phone.\n• Multi-worktree, multi-terminal: jump between projects with a tap.\n• Phone-fit zoom and a smart on-screen keyboard with quick Ctrl/Alt accessory keys and shortcuts.\n• Background long-running commands and get a notification when a TUI goes idle (e.g. when Claude finishes thinking).\n\nHOW IT WORKS\n\nOrca Mobile is a thin client. Nothing runs on your phone except the viewer. Your code, your shells, your AI agents — all of that stays on your desktop. The phone shows you what's happening and forwards your keystrokes back over an encrypted local-network connection.\n\nPairing is end-to-end encrypted. Your desktop generates a one-time keypair and shows a QR code; you scan it from the app. After that, all traffic between phone and desktop is sealed with that pair's keys.\n\nPRIVACY\n\n• No analytics SDK, no crash reporter, no third-party SDKs of any kind in the iOS app.\n• Pairing tokens and your preference list live in the app's own sandbox. The app's traffic to your desktop is end-to-end encrypted on your local network only.\n• The Orca desktop app collects anonymized usage metrics per the policy at https://www.onorca.dev/privacy. The mobile app itself does not.\n• Open source: every line is auditable at https://github.com/stablyai/orca.\n\nREQUIREMENTS\n\n• An iPhone (iOS 16+) on the same Wi-Fi as your desktop.\n• The Orca desktop app, free, from https://www.onorca.dev/ or https://github.com/stablyai/orca/releases.", + "trackCensoredName": "Orca IDE", + "trackViewUrl": "https://apps.apple.com/us/app/orca-ide/id6766130217?uo=4", + "contentAdvisoryRating": "4+", + "averageUserRating": 4.58621000000000034191316444776020944118499755859375, + "averageUserRatingForCurrentVersion": 4.58621000000000034191316444776020944118499755859375, + "languageCodesISO2A": ["EN"], + "fileSizeBytes": "41495552", + "formattedPrice": "Free", + "userRatingCountForCurrentVersion": 29, + "trackContentRating": "4+", + "sellerUrl": "https://www.onorca.dev/", + "minimumOsVersion": "15.1", + "userRatingCount": 29 + } + ] +} diff --git a/mobile/src/app-update/github-release-update-source.ts b/mobile/src/app-update/github-release-update-source.ts new file mode 100644 index 00000000000..c32c3475121 --- /dev/null +++ b/mobile/src/app-update/github-release-update-source.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { compareAppVersions } from '../../../src/shared/app-version' +import type { AppUpdateCheckResult, AppUpdateSource } from './app-update-source' +import { isNewerReleaseVersion } from './app-update-source' + +const REPO_API = 'https://api.github.com/repos/stablyai/orca' +// Why tag refs, not releases.atom or /releases?per_page=100: both are newest-first windows that a +// run of desktop releases fills, pushing the newest mobile release out and reading as "current". +// The release `prerelease` flag is not a filter: every mobile-android-v* release is published as one. +const TAG_PREFIX = 'mobile-android-v' +// Why: the release workflow pushes the tag before the APK build, so a failed build leaves a +// tag with no release; probe a few older candidates, bounded like the desktop's manifest probe. +const MAX_RELEASE_PROBES = 3 + +type Fetch = typeof fetch + +const tagRefsSchema = z.array(z.looseObject({ ref: z.string() })) +const installableReleaseSchema = z.looseObject({ + draft: z.literal(false), + html_url: z.string(), + assets: z.array(z.looseObject({ name: z.string() })) +}) + +/** Versions named by the tag refs GitHub returns for the mobile-android prefix. */ +export function parseMobileAndroidTagVersions(refs: unknown): string[] { + const prefix = `refs/tags/${TAG_PREFIX}` + return tagRefsSchema + .parse(refs) + .filter(({ ref }) => ref.startsWith(prefix)) + .map(({ ref }) => ref.slice(prefix.length)) +} + +/** The release page when the tag has a published release carrying an APK, else null. */ +export function installableReleaseUrl(release: unknown): string | null { + const parsed = installableReleaseSchema.safeParse(release) + return parsed.success && parsed.data.assets.some((asset) => asset.name.endsWith('.apk')) + ? parsed.data.html_url + : null +} + +export function createGithubReleaseUpdateSource(fetchImpl: Fetch): AppUpdateSource { + return { + async check(installedVersion, signal): Promise { + const refsReply = await fetchImpl(`${REPO_API}/git/matching-refs/tags/${TAG_PREFIX}`, { + signal + }) + if (!refsReply.ok) { + throw new Error(`tag refs HTTP ${refsReply.status}`) + } + const candidates = parseMobileAndroidTagVersions(await refsReply.json()) + .filter((version) => isNewerReleaseVersion(version, installedVersion)) + .sort((left, right) => compareAppVersions(right, left)) + .slice(0, MAX_RELEASE_PROBES) + for (const version of candidates) { + const releaseReply = await fetchImpl( + `${REPO_API}/releases/tags/${encodeURIComponent(`${TAG_PREFIX}${version}`)}`, + { signal } + ) + if (releaseReply.status === 404) { + continue + } + if (!releaseReply.ok) { + throw new Error(`release HTTP ${releaseReply.status}`) + } + const url = installableReleaseUrl(await releaseReply.json()) + if (url) { + return { kind: 'available', version, url } + } + } + return { kind: 'current' } + } + } +} + +export const githubReleaseUpdateSource = createGithubReleaseUpdateSource((input, init) => + fetch(input, init) +) diff --git a/mobile/src/home/AppUpdateCard.test.tsx b/mobile/src/home/AppUpdateCard.test.tsx new file mode 100644 index 00000000000..7a98a91f91f --- /dev/null +++ b/mobile/src/home/AppUpdateCard.test.tsx @@ -0,0 +1,50 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AppUpdateCard } from './AppUpdateCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ ArrowUpFromLine: 'ArrowUpFromLine', X: 'X' })) + +describe('AppUpdateCard', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render() { + const onPress = vi.fn() + const onDismiss = vi.fn() + act(() => { + renderer = create(createElement(AppUpdateCard, { version: '0.0.51', onPress, onDismiss })) + }) + const root = renderer!.root + const texts = root + .findAll((node) => String(node.type) === 'Text') + .flatMap((node) => node.children.filter((child) => typeof child === 'string')) + return { root, texts, onPress, onDismiss } + } + + it('says which version is available and how to get it, with a monochrome glyph', () => { + const { root, texts } = render() + expect(texts).toEqual(['Orca 0.0.51 is available', 'Tap to update']) + expect(root.find((node) => String(node.type) === 'ArrowUpFromLine').props.color).toBe('#e0e0e0') + }) + + it('opens the update from the card body and dismisses from the X', () => { + const { root, onPress, onDismiss } = render() + const [body, dismiss] = root.findAll((node) => String(node.type) === 'Pressable') + expect(dismiss.props.accessibilityLabel).toBe('Dismiss Orca 0.0.51 update') + act(() => body.props.onPress()) + expect(onPress).toHaveBeenCalledTimes(1) + expect(onDismiss).not.toHaveBeenCalled() + act(() => dismiss.props.onPress()) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/home/AppUpdateCard.tsx b/mobile/src/home/AppUpdateCard.tsx new file mode 100644 index 00000000000..6713bcbfd12 --- /dev/null +++ b/mobile/src/home/AppUpdateCard.tsx @@ -0,0 +1,86 @@ +import { ArrowUpFromLine, X } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing } from '../theme/mobile-theme' + +/** Home's update notice, drawn in the host card's frame so it reads as part of that list. */ +export function AppUpdateCard(props: { + version: string + onPress: () => void + onDismiss: () => void +}) { + const title = `Orca ${props.version} is available` + return ( + + [styles.cardMain, pressed && styles.cardPressed]} + onPress={props.onPress} + > + + + + + + {title} + + + Tap to update + + + + [styles.dismissButton, pressed && styles.dismissPressed]} + onPress={props.onDismiss} + > + + + + ) +} + +// Same frame, icon well and spacing as MobileHostCard. +const styles = StyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + overflow: 'hidden' + }, + cardMain: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: spacing.md, + paddingVertical: 12 + }, + cardPressed: { backgroundColor: colors.bgRaised }, + icon: { + width: 46, + height: 46, + borderRadius: 13, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginRight: 14 + }, + main: { flex: 1, minWidth: 0, marginRight: spacing.sm }, + title: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 }, + subtitle: { color: colors.textSecondary, fontSize: 12, lineHeight: 16 }, + dismissButton: { + width: 40, + height: 40, + marginHorizontal: spacing.xs, + borderRadius: radii.row, + alignItems: 'center', + justifyContent: 'center' + }, + dismissPressed: { backgroundColor: colors.bgRaised } +}) diff --git a/mobile/src/home/MobileHomeAppUpdateCard.tsx b/mobile/src/home/MobileHomeAppUpdateCard.tsx new file mode 100644 index 00000000000..51bc41d8602 --- /dev/null +++ b/mobile/src/home/MobileHomeAppUpdateCard.tsx @@ -0,0 +1,27 @@ +import { StyleSheet, View } from 'react-native' +import { undismissedAppUpdate } from '../app-update/app-update-checker' +import { + appUpdateChecker, + openAppUpdate, + useAppUpdateState +} from '../app-update/app-update-runtime' +import { AppUpdateCard } from './AppUpdateCard' +import { spacing } from '../theme/mobile-theme' + +export function MobileHomeAppUpdateCard() { + const available = undismissedAppUpdate(useAppUpdateState()) + if (!available) { + return null + } + return ( + + openAppUpdate(available.url)} + onDismiss={() => appUpdateChecker.dismiss(available.version)} + /> + + ) +} + +const styles = StyleSheet.create({ slot: { marginBottom: spacing.lg } }) diff --git a/mobile/src/home/MobileHomeListHeader.tsx b/mobile/src/home/MobileHomeListHeader.tsx index 7e47c9ae9d6..f7da47c9733 100644 --- a/mobile/src/home/MobileHomeListHeader.tsx +++ b/mobile/src/home/MobileHomeListHeader.tsx @@ -1,6 +1,7 @@ import { StyleSheet, Text, View } from 'react-native' import type { HomeStatsSummary } from '../stats/home-stats-total' import { colors, spacing } from '../theme/mobile-theme' +import { MobileHomeAppUpdateCard } from './MobileHomeAppUpdateCard' function formatDuration(ms: number): string { const totalMinutes = Math.floor(ms / 60_000) @@ -36,6 +37,7 @@ export function MobileHomeListHeader({ stats }: { stats: HomeStatsSummary | null ) : null} + Desktops ) diff --git a/mobile/src/settings/app-update-settings-rows.test.tsx b/mobile/src/settings/app-update-settings-rows.test.tsx new file mode 100644 index 00000000000..77177bf7d63 --- /dev/null +++ b/mobile/src/settings/app-update-settings-rows.test.tsx @@ -0,0 +1,90 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AppUpdateSettingsRows, type AppUpdateCheckRowStatus } from './app-update-settings-rows' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +const NOW = Date.UTC(2026, 8, 28, 12) +const HOUR = 60 * 60 * 1000 + +describe('AppUpdateSettingsRows', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(opts: { + available?: { version: string; url: string } | null + lastCheckedAt?: number | null + checkStatus?: AppUpdateCheckRowStatus + }) { + const onUpdate = vi.fn() + const onCheck = vi.fn() + act(() => { + renderer = create( + createElement(AppUpdateSettingsRows, { + installedVersion: '0.0.48', + available: opts.available ?? null, + lastCheckedAt: opts.lastCheckedAt ?? null, + now: NOW, + checkStatus: opts.checkStatus ?? 'idle', + onUpdate, + onCheck + }) + ) + }) + const root = renderer!.root + const texts = root + .findAll((node) => String(node.type) === 'Text') + .map((node) => node.children.filter((child) => typeof child === 'string').join('')) + return { + root, + texts, + onUpdate, + onCheck, + pressables: root.findAll((node) => String(node.type) === 'Pressable') + } + } + + it('offers the known update as a tappable row with an Update action', () => { + const { texts, pressables, onUpdate } = render({ + available: { version: '0.0.51', url: 'https://example.test' }, + lastCheckedAt: NOW - 2 * HOUR + }) + expect(texts).toEqual([ + 'Update to Orca 0.0.51', + 'Update', + 'Check for updates', + 'Last checked 2h ago' + ]) + act(() => pressables[0].props.onPress()) + expect(onUpdate).toHaveBeenCalledTimes(1) + }) + + it('shows a plain, untappable version row when current, and Never before a first success', () => { + const { texts, pressables } = render({}) + expect(texts).toEqual(['Version 0.0.48', 'Check for updates', 'Never']) + expect(pressables).toHaveLength(1) + expect(pressables[0].props.accessibilityLabel).toBe('Check for updates, Never') + }) + + it('runs the check from its row and shows the transient outcome in place', () => { + const idle = render({ lastCheckedAt: NOW - 30_000 }) + expect(idle.texts.at(-1)).toBe('Last checked just now') + act(() => idle.pressables[0].props.onPress()) + expect(idle.onCheck).toHaveBeenCalledTimes(1) + act(() => renderer?.unmount()) + expect(render({ checkStatus: 'checking' }).pressables[0].props.disabled).toBe(true) + act(() => renderer?.unmount()) + expect(render({ checkStatus: 'up-to-date' }).texts.at(-1)).toBe('Up to date') + act(() => renderer?.unmount()) + expect(render({ checkStatus: 'failed' }).texts.at(-1)).toBe("Couldn't check") + }) +}) diff --git a/mobile/src/settings/app-update-settings-rows.tsx b/mobile/src/settings/app-update-settings-rows.tsx new file mode 100644 index 00000000000..b605a89105b --- /dev/null +++ b/mobile/src/settings/app-update-settings-rows.tsx @@ -0,0 +1,100 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import type { KnownAppUpdate } from '../storage/app-update-preferences' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { formatTimeAgo } from '../worktree/agent-row-display' + +export type AppUpdateCheckRowStatus = 'idle' | 'checking' | 'up-to-date' | 'failed' + +function checkRowValue(status: AppUpdateCheckRowStatus, lastCheckedAt: number | null, now: number) { + if (status === 'checking') { + return 'Checking…' + } + if (status === 'up-to-date') { + return 'Up to date' + } + if (status === 'failed') { + return "Couldn't check" + } + if (lastCheckedAt === null) { + return 'Never' + } + const ago = formatTimeAgo(lastCheckedAt, now) + return `Last checked ${ago === 'just now' ? ago : `${ago} ago`}` +} + +/** Settings' version row and manual check; the update row stays after the home card is dismissed. */ +export function AppUpdateSettingsRows(props: { + installedVersion: string | null + available: KnownAppUpdate | null + lastCheckedAt: number | null + now: number + checkStatus: AppUpdateCheckRowStatus + onUpdate: (url: string) => void + onCheck: () => void +}) { + const checkValue = checkRowValue(props.checkStatus, props.lastCheckedAt, props.now) + const { available, installedVersion, onUpdate } = props + const versionRow = available ? ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => onUpdate(available.url)} + > + Update to Orca {available.version} + Update + + ) : installedVersion ? ( + + Version {installedVersion} + + ) : null + return ( + + {versionRow} + {versionRow && } + [styles.row, pressed && styles.rowPressed]} + onPress={props.onCheck} + > + Check for updates + {checkValue} + + + ) +} + +// Same panel, rows and separator as MobileSettingsSection. +const styles = StyleSheet.create({ + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden', + marginTop: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { backgroundColor: colors.bgRaised }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowValue: { fontSize: typography.metaSize, color: colors.textSecondary }, + actionValue: { fontSize: typography.bodySize, fontWeight: '600', color: colors.textPrimary }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/settings/settings-app-update-section.tsx b/mobile/src/settings/settings-app-update-section.tsx new file mode 100644 index 00000000000..4edc93d95a9 --- /dev/null +++ b/mobile/src/settings/settings-app-update-section.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { + appUpdateChecker, + installedAppVersion, + openAppUpdate, + useAppUpdateState +} from '../app-update/app-update-runtime' +import { AppUpdateSettingsRows, type AppUpdateCheckRowStatus } from './app-update-settings-rows' + +const CHECK_RESULT_VISIBLE_MS = 3000 +const RELATIVE_TIME_TICK_MS = 60_000 + +export function SettingsAppUpdateSection() { + const state = useAppUpdateState() + const [result, setResult] = useState<'up-to-date' | 'failed' | null>(null) + // A check newer than this reads as "just now", so the tick only has to age the label. + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const tick = setInterval(() => setNow(Date.now()), RELATIVE_TIME_TICK_MS) + return () => clearInterval(tick) + }, []) + + useEffect(() => { + if (result === null) { + return + } + const hide = setTimeout(() => setResult(null), CHECK_RESULT_VISIBLE_MS) + return () => clearTimeout(hide) + }, [result]) + + const runCheck = () => { + setResult(null) + void appUpdateChecker.checkNow().then((outcome) => { + // An available update shows in the row above, so only the other outcomes need words here. + setResult(outcome === 'available' ? null : outcome) + }) + } + + const checkStatus: AppUpdateCheckRowStatus = state.checking ? 'checking' : (result ?? 'idle') + return ( + + ) +} diff --git a/mobile/src/storage/app-update-preferences.test.ts b/mobile/src/storage/app-update-preferences.test.ts new file mode 100644 index 00000000000..dbd7ba4215d --- /dev/null +++ b/mobile/src/storage/app-update-preferences.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { loadAppUpdatePreferences } from './app-update-preferences' + +const stored = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + multiGet: async (keys: string[]) => keys.map((key) => [key, stored.get(key) ?? null]) + } +})) + +describe('loadAppUpdatePreferences', () => { + it('drops only a corrupt update record, keeping the check time and dismissal beside it', async () => { + stored.set('orca:appUpdate:lastCheckedAt', '1700000000000') + stored.set('orca:appUpdate:latest', '{not json') + stored.set('orca:appUpdate:dismissedVersion', '0.0.51') + await expect(loadAppUpdatePreferences()).resolves.toEqual({ + lastCheckedAt: 1700000000000, + latest: null, + dismissedVersion: '0.0.51' + }) + }) + + it('reads a well-formed update record', async () => { + stored.set( + 'orca:appUpdate:latest', + JSON.stringify({ version: '0.0.52', url: 'https://x.test' }) + ) + await expect(loadAppUpdatePreferences()).resolves.toMatchObject({ + latest: { version: '0.0.52', url: 'https://x.test' } + }) + }) +}) diff --git a/mobile/src/storage/app-update-preferences.ts b/mobile/src/storage/app-update-preferences.ts new file mode 100644 index 00000000000..51e199a68b1 --- /dev/null +++ b/mobile/src/storage/app-update-preferences.ts @@ -0,0 +1,64 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { z } from 'zod' + +const LAST_CHECKED_AT_KEY = 'orca:appUpdate:lastCheckedAt' +const LATEST_KEY = 'orca:appUpdate:latest' +const DISMISSED_VERSION_KEY = 'orca:appUpdate:dismissedVersion' + +export type KnownAppUpdate = { readonly version: string; readonly url: string } + +export type AppUpdatePreferences = { + /** Last successful check; failures never move it. */ + readonly lastCheckedAt: number | null + /** Newest installable release that check saw, or null when it saw none. */ + readonly latest: KnownAppUpdate | null + readonly dismissedVersion: string | null +} + +export const EMPTY_APP_UPDATE_PREFERENCES: AppUpdatePreferences = { + lastCheckedAt: null, + latest: null, + dismissedVersion: null +} + +const knownAppUpdateSchema = z.object({ version: z.string(), url: z.string() }) + +// A corrupt record loses only itself, never the check time or the dismissal stored beside it. +function parseLatest(raw: string | null): KnownAppUpdate | null { + try { + const parsed = raw ? knownAppUpdateSchema.safeParse(JSON.parse(raw)) : null + return parsed?.success ? parsed.data : null + } catch { + return null + } +} + +export async function loadAppUpdatePreferences(): Promise { + try { + const [[, checkedAt], [, latest], [, dismissed]] = await AsyncStorage.multiGet([ + LAST_CHECKED_AT_KEY, + LATEST_KEY, + DISMISSED_VERSION_KEY + ]) + const lastCheckedAt = checkedAt === null ? null : Number(checkedAt) + return { + lastCheckedAt: + lastCheckedAt !== null && Number.isFinite(lastCheckedAt) ? lastCheckedAt : null, + latest: parseLatest(latest), + dismissedVersion: dismissed + } + } catch { + return EMPTY_APP_UPDATE_PREFERENCES + } +} + +export async function saveAppUpdateCheck(checkedAt: number, latest: KnownAppUpdate | null) { + await AsyncStorage.multiSet([ + [LAST_CHECKED_AT_KEY, String(checkedAt)], + [LATEST_KEY, latest ? JSON.stringify(latest) : ''] + ]) +} + +export async function saveDismissedAppUpdateVersion(version: string): Promise { + await AsyncStorage.setItem(DISMISSED_VERSION_KEY, version) +} From 85ad2929309ec01d24d3cd3fe7a357deeb8e83e3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:23:28 -0700 Subject: [PATCH 325/387] fix(status-bar): re-measure collapsing levels when only the collapsed width moves (#23773) --- .../status-bar/status-bar-density.test.ts | 15 +++++++++------ .../components/status-bar/status-bar-density.ts | 12 ++++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/status-bar/status-bar-density.test.ts b/src/renderer/src/components/status-bar/status-bar-density.test.ts index 27b905e962d..668f5c90a9e 100644 --- a/src/renderer/src/components/status-bar/status-bar-density.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-density.test.ts @@ -59,12 +59,15 @@ describe('recordStatusBarDensityWidth', () => { ]) }) - it('keeps other levels when only the collapsed width moves', () => { - // Collapsing different chips changes the fit width, never the natural one. - expect(recordStatusBarDensityWidth([w(700), w(600, 450)], 1, w(600, 430))).toEqual([ - w(700), - w(600, 430) - ]) + it('drops only the other collapsing levels when only the collapsed width moves', () => { + // Urgency changes which chips collapse, moving fit widths but never the natural one. + expect( + recordStatusBarDensityWidth( + [w(700), w(600), w(500), w(600, 450), w(500, 400)], + 3, + w(600, 430) + ) + ).toEqual([w(700), w(600), w(500), w(600, 430), undefined]) }) it('keeps other levels on a sub-pixel re-measure', () => { diff --git a/src/renderer/src/components/status-bar/status-bar-density.ts b/src/renderer/src/components/status-bar/status-bar-density.ts index 17748c19d45..6801d910358 100644 --- a/src/renderer/src/components/status-bar/status-bar-density.ts +++ b/src/renderer/src/components/status-bar/status-bar-density.ts @@ -56,9 +56,17 @@ export function recordStatusBarDensityWidth( width: StatusBarLevelWidth ): (StatusBarLevelWidth | undefined)[] { const previous = levelWidths[level] - const next = + const naturalChanged = previous !== undefined && Math.abs(previous.natural - width.natural) > WIDTH_TOLERANCE_PX - ? [] + // Why: urgency can change which chips pin without moving the natural width. + const fitChanged = + previous !== undefined && Math.abs(previous.fit - width.fit) > WIDTH_TOLERANCE_PX + const next = naturalChanged + ? [] + : fitChanged + ? levelWidths.map((entry, index) => + STATUS_BAR_DENSITY_LEVELS[index]?.collapseUsage ? undefined : entry + ) : [...levelWidths] next[level] = width return next From a5ce8251e3bf515636d3e949459e84594b9b88ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:33:59 -0700 Subject: [PATCH 326/387] Agent launches carry the surface that started them (#23697) * feat(agent-launch): every launch carries the surface that started it The host now attributes every agent it builds to the surface that asked for it, resolving a missing or unrecognized surface to 'unknown' in one place instead of silently skipping it. The CLI names itself on worktree.create and orchestration workers name themselves host-side. * fix(agent-launch): attribute the agent a startup-draft create launches The host builds a third kind of agent launch: a worktree.create with a startupDraft and no startupAgent, where the host picks the agent itself. It carried no launch record at all and ignored the caller's launchSource. Route it through the same resolver as the other two builders, and derive the startupAgent terminal record only from the resolver so no prebuilt record can stand in for it. * fix(agent-launch): attribute the agent a host-built agent session launches terminal.createAgentSession builds a fresh agent's launch on the host, like the other startup builders, but spawned it with no launch record, so those launches were never counted. Record them through the same resolver; the request names no surface, so they count as unknown. * test(agent-launch): require an attribution decision for every host-built agent startup --- src/cli/handlers/worktree.ts | 3 +- src/cli/index-worktree-create-agent.test.ts | 6 +- .../host-agent-startup-call-sites.txt | 10 ++ .../agent-launch/agent-started-telemetry.ts | 30 ++++++ .../host-agent-startup-attribution.test.ts | 99 +++++++++++++++++++ .../orca-runtime-activate-managed-worktree.ts | 4 +- .../orca-runtime-create-agent-session.ts | 3 + .../orca-runtime-create-managed-worktree.ts | 7 +- ...runtime-resolve-worktree-removal-target.ts | 5 +- ...nal-creation-and-readiness-part-12.spec.ts | 70 +++++++++++++ ...worktree-setup-and-startup-part-04.spec.ts | 5 +- .../rpc/methods/agent-launch-surfaces.ts | 34 +------ .../runtime/rpc/methods/agent-launch.test.ts | 36 +++++-- .../orchestration/federation/federation.ts | 2 + .../worker/composed-workers.test.ts | 1 + .../orchestration/worker/worker-topology.ts | 1 + .../worker/worker-worktree-creation.ts | 1 + .../worker/workers-new-worktree.test.ts | 1 + .../rpc/methods/worktree-create-args.ts | 1 + .../worktree-create-launch-source.test.ts | 41 ++++++++ .../runtime-managed-worktree-create-types.ts | 1 + .../runtime/runtime-terminal-contracts.ts | 3 + .../runtime-worktree-agent-startup.test.ts | 45 +++++++++ .../runtime/runtime-worktree-agent-startup.ts | 40 ++++---- src/shared/agent-launch-intent.ts | 4 +- .../rpc-contract/agent-launch-params.ts | 12 +-- .../rpc-contract/launch-source-param.ts | 12 +++ .../rpc-contract/worktree-create-params.ts | 3 + src/shared/telemetry-property-schemas.ts | 4 + 29 files changed, 401 insertions(+), 83 deletions(-) create mode 100644 src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt create mode 100644 src/main/agent-launch/agent-started-telemetry.ts create mode 100644 src/main/agent-launch/host-agent-startup-attribution.test.ts create mode 100644 src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts create mode 100644 src/shared/rpc-contract/launch-source-param.ts diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 62ddd27155a..3541beef20b 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -245,7 +245,8 @@ export const WORKTREE_HANDLERS: Record = { ...(startupAgent ? { startupAgent, - startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '' + startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', + launchSource: 'cli' } : {}) }) diff --git a/src/cli/index-worktree-create-agent.test.ts b/src/cli/index-worktree-create-agent.test.ts index f1d79ef21df..6ae1266b91c 100644 --- a/src/cli/index-worktree-create-agent.test.ts +++ b/src/cli/index-worktree-create-agent.test.ts @@ -138,7 +138,8 @@ describe('orca cli worktree awareness', () => { callerTerminalHandle: undefined, cliProvenanceRequest: {}, startupAgent: 'codex', - startupPrompt: 'hi' + startupPrompt: 'hi', + launchSource: 'cli' }) }) @@ -187,7 +188,8 @@ describe('orca cli worktree awareness', () => { callerTerminalHandle: undefined, cliProvenanceRequest: {}, startupAgent: 'codex', - startupPrompt: 'hi' + startupPrompt: 'hi', + launchSource: 'cli' }) }) diff --git a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt new file mode 100644 index 00000000000..de078fd731e --- /dev/null +++ b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt @@ -0,0 +1,10 @@ +# Host-side calls to the agent startup-plan builders, per file: . +# attributes a fresh agent the host builds; carries agentStartedTelemetry +# resume continues an existing agent session; not a new start +# mobile-followup the phone's session-tab launch; attribution is a separate follow-up +# The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed. +src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes +src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes +src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes +src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume +src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup diff --git a/src/main/agent-launch/agent-started-telemetry.ts b/src/main/agent-launch/agent-started-telemetry.ts new file mode 100644 index 00000000000..2d1ff76179d --- /dev/null +++ b/src/main/agent-launch/agent-started-telemetry.ts @@ -0,0 +1,30 @@ +/** + * The `agent_started` triple for an agent launch the host builds, of which only `launch_source` + * comes from the caller. + * + * `agent_kind` and `request_kind` are derived rather than accepted — the host already knows both, + * and a value it derives is a value a caller cannot misreport. `request_kind` is always `new` + * because a host-built launch is always a fresh agent. + * + * Every launch is attributed: a caller that names no surface, or one this build has never heard of + * (a newer client), is recorded as `unknown` rather than dropped. The wire keeps the arm set open so + * an older host cannot refuse a newer client's launch over a label, and resolving it here, leniently, + * keeps that label from gating the launch at the point it is actually used. + */ + +import { tuiAgentToAgentKind } from '../../shared/agent-kind' +import { launchSourceSchema } from '../../shared/telemetry-property-schemas' +import type { TuiAgent } from '../../shared/tui-agent' +import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' + +export function agentStartedTelemetry( + agent: TuiAgent, + launchSource: string | undefined +): NonNullable { + const parsed = launchSourceSchema.safeParse(launchSource) + return { + agent_kind: tuiAgentToAgentKind(agent), + launch_source: parsed.success ? parsed.data : 'unknown', + request_kind: 'new' + } +} diff --git a/src/main/agent-launch/host-agent-startup-attribution.test.ts b/src/main/agent-launch/host-agent-startup-attribution.test.ts new file mode 100644 index 00000000000..612caab8001 --- /dev/null +++ b/src/main/agent-launch/host-agent-startup-attribution.test.ts @@ -0,0 +1,99 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Every host-side call that builds an agent's startup command has made an attribution decision. + * + * `agent_started` is recorded per call site, and twice a new host builder shipped without one, so + * that agent's launches were silently uncounted. A new call, or another call in a listed file, + * fails here until its author decides and records whether that launch is a fresh start. + */ +const DECISIONS = ['attributes', 'resume', 'mobile-followup'] as const + +const LISTED: ReadonlyMap = new Map( + readFileSync(join(__dirname, '__fixtures__', 'host-agent-startup-call-sites.txt'), 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const [path, calls, decision] = line.split(/\s+/) + return [path, { calls: Number(calls), decision }] as const + }) +) + +const BUILDER_CALL = + /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan)\s*\(/g + +const DECIDE = + 'Decide attribution: a fresh agent the host builds must carry ' + + '`agentStartedTelemetry(agent, launchSource)` (src/main/agent-launch/agent-started-telemetry.ts) ' + + 'on its startup; a resume or a bare typed command must not. Then record the file, its call ' + + 'count and the decision in __fixtures__/host-agent-startup-call-sites.txt.' + +function isTestFile(path: string): boolean { + return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/') +} + +function collectSourceFiles(root: string): string[] { + return readdirSync(root).flatMap((entry) => { + const full = join(root, entry) + if (statSync(full).isDirectory()) { + return entry === '__fixtures__' ? [] : collectSourceFiles(full) + } + return /\.tsx?$/.test(entry) ? [full] : [] + }) +} + +/** Drop comment-only lines so prose naming a builder is not a call. */ +function codeText(contents: string): string { + return contents + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) + .join('\n') +} + +describe('host agent startup attribution', () => { + const repoRoot = resolve(__dirname, '..', '..', '..') + const files = collectSourceFiles(join(repoRoot, 'src', 'main')) + const found = new Map() + for (const file of files) { + const path = relative(repoRoot, file).split('\\').join('/') + const calls = isTestFile(path) + ? 0 + : (codeText(readFileSync(file, 'utf8')).match(BUILDER_CALL)?.length ?? 0) + if (calls > 0) { + found.set(path, calls) + } + } + + it('scans a plausible number of files', () => { + // A broken root would make the guard silently vacuous. + expect(files.length).toBeGreaterThan(500) + }) + + it('has an attribution decision for every builder call', () => { + const undecided = [...found] + .filter(([path, calls]) => calls > (LISTED.get(path)?.calls ?? 0)) + .map(([path, calls]) => `${path}: ${calls} calls, ${LISTED.get(path)?.calls ?? 0} decided`) + expect(undecided, `New host-built agent startup. ${DECIDE}`).toEqual([]) + }) + + it('has no stale entry', () => { + // A count left above reality lets the next call in that file land without a decision. + const stale = [...LISTED] + .filter(([path, entry]) => entry.calls > (found.get(path) ?? 0)) + .map(([path, entry]) => `${path}: ${found.get(path) ?? 0} calls, ${entry.calls} listed`) + expect(stale, 'Lower or delete the entry to match the calls that remain.').toEqual([]) + }) + + it('uses the known decision vocabulary', () => { + const unknown = [...LISTED].filter( + ([, entry]) => !DECISIONS.some((decision) => decision === entry.decision) + ) + expect( + unknown.map(([path]) => path), + `Use one of: ${DECISIONS.join(', ')}.` + ).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 48d81fc79cc..28ee6cc0a92 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -118,7 +118,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM protected async buildStartupForDraft( repo: Repo, draft: string, - requestedAgent?: TuiAgent + requestedAgent?: TuiAgent, + launchSource?: string ): Promise<{ agent: TuiAgent startup: WorktreeStartupLaunch @@ -131,6 +132,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM repo, draft, ...(requestedAgent ? { requestedAgent } : {}), + ...(launchSource ? { launchSource } : {}), settings: this.store.getSettings(), getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo) }) diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 6c5d11b8349..61d80e60b09 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -27,6 +27,7 @@ import { deterministicAgentSessionUuid, isAgentSessionOperationOutcomeUnknown } from './runtime-agent-launch-resolution' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSessionExecutionNamespace { async createAgentSession( @@ -201,6 +202,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe launchAgent: request.agent, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, + // A fresh agent this host built; the request has no surface field, so it counts as `unknown`. + telemetry: agentStartedTelemetry(request.agent, undefined), cwd: startupCwd, presentation: request.presentation ?? 'background', tabId: operationTabId, diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 79c0b612c05..c293d65460f 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -68,7 +68,12 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork : null const draftStartup = !args.startup && !agentStartup && args.startupDraft - ? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent) + ? await this.buildStartupForDraft( + repo, + args.startupDraft, + requestedAgent, + args.startupLaunchSource + ) : null const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup const effectiveStartupFollowup = agentStartup?.followup diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 94dba6bdc83..eeac6dd9a78 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,6 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -250,7 +251,9 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, - startupCommandDelivery: startupPlan.startupCommandDelivery + startupCommandDelivery: startupPlan.startupCommandDelivery, + // A bare command the user typed stays out of launch accounting, as before. + ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) } } } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts index 6aa0c6bd70f..92612c2e763 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-12.spec.ts @@ -223,4 +223,74 @@ describe('OrcaRuntimeService', () => { expect(spawn.mock.calls[0]?.[0]?.command).not.toMatch(/--model/) }) + + // Every agent the runtime builds is attributed, so a launch whose caller named no surface still + // counts; only the caller's surface is taken, the rest is derived from the agent it resolved. + it.each([ + ['orchestration', 'orchestration'], + [undefined, 'unknown'], + ['a_surface_added_later', 'unknown'] + ])('attributes a startup-agent launch named %s as %s', async (launchSource, expected) => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-attributed' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'claude', + ...(launchSource ? { launchSource } : {}) + }) + + expect(spawn.mock.calls[0]?.[0]?.telemetry).toEqual({ + agent_kind: 'claude-code', + launch_source: expected, + request_kind: 'new' + }) + }) + + it('attributes a fresh agent session the host builds as unknown', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-session' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createAgentSession( + { + clientOperationId: `${Date.now()}-${'cd'.repeat(16)}`, + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude' + }, + { clientId: 'remote-desktop', clientKind: 'runtime' } + ) + + expect(spawn.mock.calls[0]?.[0]?.telemetry).toEqual({ + agent_kind: 'claude-code', + launch_source: 'unknown', + request_kind: 'new' + }) + }) + + it('leaves a bare agent command the user typed out of launch attribution', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bare' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'claude' }) + + expect(spawn.mock.calls[0]?.[0]?.launchAgent).toBe('claude') + expect(spawn.mock.calls[0]?.[0]?.telemetry).toBeUndefined() + }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index cc2c2cae2ba..d3d2ee8b44a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -509,6 +509,7 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:repo-1', name: 'runtime-explicit-draft', startupDraft: draftUrl, + startupLaunchSource: 'cli', createdWithAgent: 'codex', activate: true }) @@ -519,7 +520,9 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ cwd: '/tmp/workspaces/runtime-explicit-draft', command: "codex '--dangerously-bypass-approvals-and-sandbox'", - worktreeId: result.worktree.id + worktreeId: result.worktree.id, + // The host picked and launched this agent, so it carries the caller's surface too. + telemetry: { agent_kind: 'codex', launch_source: 'cli', request_kind: 'new' } }) ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 07dcdb53186..3f184cd50b5 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -14,10 +14,6 @@ */ import { randomUUID } from 'node:crypto' -import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' -import { launchSourceSchema } from '../../../../shared/telemetry-property-schemas' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' import { narrowStructuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' @@ -138,7 +134,7 @@ export function agentLaunchSurfaceFactory( ...(launchPreferences ? { launchPreferences } : {}), // A live reserved pane would be attached, not launched into, so the runtime refuses it. ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), - ...agentLaunchTelemetry(agent, launchSource), + ...(launchSource ? { launchSource } : {}), onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) const terminal = await created.catch(terminalSpawn.rethrow) @@ -159,34 +155,6 @@ export function agentLaunchSurfaceFactory( } } -/** - * The `agent_started` triple, of which only `launch_source` came from the caller. - * - * `agent_kind` and `request_kind` are derived rather than accepted — the host already knows both, - * and a value it derives is a value a caller cannot misreport. `request_kind` is always `new` - * because a launch reusing a terminal returns before any surface is created. - * - * An unrecognized `launch_source` drops the telemetry and starts the agent anyway. The wire keeps - * the arm set open so an older host cannot refuse a newer client's launch over a label, which is - * only honoured if the refusal does not reappear here: attribution is bookkeeping, and bookkeeping - * must not gate the user's launch. - */ -function agentLaunchTelemetry( - agent: TuiAgent, - launchSource: string | undefined -): Pick { - const parsed = launchSourceSchema.safeParse(launchSource) - return parsed.success - ? { - telemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: parsed.data, - request_kind: 'new' - } - } - : {} -} - function requireInstalledHost(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() if (!host) { diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index b39dbc350f0..75045cf74c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -331,6 +331,23 @@ describe('the worktree factory', () => { }) }) + it("attributes a create by the launch's own source, never a copy inside the create payload", async () => { + const createWithSource = { + ...CREATE_LAUNCH, + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, launchSource: 'cli' } + } + } + const named = runtimeStub({ settings: {} }) + await launch({ ...createWithSource, launchSource: 'onboarding' }, named) + expect(createArgs(named)).toMatchObject({ startupLaunchSource: 'onboarding' }) + + const unnamed = runtimeStub({ settings: {} }) + await launch(createWithSource, unnamed) + expect(createArgs(unnamed)).not.toHaveProperty('startupLaunchSource') + }) + it('preserves an explicit no-arguments value for an agent-first worktree create', async () => { const runtime = runtimeStub({ settings: {} }) await launch({ ...CREATE_LAUNCH, agentArgs: null }, runtime) @@ -554,15 +571,14 @@ describe('launch inputs that cross the wire', () => { }) }) - it('derives agent_kind and request_kind, taking only launch_source from the caller', async () => { + it("hands the caller's launch_source to the runtime, which attributes the launch", async () => { const runtime = runtimeStub({ settings: {} }) await launch({ ...EXISTING_LAUNCH, launchSource: 'source_control_recovery' }, runtime) - expect(terminalOptions(runtime).telemetry).toEqual({ - agent_kind: 'claude-code', - launch_source: 'source_control_recovery', - request_kind: 'new' - }) + // The runtime derives agent_kind and request_kind from the agent it builds, so the handler + // forwards only the one member it cannot know, and never a prebuilt triple. + expect(terminalOptions(runtime)).toMatchObject({ launchSource: 'source_control_recovery' }) + expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') }) it('starts the agent anyway when launch_source is one this build has never heard of', async () => { @@ -573,16 +589,16 @@ describe('launch inputs that cross the wire', () => { ) // The whole point of the open arm set: attribution is bookkeeping, and bookkeeping must never - // gate a user action. The row is dropped; the launch is not. + // gate a user action. The runtime records it as `unknown`; the launch still starts. expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) - expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') + expect(terminalOptions(runtime)).toMatchObject({ launchSource: 'a_surface_added_later' }) }) - it('sends no telemetry at all when the caller named no launch source', async () => { + it('names no launch source when the caller named none', async () => { const runtime = runtimeStub({ settings: {} }) await launch(EXISTING_LAUNCH, runtime) - expect(terminalOptions(runtime)).not.toHaveProperty('telemetry') + expect(terminalOptions(runtime)).not.toHaveProperty('launchSource') }) it('keeps a structured preference when the cwd names the workspace root', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 6f7a1bba077..067f583e37b 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -114,6 +114,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ observeSetupCompletion: true, createdWithAgent: agent as TuiAgent, startupAgent: agent as TuiAgent, + startupLaunchSource: 'orchestration', ...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}), activate: false, lineage: { noParent: true } @@ -185,6 +186,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ // Why: agent ids are not shell commands (`cursor` is the desktop app, // its CLI is `cursor-agent`); resolve through the TUI agent config. startupAgent: agent as TuiAgent, + launchSource: 'orchestration', ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), title: `worker-${params.taskId}`, presentation: 'background' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index 8459a9886e4..d265858b024 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -155,6 +155,7 @@ describe('orchestration RPC methods', () => { // the tab without scrolling the sidebar to the worker's workspace. expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', { startupAgent: 'codex', + launchSource: 'orchestration', title: `worker-${task.id}`, surfaceOwner: false }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts index b18eb9ee6c2..3e86dea3e28 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts @@ -69,6 +69,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // desktop app while its CLI is `cursor-agent`. Let the runtime build the // configured launcher instead of executing the raw id. startupAgent: args.agent, + launchSource: 'orchestration', ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index fe0c11e374d..cbdab8d1f94 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -61,6 +61,7 @@ export async function createWorkerWorktree(args: { ...(args.withAgentTerminal ? { startupAgent: args.agent, + startupLaunchSource: 'orchestration', ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}) } : {}), diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 5164ac0b22f..cda3354e816 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -140,6 +140,7 @@ describe('orchestration new-worktree workers', () => { expect(runtime.createManagedWorktree).toHaveBeenCalledWith( expect.objectContaining({ startupAgent: 'codex', + startupLaunchSource: 'orchestration', awaitTerminalProvisioning: true, observeSetupCompletion: true, lineage: expect.objectContaining({ noParent: true, parentWorktree: undefined }) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 932659758bb..c05dc906f40 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -78,6 +78,7 @@ export function buildManagedWorktreeCreateArgs( : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + ...(params.launchSource ? { startupLaunchSource: params.launchSource } : {}), startupDraft: params.startupDraft, lineage: { parentWorkspace: params.parentWorkspace, diff --git a/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts b/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts new file mode 100644 index 00000000000..2cfd6c3de92 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const +} + +describe('worktree.create launch source', () => { + it('hands the surface that asked for a startup agent to the runtime', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method worktree.create reaches for a create with no provenance request. + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: (_repo: string, _id: string | undefined, run: () => Promise) => + run(), + showRepo: vi.fn().mockResolvedValue(repo), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + // An open label: a surface this host has never heard of must not refuse the create. + for (const launchSource of ['cli', 'a_surface_added_later']) { + const response = await dispatcher.dispatch({ + id: 'req-1', + authToken: 'tok', + method: 'worktree.create', + params: { repo: 'repo-1', name: 'agent-startup', startupAgent: 'codex', launchSource } + }) + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenLastCalledWith( + expect.objectContaining({ startupAgent: 'codex', startupLaunchSource: launchSource }) + ) + } + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index bdc8c2a0a12..9edd5b23836 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -54,6 +54,7 @@ export type RuntimeManagedWorktreeCreateArgs = { /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null startupCwd?: string + /** The surface behind a host-built startup agent (`startupAgent` or `startupDraft`). */ startupLaunchSource?: string /** A caller-minted `tabId:leafId` for the startup terminal's pane. */ startupPaneKey?: string diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index fa0e7a0afa0..e08fffa273c 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -62,6 +62,9 @@ export type TerminalCreateOptions = { viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] + /** The surface that asked for this `startupAgent` launch; the runtime attributes every one it + * builds, as `unknown` when this is absent or unrecognized. Ignored without `startupAgent`. */ + launchSource?: string title?: string focus?: boolean rendererBacked?: boolean diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index deb2eea281a..ba2c57b7854 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' +import { tuiAgentToAgentKind } from '../../shared/agent-kind' const mocks = vi.hoisted(() => ({ markAntigravityWorkspaceTrusted: vi.fn(), @@ -102,6 +103,22 @@ describe('buildWorktreeStartupForAgent host resolution', () => { request_kind: 'new' }) }) + + it('attributes a startup agent whose caller named no surface as unknown', () => { + const result = buildWorktreeStartupForAgent({ + repo: makeRepo({}), + settings, + agent: 'claude', + getLaunchPlatform: () => 'linux', + toSessionOptions: () => undefined + }) + + expect(result.startup.telemetry).toEqual({ + agent_kind: 'claude-code', + launch_source: 'unknown', + request_kind: 'new' + }) + }) }) describe('buildWorktreeStartupForDraft agent detection', () => { @@ -135,6 +152,34 @@ describe('buildWorktreeStartupForDraft agent detection', () => { expect(mocks.detectRemoteAgents).not.toHaveBeenCalled() expect(result?.agent).toBe('claude') }) + + // The host picks and launches this agent itself, so it is attributed like any other it builds, + // whether the draft rides the launch command or is pasted once the agent is up. + it.each([ + ['claude', 'cli', 'cli', false], + ['claude', undefined, 'unknown', false], + ['claude-agent-teams', 'orchestration', 'orchestration', true], + ['claude-agent-teams', undefined, 'unknown', true] + ] as const)( + 'attributes a %s draft launch named %s as %s', + async (agent, launchSource, expected, pasted) => { + const result = await buildWorktreeStartupForDraft({ + repo: makeRepo({}), + settings, + draft: 'ship it', + requestedAgent: agent, + getLaunchPlatform: () => 'linux', + ...(launchSource ? { launchSource } : {}) + }) + + expect(result?.draftPaste !== undefined).toBe(pasted) + expect(result?.startup.telemetry).toEqual({ + agent_kind: tuiAgentToAgentKind(agent), + launch_source: expected, + request_kind: 'new' + }) + } + ) }) describe('markLocalWorktreeTrusted', () => { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 10f335a4b05..faca0baf8fa 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -1,10 +1,9 @@ import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' -import { tuiAgentToAgentKind } from '../../shared/agent-kind' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' -import { launchSourceSchema } from '../../shared/telemetry-property-schemas' import { repoIsRemote } from '../../shared/agent-launch-remote' import { getRepoSshConnectionId } from '../../shared/execution-host' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -28,6 +27,11 @@ import type { RuntimeStore } from './runtime-store-contract' export type WorktreeStartupDraftPaste = { agent: TuiAgent; content: string } export type WorktreeStartupFollowup = { expectedProcess: string; prompt: string } +/** A fresh agent the host builds always carries its `agent_started` record; dropping it fails to compile. */ +type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & { + telemetry: NonNullable +} + type StartupEnvironment = { repo: Repo settings: ReturnType @@ -42,7 +46,7 @@ export async function buildWorktreeStartupForDraft( environment: StartupEnvironment & { draft: string; requestedAgent?: TuiAgent } ): Promise<{ agent: TuiAgent - startup: WorktreeStartupLaunch + startup: AttributedWorktreeStartupLaunch draftPaste?: WorktreeStartupDraftPaste } | null> { const content = environment.draft.trim() @@ -85,6 +89,7 @@ export async function buildWorktreeStartupForDraft( isRemote: repoIsRemote(repo), ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}) }) + const telemetry = agentStartedTelemetry(agent, environment.launchSource) const draftPlan = buildAgentDraftLaunchPlan({ ...launchArgs, draft: content }) if (draftPlan) { return { @@ -95,7 +100,8 @@ export async function buildWorktreeStartupForDraft( ...(draftPlan.startupCommandDelivery ? { startupCommandDelivery: draftPlan.startupCommandDelivery } : {}), - ...(draftPlan.env ? { env: draftPlan.env } : {}) + ...(draftPlan.env ? { env: draftPlan.env } : {}), + telemetry } } } @@ -115,7 +121,8 @@ export async function buildWorktreeStartupForDraft( ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), - ...(startupPlan.env ? { env: startupPlan.env } : {}) + ...(startupPlan.env ? { env: startupPlan.env } : {}), + telemetry }, draftPaste: { agent, content } } @@ -130,7 +137,11 @@ export function buildWorktreeStartupForAgent( preferences?: AgentLaunchPreferences ) => Parameters[0]['sessionOptions'] | undefined } -): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { +): { + agent: TuiAgent + startup: AttributedWorktreeStartupLaunch + followup?: WorktreeStartupFollowup +} { const { agent, repo, settings } = environment if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') @@ -150,7 +161,6 @@ export function buildWorktreeStartupForAgent( if (!startupPlan) { throw new Error(`Could not build launch command for ${agent}.`) } - const telemetry = agentLaunchTelemetry(agent, environment.launchSource) return { agent, startup: { @@ -160,7 +170,7 @@ export function buildWorktreeStartupForAgent( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}), - ...(telemetry ? { telemetry } : {}) + telemetry: agentStartedTelemetry(agent, environment.launchSource) }, ...(startupPlan.followupPrompt ? { @@ -173,20 +183,6 @@ export function buildWorktreeStartupForAgent( } } -function agentLaunchTelemetry( - agent: TuiAgent, - launchSource: string | undefined -): WorktreeStartupLaunch['telemetry'] | undefined { - const parsed = launchSourceSchema.safeParse(launchSource) - return parsed.success - ? { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: parsed.data, - request_kind: 'new' - } - : undefined -} - export async function markLocalWorktreeTrusted( agent: TuiAgent, workspacePath: string diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 016b759d877..bffb93c1b9a 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -305,7 +305,9 @@ export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [ 'startupDraft', 'startupLaunchConfig', 'startupEnv', - 'startupCommandDelivery' + 'startupCommandDelivery', + // The launch carries its own; a create's copy would be a second, possibly contradicting, answer. + 'launchSource' ] as const /** Strips the reserved agent fields from a create payload. Callers migrating from diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 3fd12f52eed..3926c610708 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -18,6 +18,7 @@ import { isValidHostTerminalTabId } from '../terminal-tab-id' import { isTuiAgent } from '../tui-agent-config' import type { TuiAgent } from '../tui-agent' import { WorktreeCreate } from './worktree-create-params' +import { LaunchSourceParam } from './launch-source-param' import { TerminalTabIdParam } from './agent-session-params' import { SessionId } from './structured-agent-session-params' import { isStructuredAgentSessionIdFor } from '../structured-agent-session-create' @@ -78,16 +79,7 @@ export const AgentLaunchFields = z.object({ /** A start directory other than the workspace root. Terminal-only, and the host downgrades a * structured launch that carries one rather than ignoring it. */ cwd: z.string().min(1, 'Empty launch cwd').optional(), - /** - * Telemetry attribution, deliberately `z.string()` rather than the closed `launchSourceSchema`. - * - * Params are validated by the HOST, so a closed enum here is a version claim pointing the wrong - * way: a newer client naming a launch surface an older host has never heard of would have its - * whole launch refused over a label nothing reads as behaviour. Bookkeeping must not gate a user - * action, so the arm set stays open here and the host parses it leniently at the point it is - * actually used — the same `safeParse`-and-skip the PTY spawn already does. - */ - launchSource: z.string().optional(), + launchSource: LaunchSourceParam.optional(), /** * The pane a terminal launch should create, minted by a caller that places its own tabs. * diff --git a/src/shared/rpc-contract/launch-source-param.ts b/src/shared/rpc-contract/launch-source-param.ts new file mode 100644 index 00000000000..61004422483 --- /dev/null +++ b/src/shared/rpc-contract/launch-source-param.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +/** + * Which surface started an agent launch, deliberately `z.string()` rather than the closed + * `launchSourceSchema`. + * + * Params are validated by the HOST, so a closed enum here is a version claim pointing the wrong + * way: a newer client naming a launch surface an older host has never heard of would have its + * whole launch refused over a label nothing reads as behaviour. Bookkeeping must not gate a user + * action, so the arm set stays open here and the host resolves it leniently where it is used. + */ +export const LaunchSourceParam = z.string() diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts index 7c0b1f55fad..04de055a639 100644 --- a/src/shared/rpc-contract/worktree-create-params.ts +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -5,6 +5,7 @@ import { workspaceSourceSchema } from '../telemetry-events' import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents' import { isTuiAgent } from '../tui-agent-config' +import { LaunchSourceParam } from './launch-source-param' import { OptionalBoolean, OptionalFiniteNumber, @@ -110,6 +111,8 @@ export const WorktreeCreate = z // workspaces execute in a different shell than the client process. startupAgent: OptionalTuiAgent, startupPrompt: OptionalString, + // Which surface asked for the agent the host launches from `startupAgent` or `startupDraft`. + launchSource: LaunchSourceParam.optional(), // Why: task-driven mobile creates need desktop parity: the host chooses // the same default/detected agent and drafts the linked issue/PR URL into it. startupDraft: OptionalString, diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 89f62138407..a45a2f8d9f2 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -141,6 +141,10 @@ export const launchSourceSchema = z.enum([ 'conflict_resolution', 'source_control_recovery', 'terminal_context_menu', + // Launches the host performs for a caller outside the desktop app. + 'cli', + 'mobile', + 'orchestration', 'unknown' ]) export type LaunchSource = z.infer From 94b5a7d256da1bec656980800b7dafbdcb9a6c63 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:47:45 -0700 Subject: [PATCH 327/387] fix(native-chat): only Codex's turn completion ends a Codex turn (#23682) * fix(native-chat): the sink queue keeps a settlement's first batch, as the journal does The journal applies a lifecycle batch's settlement id once and skips any later batch with the same id. The deferred sink queue coalesced the same key the other way: a second batch replaced the first while it was still queued. So which record survived depended on whether the first had drained yet. A lifecycle batch now keeps the queued operation with its key, and a later one is accepted and dropped, which is what the journal does once the first is written. * fix(native-chat): only turn/completed ends a Codex turn Codex follows every turn-ending `error` (willRetry=false) with a failed `turn/completed` for the same turn, 0-32 ms later. That was captured from the real app-server on 0.141.0 and 0.158.0 across eight failure scenarios, and it is how Codex builds a failed turn: it records the error as the turn's last error, records any pending input, and then derives `failed` from that error when it completes the turn. The translator ended the turn twice: once on the error, and again on the completion, with a guard to make the first end final. Ending on the error threw away what only the completion carries: Codex's duration, and the completion's receipt time. It also forgot the turn before Codex recorded the turn's pending input. Now the error is only the row the user reads, inside the still-open turn, and `turn/completed` is the turn's only live end. A process exit between the two is the existing exit sweep's observed end, recorded as interrupted. A failed completion is stored as completed with outcome failure, live and on restore alike. Only `interrupted` maps to the interrupted state. The first-end-final guard is gone. Codex sends one completion per turn, the only redelivery Orca has is the retry of a refused frame (which changes nothing), and the settlement id already keeps the first record in the queue and the journal. * refactor(codex): delete the unreachable oversized-notification settlement The translator settled a streamed item when the transport rejected its notification as oversized. Nothing can produce that frame. The Codex stdio reader frames with `maxLineBytes: Number.POSITIVE_INFINITY` (codex-app-server-record-reader.ts), which it has done since the app-server records were uncapped. With an infinite limit the framer never reports `line-too-long`: no line, pending suffix or paused queue can exceed it. So the dispatcher never emits `frame:oversized-notification`, and the arm that settles it never runs. The arm, its helper module and its test go. In place of the test, the connection test now proves the reason: a notification past the old 16 MiB wire limit arrives whole, and no oversized frame is reported. * test(codex): replace the captured ids in the turn-endings fixture with synthetic ones The replay reads ids only to group frames, so the real thread, turn and response ids from the capture account carry nothing the test needs. The fixture moves beside the Codex tests that read it. * test(codex): use a neutral made-up status as the unknown-status example 'cancelled' read as a stop being recorded as a completion. * test(codex): a restored turn with a status Orca cannot place ends with no verdict Codex's history carries the same status field as the live completion, so the restore path is pinned to the same mapping: completed, and no outcome. --- .../codex-app-server-turn-endings.jsonl | 172 +++++++++++++++ .../codex/codex-app-server-connection.test.ts | 30 +++ ...ex-structured-journal-provider-verdicts.ts | 40 ---- .../codex-structured-journal-settlement.ts | 76 ------- ...x-structured-journal-translation-frames.ts | 68 ------ ...ctured-journal-translation-streams.test.ts | 49 ----- ...red-journal-translation-turn-boundaries.ts | 58 +---- ...journal-translation-turn-lifecycle.test.ts | 63 +++++- ...rnal-translation-turn-settles-once.test.ts | 139 +++++++++--- ...ructured-journal-translation-turn-state.ts | 13 +- ...ex-structured-journal-translation-turns.ts | 6 +- ...-structured-journal-translation-writers.ts | 4 +- ...dex-structured-journal-translation.test.ts | 53 ++--- .../codex-structured-journal-translation.ts | 38 +--- ...ctured-journal-turn-endings-replay.test.ts | 206 ++++++++++++++++++ ...-journal-turn-settles-once-journal.test.ts | 56 ++++- .../codex/codex-structured-thread-facts.ts | 6 - ...ructured-agent-session-event-sink-queue.ts | 7 +- ...tructured-agent-session-event-sink.test.ts | 26 +++ .../structured-agent-session-event-sink.ts | 1 + 20 files changed, 699 insertions(+), 412 deletions(-) create mode 100644 src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl delete mode 100644 src/main/codex/codex-structured-journal-provider-verdicts.ts delete mode 100644 src/main/codex/codex-structured-journal-translation-frames.ts create mode 100644 src/main/codex/codex-structured-journal-turn-endings-replay.test.ts diff --git a/src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl b/src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl new file mode 100644 index 00000000000..7889b24d113 --- /dev/null +++ b/src/main/codex/__fixtures__/codex-app-server-turn-endings.jsonl @@ -0,0 +1,172 @@ +{"case":"0.141.0-bad-model","t":371,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000001","status":{"type":"active"}}} +{"case":"0.141.0-bad-model","t":371,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000001","turn":{"id":"00000000-0000-7000-8000-000000000002","status":"inProgress"}}} +{"case":"0.141.0-bad-model","t":1745,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000001","turnId":"00000000-0000-7000-8000-000000000002","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000003","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-model","t":1745,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000001","turnId":"00000000-0000-7000-8000-000000000002","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000003","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-model","t":2831,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000001","status":{"type":"systemError"}}} +{"case":"0.141.0-bad-model","t":2831,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000001","turnId":"00000000-0000-7000-8000-000000000002","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"}}} +{"case":"0.141.0-bad-model","t":2831,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000001","turn":{"id":"00000000-0000-7000-8000-000000000002","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"},"durationMs":2488}}} +{"case":"0.141.0-bad-turn-cwd","t":1458,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000004","status":{"type":"active"}}} +{"case":"0.141.0-bad-turn-cwd","t":1458,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000004","turn":{"id":"00000000-0000-7000-8000-000000000005","status":"inProgress"}}} +{"case":"0.141.0-bad-turn-cwd","t":5792,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000006","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-turn-cwd","t":5792,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000006","clientId":null,"content":[]}}} +{"case":"0.141.0-bad-turn-cwd","t":7645,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"agentMessage","id":"msg_1","text":""}}} +{"case":"0.141.0-bad-turn-cwd","t":7938,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000004","turnId":"00000000-0000-7000-8000-000000000005","item":{"type":"agentMessage","id":"msg_1","text":"pong"}}} +{"case":"0.141.0-bad-turn-cwd","t":7959,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000004","status":{"type":"idle"}}} +{"case":"0.141.0-bad-turn-cwd","t":7959,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000004","turn":{"id":"00000000-0000-7000-8000-000000000005","status":"completed","durationMs":6532}}} +{"case":"0.141.0-compact-overloaded","t":582,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"active"}}} +{"case":"0.141.0-compact-overloaded","t":582,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000008","status":"inProgress"}}} +{"case":"0.141.0-compact-overloaded","t":2940,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000009","clientId":null,"content":[]}}} +{"case":"0.141.0-compact-overloaded","t":2940,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000009","clientId":null,"content":[]}}} +{"case":"0.141.0-compact-overloaded","t":3011,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.141.0-compact-overloaded","t":3011,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000008","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.141.0-compact-overloaded","t":3017,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"idle"}}} +{"case":"0.141.0-compact-overloaded","t":3017,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000008","status":"completed","durationMs":2474}}} +{"case":"0.141.0-compact-overloaded","t":3076,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"active"}}} +{"case":"0.141.0-compact-overloaded","t":3076,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000010","status":"inProgress"}}} +{"case":"0.141.0-compact-overloaded","t":3081,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","item":{"type":"contextCompaction","id":"00000000-0000-7000-8000-000000000011"}}} +{"case":"0.141.0-compact-overloaded","t":3684,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.141.0-compact-overloaded","t":4508,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.141.0-compact-overloaded","t":5511,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000007","status":{"type":"systemError"}}} +{"case":"0.141.0-compact-overloaded","t":5511,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000007","turnId":"00000000-0000-7000-8000-000000000010","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.141.0-compact-overloaded","t":5511,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000007","turn":{"id":"00000000-0000-7000-8000-000000000010","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":2435}}} +{"case":"0.141.0-conn-refused","t":588,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000012","status":{"type":"active"}}} +{"case":"0.141.0-conn-refused","t":588,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000012","turn":{"id":"00000000-0000-7000-8000-000000000013","status":"inProgress"}}} +{"case":"0.141.0-conn-refused","t":700,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000014","clientId":null,"content":[]}}} +{"case":"0.141.0-conn-refused","t":700,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000014","clientId":null,"content":[]}}} +{"case":"0.141.0-conn-refused","t":1354,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)"}}} +{"case":"0.141.0-conn-refused","t":2134,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)"}}} +{"case":"0.141.0-conn-refused","t":3116,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000012","status":{"type":"systemError"}}} +{"case":"0.141.0-conn-refused","t":3116,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000012","turnId":"00000000-0000-7000-8000-000000000013","willRetry":false,"error":{"message":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)","codexErrorInfo":"other"}}} +{"case":"0.141.0-conn-refused","t":3116,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000012","turn":{"id":"00000000-0000-7000-8000-000000000013","status":"failed","error":{"message":"stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)","codexErrorInfo":"other"},"durationMs":2556}}} +{"case":"0.141.0-control","t":487,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000015","status":{"type":"active"}}} +{"case":"0.141.0-control","t":487,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000015","turn":{"id":"00000000-0000-7000-8000-000000000016","status":"inProgress"}}} +{"case":"0.141.0-control","t":2657,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000017","clientId":null,"content":[]}}} +{"case":"0.141.0-control","t":2657,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000017","clientId":null,"content":[]}}} +{"case":"0.141.0-control","t":4651,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"agentMessage","id":"msg_2","text":""}}} +{"case":"0.141.0-control","t":4661,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000015","turnId":"00000000-0000-7000-8000-000000000016","item":{"type":"agentMessage","id":"msg_2","text":"pong"}}} +{"case":"0.141.0-control","t":4811,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000015","status":{"type":"idle"}}} +{"case":"0.141.0-control","t":4811,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000015","turn":{"id":"00000000-0000-7000-8000-000000000016","status":"completed","durationMs":4351}}} +{"case":"0.141.0-interrupt","t":670,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000018","status":{"type":"active"}}} +{"case":"0.141.0-interrupt","t":670,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000018","turn":{"id":"00000000-0000-7000-8000-000000000019","status":"inProgress"}}} +{"case":"0.141.0-interrupt","t":2618,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000018","turnId":"00000000-0000-7000-8000-000000000019","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000020","clientId":null,"content":[]}}} +{"case":"0.141.0-interrupt","t":2618,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000018","turnId":"00000000-0000-7000-8000-000000000019","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000020","clientId":null,"content":[]}}} +{"case":"0.141.0-interrupt","t":4153,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000018","turnId":"00000000-0000-7000-8000-000000000019","item":{"type":"agentMessage","id":"msg_3","text":""}}} +{"case":"0.141.0-interrupt","t":5669,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000018","status":{"type":"idle"}}} +{"case":"0.141.0-interrupt","t":5669,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000018","turn":{"id":"00000000-0000-7000-8000-000000000019","status":"interrupted","durationMs":5027}}} +{"case":"0.141.0-model-too-new-a","t":1276,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000021","status":{"type":"active"}}} +{"case":"0.141.0-model-too-new-a","t":1276,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000021","turn":{"id":"00000000-0000-7000-8000-000000000022","status":"inProgress"}}} +{"case":"0.141.0-model-too-new-a","t":6105,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000021","turnId":"00000000-0000-7000-8000-000000000022","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000023","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-a","t":6105,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000021","turnId":"00000000-0000-7000-8000-000000000022","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000023","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-a","t":7424,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000021","status":{"type":"systemError"}}} +{"case":"0.141.0-model-too-new-a","t":7424,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000021","turnId":"00000000-0000-7000-8000-000000000022","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"}}} +{"case":"0.141.0-model-too-new-a","t":7424,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000021","turn":{"id":"00000000-0000-7000-8000-000000000022","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"},"durationMs":6195}}} +{"case":"0.141.0-model-too-new-b","t":387,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000024","status":{"type":"active"}}} +{"case":"0.141.0-model-too-new-b","t":387,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000024","turn":{"id":"00000000-0000-7000-8000-000000000025","status":"inProgress"}}} +{"case":"0.141.0-model-too-new-b","t":1162,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000024","turnId":"00000000-0000-7000-8000-000000000025","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000026","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-b","t":1162,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000024","turnId":"00000000-0000-7000-8000-000000000025","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000026","clientId":null,"content":[]}}} +{"case":"0.141.0-model-too-new-b","t":1993,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000024","status":{"type":"systemError"}}} +{"case":"0.141.0-model-too-new-b","t":1993,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000024","turnId":"00000000-0000-7000-8000-000000000025","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"}}} +{"case":"0.141.0-model-too-new-b","t":1993,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000024","turn":{"id":"00000000-0000-7000-8000-000000000025","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-6-astra' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again.\"}}","codexErrorInfo":"other"},"durationMs":1633}}} +{"case":"0.141.0-overloaded-503","t":407,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000027","status":{"type":"active"}}} +{"case":"0.141.0-overloaded-503","t":407,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000027","turn":{"id":"00000000-0000-7000-8000-000000000028","status":"inProgress"}}} +{"case":"0.141.0-overloaded-503","t":574,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000027","turnId":"00000000-0000-7000-8000-000000000028","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000029","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-503","t":574,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000027","turnId":"00000000-0000-7000-8000-000000000028","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000029","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-503","t":1296,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000027","status":{"type":"systemError"}}} +{"case":"0.141.0-overloaded-503","t":1296,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000027","turnId":"00000000-0000-7000-8000-000000000028","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.141.0-overloaded-503","t":1296,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000027","turn":{"id":"00000000-0000-7000-8000-000000000028","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":921}}} +{"case":"0.141.0-overloaded-sse","t":510,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000030","status":{"type":"active"}}} +{"case":"0.141.0-overloaded-sse","t":510,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000030","turn":{"id":"00000000-0000-7000-8000-000000000031","status":"inProgress"}}} +{"case":"0.141.0-overloaded-sse","t":611,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000030","turnId":"00000000-0000-7000-8000-000000000031","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000032","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-sse","t":611,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000030","turnId":"00000000-0000-7000-8000-000000000031","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000032","clientId":null,"content":[]}}} +{"case":"0.141.0-overloaded-sse","t":695,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000030","status":{"type":"systemError"}}} +{"case":"0.141.0-overloaded-sse","t":695,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000030","turnId":"00000000-0000-7000-8000-000000000031","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.141.0-overloaded-sse","t":695,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000030","turn":{"id":"00000000-0000-7000-8000-000000000031","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":213}}} +{"case":"0.141.0-stream-drop","t":510,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000033","status":{"type":"active"}}} +{"case":"0.141.0-stream-drop","t":510,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000033","turn":{"id":"00000000-0000-7000-8000-000000000034","status":"inProgress"}}} +{"case":"0.141.0-stream-drop","t":621,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000035","clientId":null,"content":[]}}} +{"case":"0.141.0-stream-drop","t":621,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000035","clientId":null,"content":[]}}} +{"case":"0.141.0-stream-drop","t":740,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.141.0-stream-drop","t":942,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.141.0-stream-drop","t":1320,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000033","status":{"type":"systemError"}}} +{"case":"0.141.0-stream-drop","t":1320,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000033","turnId":"00000000-0000-7000-8000-000000000034","willRetry":false,"error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"}}} +{"case":"0.141.0-stream-drop","t":1320,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000033","turn":{"id":"00000000-0000-7000-8000-000000000034","status":"failed","error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"},"durationMs":838}}} +{"case":"0.158.0-bad-model","t":1232,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000036","status":{"type":"active"}}} +{"case":"0.158.0-bad-model","t":1232,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000036","turn":{"id":"00000000-0000-7000-8000-000000000037","status":"inProgress"}}} +{"case":"0.158.0-bad-model","t":7177,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000036","turnId":"00000000-0000-7000-8000-000000000037","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000038","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-model","t":7215,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000036","turnId":"00000000-0000-7000-8000-000000000037","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000038","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-model","t":8236,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000036","status":{"type":"systemError"}}} +{"case":"0.158.0-bad-model","t":8236,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000036","turnId":"00000000-0000-7000-8000-000000000037","willRetry":false,"error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"}}} +{"case":"0.158.0-bad-model","t":8257,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000036","turn":{"id":"00000000-0000-7000-8000-000000000037","status":"failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-qa-nonexistent' model is not supported when using Codex with a ChatGPT account.\"}}","codexErrorInfo":"other"},"durationMs":7576}}} +{"case":"0.158.0-bad-turn-cwd","t":4437,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000039","status":{"type":"active"}}} +{"case":"0.158.0-bad-turn-cwd","t":4438,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000039","turn":{"id":"00000000-0000-7000-8000-000000000040","status":"inProgress"}}} +{"case":"0.158.0-bad-turn-cwd","t":7840,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000041","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-turn-cwd","t":7851,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000041","clientId":null,"content":[]}}} +{"case":"0.158.0-bad-turn-cwd","t":9220,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"agentMessage","id":"msg_4","text":""}}} +{"case":"0.158.0-bad-turn-cwd","t":9421,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000039","turnId":"00000000-0000-7000-8000-000000000040","item":{"type":"agentMessage","id":"msg_4","text":"pong"}}} +{"case":"0.158.0-bad-turn-cwd","t":9566,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000039","status":{"type":"idle"}}} +{"case":"0.158.0-bad-turn-cwd","t":9566,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000039","turn":{"id":"00000000-0000-7000-8000-000000000040","status":"completed","durationMs":5172}}} +{"case":"0.158.0-compact-overloaded","t":943,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"active"}}} +{"case":"0.158.0-compact-overloaded","t":944,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000043","status":"inProgress"}}} +{"case":"0.158.0-compact-overloaded","t":5760,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000044","clientId":null,"content":[]}}} +{"case":"0.158.0-compact-overloaded","t":5765,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000044","clientId":null,"content":[]}}} +{"case":"0.158.0-compact-overloaded","t":5770,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.158.0-compact-overloaded","t":5771,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000043","item":{"type":"agentMessage","id":"msg_ok","text":"pong"}}} +{"case":"0.158.0-compact-overloaded","t":5795,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"idle"}}} +{"case":"0.158.0-compact-overloaded","t":5795,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000043","status":"completed","durationMs":4899}}} +{"case":"0.158.0-compact-overloaded","t":5820,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"active"}}} +{"case":"0.158.0-compact-overloaded","t":5820,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000045","status":"inProgress"}}} +{"case":"0.158.0-compact-overloaded","t":5821,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","item":{"type":"contextCompaction","id":"00000000-0000-7000-8000-000000000046"}}} +{"case":"0.158.0-compact-overloaded","t":6421,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.158.0-compact-overloaded","t":7232,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Selected model is at capacity. Please try a different model."}}} +{"case":"0.158.0-compact-overloaded","t":8204,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000042","status":{"type":"systemError"}}} +{"case":"0.158.0-compact-overloaded","t":8204,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000042","turnId":"00000000-0000-7000-8000-000000000045","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.158.0-compact-overloaded","t":8207,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000042","turn":{"id":"00000000-0000-7000-8000-000000000045","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":2388}}} +{"case":"0.158.0-conn-refused","t":1343,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000047","status":{"type":"active"}}} +{"case":"0.158.0-conn-refused","t":1343,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000047","turn":{"id":"00000000-0000-7000-8000-000000000048","status":"inProgress"}}} +{"case":"0.158.0-conn-refused","t":2002,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000049","clientId":null,"content":[]}}} +{"case":"0.158.0-conn-refused","t":2013,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000049","clientId":null,"content":[]}}} +{"case":"0.158.0-conn-refused","t":2616,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":8185,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":18778,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":39406,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":79975,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":140587,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-conn-refused","t":201225,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000047","turnId":"00000000-0000-7000-8000-000000000048","willRetry":true,"error":{"message":"Reconnecting... waiting for network","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"Connection failed: error sending request"}}} +{"case":"0.158.0-control","t":3620,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000050","status":{"type":"active"}}} +{"case":"0.158.0-control","t":3621,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000050","turn":{"id":"00000000-0000-7000-8000-000000000051","status":"inProgress"}}} +{"case":"0.158.0-control","t":5019,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000052","clientId":null,"content":[]}}} +{"case":"0.158.0-control","t":5254,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000052","clientId":null,"content":[]}}} +{"case":"0.158.0-control","t":7276,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"agentMessage","id":"msg_5","text":""}}} +{"case":"0.158.0-control","t":7480,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000050","turnId":"00000000-0000-7000-8000-000000000051","item":{"type":"agentMessage","id":"msg_5","text":"pong"}}} +{"case":"0.158.0-control","t":7659,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000050","status":{"type":"idle"}}} +{"case":"0.158.0-control","t":7659,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000050","turn":{"id":"00000000-0000-7000-8000-000000000051","status":"completed","durationMs":4520}}} +{"case":"0.158.0-interrupt","t":929,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000053","status":{"type":"active"}}} +{"case":"0.158.0-interrupt","t":929,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000053","turn":{"id":"00000000-0000-7000-8000-000000000054","status":"inProgress"}}} +{"case":"0.158.0-interrupt","t":2320,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000053","turnId":"00000000-0000-7000-8000-000000000054","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000055","clientId":null,"content":[]}}} +{"case":"0.158.0-interrupt","t":2325,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000053","turnId":"00000000-0000-7000-8000-000000000054","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000055","clientId":null,"content":[]}}} +{"case":"0.158.0-interrupt","t":4063,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000053","turnId":"00000000-0000-7000-8000-000000000054","item":{"type":"agentMessage","id":"msg_6","text":""}}} +{"case":"0.158.0-interrupt","t":5582,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000053","status":{"type":"idle"}}} +{"case":"0.158.0-interrupt","t":5582,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000053","turn":{"id":"00000000-0000-7000-8000-000000000054","status":"interrupted","durationMs":4711}}} +{"case":"0.158.0-overloaded-503","t":455,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000056","status":{"type":"active"}}} +{"case":"0.158.0-overloaded-503","t":455,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000056","turn":{"id":"00000000-0000-7000-8000-000000000057","status":"inProgress"}}} +{"case":"0.158.0-overloaded-503","t":1114,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000056","turnId":"00000000-0000-7000-8000-000000000057","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000058","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-503","t":1131,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000056","turnId":"00000000-0000-7000-8000-000000000057","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000058","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-503","t":1722,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000056","status":{"type":"systemError"}}} +{"case":"0.158.0-overloaded-503","t":1722,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000056","turnId":"00000000-0000-7000-8000-000000000057","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.158.0-overloaded-503","t":1753,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000056","turn":{"id":"00000000-0000-7000-8000-000000000057","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":1302}}} +{"case":"0.158.0-overloaded-sse","t":944,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000059","status":{"type":"active"}}} +{"case":"0.158.0-overloaded-sse","t":944,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000059","turn":{"id":"00000000-0000-7000-8000-000000000060","status":"inProgress"}}} +{"case":"0.158.0-overloaded-sse","t":1196,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000059","turnId":"00000000-0000-7000-8000-000000000060","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000061","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-sse","t":1199,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000059","turnId":"00000000-0000-7000-8000-000000000060","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000061","clientId":null,"content":[]}}} +{"case":"0.158.0-overloaded-sse","t":1204,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000059","status":{"type":"systemError"}}} +{"case":"0.158.0-overloaded-sse","t":1204,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000059","turnId":"00000000-0000-7000-8000-000000000060","willRetry":false,"error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"}}} +{"case":"0.158.0-overloaded-sse","t":1206,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000059","turn":{"id":"00000000-0000-7000-8000-000000000060","status":"failed","error":{"message":"Selected model is at capacity. Please try a different model.","codexErrorInfo":"serverOverloaded"},"durationMs":481}}} +{"case":"0.158.0-stream-drop","t":1136,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000062","status":{"type":"active"}}} +{"case":"0.158.0-stream-drop","t":1136,"method":"turn/started","params":{"threadId":"00000000-0000-7000-8000-000000000062","turn":{"id":"00000000-0000-7000-8000-000000000063","status":"inProgress"}}} +{"case":"0.158.0-stream-drop","t":1721,"method":"item/started","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000064","clientId":null,"content":[]}}} +{"case":"0.158.0-stream-drop","t":1733,"method":"item/completed","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","item":{"type":"userMessage","id":"00000000-0000-7000-8000-000000000064","clientId":null,"content":[]}}} +{"case":"0.158.0-stream-drop","t":1738,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","willRetry":true,"error":{"message":"Reconnecting... 1/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.158.0-stream-drop","t":1959,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","willRetry":true,"error":{"message":"Reconnecting... 2/2","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"stream disconnected before completion: stream closed before response.completed"}}} +{"case":"0.158.0-stream-drop","t":2514,"method":"thread/status/changed","params":{"threadId":"00000000-0000-7000-8000-000000000062","status":{"type":"systemError"}}} +{"case":"0.158.0-stream-drop","t":2514,"method":"error","params":{"threadId":"00000000-0000-7000-8000-000000000062","turnId":"00000000-0000-7000-8000-000000000063","willRetry":false,"error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"}}} +{"case":"0.158.0-stream-drop","t":2546,"method":"turn/completed","params":{"threadId":"00000000-0000-7000-8000-000000000062","turn":{"id":"00000000-0000-7000-8000-000000000063","status":"failed","error":{"message":"stream disconnected before completion: stream closed before response.completed","codexErrorInfo":"other"},"durationMs":1761}}} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 7fe63c6e418..a4ebd72cf01 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -583,6 +583,36 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) + it('delivers a notification beyond the daemon wire limit whole, never as an oversized frame', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const frames: string[] = [] + const deltas: unknown[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onUnhandledFrame: (kind) => frames.push(kind), + onNotification: (method, params) => { + if (method === 'item/commandExecution/outputDelta') { + deltas.push(params) + } + } + }, + spawnImpl + ) + + const params = { threadId: 'thread-1', itemId: 'exec-1', delta: '' } + params.delta = 'x'.repeat(16 * 1024 * 1024 + 1) + child.stdout.write( + `${JSON.stringify({ method: 'item/commandExecution/outputDelta', params })}\n` + ) + + await vi.waitFor(() => expect(deltas).toEqual([params])) + expect(frames).toEqual([]) + expect(connection.closed).toBe(false) + await connection.close() + }) + it('keeps malformed and non-object JSON non-fatal and processes the next record', async () => { const { child, spawnImpl } = stubChild() answerInitialize(child) diff --git a/src/main/codex/codex-structured-journal-provider-verdicts.ts b/src/main/codex/codex-structured-journal-provider-verdicts.ts deleted file mode 100644 index c8685dfedab..00000000000 --- a/src/main/codex/codex-structured-journal-provider-verdicts.ts +++ /dev/null @@ -1,40 +0,0 @@ -// What Codex's own verdict frames mean beyond the row they print. -// -// Both are decoration to the transcript and load-bearing to the session's state, -// which is why they are read here rather than left to the generic-frame fallback. - -import { codexThreadStoppedRunning, readCodexErrorWillRetry } from './codex-structured-thread-facts' - -export type CodexProviderVerdict = - /** - * Codex ended this turn with a fault. - * - * The app server emits `error` ONLY for a failure that affects turn status, and - * hardcodes `willRetry: false` there; a stream error it is about to retry carries - * `willRetry: true` and ends nothing. `turn/completed` may never follow, so this - * frame is the turn's only end — without it the running lifecycle row is a latch - * nothing re-derives and the chat reads working for the life of the session. - */ - | 'turn-failed' - /** - * Codex reports the thread is no longer running. - * - * This settles no OPEN turn: the app server clears `running` on every error, - * including the ones it says do not affect turn status, so a turn still open - * here is still running and `turn/completed` is its end. What it does settle is - * a send whose dispatch was never answered — a timed-out dispatch is recorded - * as unverified delivery, reads as work still owed, and nothing else in a live - * session re-derives it. - */ - | 'thread-stopped-running' - | null - -export function readCodexProviderVerdict(method: string, params: unknown): CodexProviderVerdict { - if (method === 'error') { - return readCodexErrorWillRetry(params) ? null : 'turn-failed' - } - if (method === 'thread/status/changed') { - return codexThreadStoppedRunning(params) ? 'thread-stopped-running' : null - } - return null -} diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e2164b9498d..39d52e6b047 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -174,82 +174,6 @@ export function settleCodexJournalTurn(input: { return ADMITTED } -/** Settle streamed items whose terminal notification was rejected as oversized. */ -export function settleCodexOversizedNotification(input: { - sessionId: string - threadId: string - method: string - sink: StructuredAgentSessionEventSink - streams: CodexStructuredItemStreams - activeItems: Map - linkageFor: CodexRowLinkage -}): StructuredAgentSessionSinkAdmission { - const itemType = oversizedStreamItemType(input.method) - if (!itemType) { - return ADMITTED - } - const mutations: JournalLifecycleMutationInput[] = [] - const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] - for (const [key, active] of input.activeItems) { - if (active.threadId !== input.threadId || active.item.type !== itemType) { - continue - } - const streamed = input.streams.snapshot(active.threadId, active.item.id) - const translated = streamed - ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item) - const body = interruptedBody(translated.body) - if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) - } - activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) - } - if (mutations.length === 0) { - return ADMITTED - } - const admission = appendCodexLifecycleMutations( - input.sink, - `oversized-notification:${input.sessionId}:${input.threadId}:${input.method}`, - mutations - ) - if (!admission.accepted) { - return admission - } - for (const active of activeItemsToForget) { - input.streams.forget(active.threadId, active.itemId) - input.activeItems.delete(active.key) - } - return ADMITTED -} - -function oversizedStreamItemType(method: string): CodexThreadItem['type'] | null { - if (method === 'item/agentMessage/delta') { - return 'agentMessage' - } - if (method === 'item/plan/delta') { - return 'plan' - } - if ( - method === 'command/exec/outputDelta' || - method === 'process/outputDelta' || - method === 'item/commandExecution/outputDelta' || - method === 'item/commandExecution/terminalInteraction' - ) { - return 'commandExecution' - } - if (method === 'item/fileChange/outputDelta' || method === 'item/fileChange/patchUpdated') { - return 'fileChange' - } - if ( - method === 'item/reasoning/summaryTextDelta' || - method === 'item/reasoning/summaryPartAdded' || - method === 'item/reasoning/textDelta' - ) { - return 'reasoning' - } - return null -} - /** A settled item or prompt, naming its producer: the settlement can be the row's first write. */ function settledRow( linkageFor: CodexRowLinkage, diff --git a/src/main/codex/codex-structured-journal-translation-frames.ts b/src/main/codex/codex-structured-journal-translation-frames.ts deleted file mode 100644 index f94c10bfe66..00000000000 --- a/src/main/codex/codex-structured-journal-translation-frames.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * The translator's provider-frame arms. - * - * Each returns null for a frame it does not own, which is the translator's - * signal to keep looking. Split out so the translator reads as routing rather - * than as the shape checks each arm performs. - */ - -import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' -import type { CodexJournalItems } from './codex-structured-journal-items' -import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' -import { settleCodexOversizedNotification } from './codex-structured-journal-settlement' -import { - readCodexJournalRecord, - readCodexJournalString -} from './codex-structured-journal-translation-values' - -type OversizedInput = Parameters[0] - -/** A notification the transport refused to carry whole: settle whatever it - * opened rather than leaving the item mid-flight. */ -export function settleCodexOversizedNotificationFrame(input: { - sessionId: string - threadId: string - kind: string - payload: unknown - sink: OversizedInput['sink'] - streams: OversizedInput['streams'] - activeItems: OversizedInput['activeItems'] - linkageFor: OversizedInput['linkageFor'] -}): CodexJournalTranslationAdmission | null { - if (input.kind !== 'frame:oversized-notification') { - return null - } - const method = readCodexJournalString(readCodexJournalRecord(input.payload), 'method') - return method - ? settleCodexOversizedNotification({ - sessionId: input.sessionId, - threadId: input.threadId, - method, - sink: input.sink, - streams: input.streams, - activeItems: input.activeItems, - linkageFor: input.linkageFor - }) - : null -} - -export function createCodexOversizedNotificationSettler( - deps: { sink: OversizedInput['sink']; linkageFor: OversizedInput['linkageFor'] }, - items: Pick -) { - return settleOversizedNotification - - /** Settles the item a notification the transport refused to carry left - * mid-flight; null when the frame is not one. */ - function settleOversizedNotification( - event: Extract - ): CodexJournalTranslationAdmission | null { - return settleCodexOversizedNotificationFrame({ - ...event, - sink: deps.sink, - streams: items.streams, - activeItems: items.activeItems, - linkageFor: deps.linkageFor - }) - } -} diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index 8a924a9d78a..ce6fd220132 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -215,55 +215,6 @@ describe('codex journal translation', () => { ).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified']) }) - it('terminalizes the active streamed item when an oversized notification is rejected', () => { - const { translator, tap } = translatorWith() - translator.handle(TURN_STARTED) - translator.handle( - notification('item/started', { - item: { - type: 'commandExecution', - id: 'exec-oversized', - command: 'run', - status: 'inProgress' - } - }) - ) - const admission = translator.handle({ - type: 'provider-frame', - sessionId: SESSION_ID, - threadId: THREAD_ID, - kind: 'frame:oversized-notification', - payload: { - reason: 'record-too-large', - observedBytes: 20 * 1024 * 1024, - maxBytes: 16 * 1024 * 1024, - classification: 'notification', - method: 'item/commandExecution/outputDelta' - } - }) - - expect(admission).toEqual({ accepted: true }) - expect(tap.rows).toEqual([ - expect.objectContaining({ - body: expect.objectContaining({ kind: 'tool-call', state: 'running' }) - }), - expect.objectContaining({ - body: expect.objectContaining({ kind: 'tool-call', state: 'failed' }) - }), - expect.objectContaining({ - body: expect.objectContaining({ - kind: 'status', - providerFrame: expect.objectContaining({ kind: 'frame:oversized-notification' }) - }) - }) - ]) - const diagnostic = tap.rows[2]?.body - expect( - diagnostic?.kind === 'status' ? diagnostic.providerFrame?.payload.byteLength : 0 - ).toBeGreaterThan(0) - expect(JSON.stringify(diagnostic)).toContain('record-too-large') - }) - it('admits suppressed diagnostics before settling a completed turn', () => { const tap = recorder() let rejectSuppression = true diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 2c958858816..3116a329c97 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -140,6 +140,8 @@ export class CodexJournalTurnBoundaries { return admission } + /** Codex ends every turn with exactly one `turn/completed`, a failed one after + * its `error` frame included, so this is the only live end. */ complete(event: TurnBoundaryEvent): CodexJournalTranslationAdmission { const suppressionAdmission = this.deps.flushSuppression() if (!suppressionAdmission.accepted) { @@ -154,58 +156,14 @@ export class CodexJournalTurnBoundaries { // turn boundary is no evidence contact was lost. Only `settleSession` may // write `unverifiable`. const status = readCodexTurnStatus(event.params) - return this.end(event, turnId, { - state: codexTurnLifecycleState(status), - outcome: codexTurnOutcome(status), - completedAt: this.receiptTime(event), - durationMs: readCodexTurnDurationMs(event.params) - }) - } - - /** - * Settles the turn a terminal `error` names. - * - * Codex reports a fault that ended a turn as an `error` notification carrying - * that turn's id, and `turn/completed` may never follow it — the app server - * marks the thread not-running off the error alone. Without this the running - * lifecycle row is a latch nothing re-derives, and the chat reads "Working" - * for the life of the session. A retrying stream error is NOT a turn end and - * never reaches here. - */ - fail(event: TurnBoundaryEvent): CodexJournalTranslationAdmission { - const suppressionAdmission = this.deps.flushSuppression() - if (!suppressionAdmission.accepted) { - return suppressionAdmission - } - const turnId = readCodexTurnId(event.params) ?? this.deps.activeTurns.current(event.threadId) - // An error ends only a turn this host saw open; its `turn/completed` settles any other. - if (!turnId || !this.deps.activeTurns.isActive(event.threadId, turnId)) { - return CODEX_JOURNAL_ADMITTED - } - return this.end(event, turnId, { - state: 'completed', - outcome: 'failure', - completedAt: this.receiptTime(event) - }) - } - - /** - * A turn's first terminal settlement is final. Codex follows a turn-ending - * `error` with a failed `turn/completed` for the same turn, and by then the - * start and attributed send this row carries are forgotten, so a second end - * could only overwrite the record with less. - */ - private end( - event: TurnBoundaryEvent, - turnId: string, - terminal: TurnTerminal - ): CodexJournalTranslationAdmission { - if (this.recentTurns.has(event.threadId, turnId)) { - return CODEX_JOURNAL_ADMITTED - } const turnLifecycle = event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, terminal) + ? this.settled(event.threadId, turnId, { + state: codexTurnLifecycleState(status), + outcome: codexTurnOutcome(status), + completedAt: this.receiptTime(event), + durationMs: readCodexTurnDurationMs(event.params) + }) : null const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 6fc1e88d184..62aa5f828a1 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -346,7 +346,7 @@ describe('codex turn lifecycle rows', () => { const lifecycle = reduced(tap.rows).find((row) => row.key === LIFECYCLE_KEY) expect(lifecycle?.body).toMatchObject({ kind: 'turn', - state: 'interrupted', + state: 'completed', outcome: 'failure', requestedAt: 900 }) @@ -381,17 +381,17 @@ describe('codex turn lifecycle rows', () => { }) // `TurnStatus` in the app-server protocol is `completed | interrupted | failed | - // inProgress`, and every one of those collapses to the same terminal lifecycle - // arm. `outcome` is what keeps a Codex failure distinguishable from a stop, and - // a status this build cannot place stays unknown rather than borrowing one. + // inProgress`. Only `interrupted` is a stop; every other end completed the + // turn, and `outcome` says how. A status this build cannot place stays unknown + // rather than borrowing a verdict. it.each([ - ['interrupted', 'cancellation'], - ['failed', 'failure'], - ['cancelled', undefined], - ['inProgress', undefined] + ['interrupted', 'interrupted', 'cancellation'], + ['failed', 'completed', 'failure'], + ['someFutureStatus', 'completed', undefined], + ['inProgress', 'completed', undefined] ] as const)( - 'maps a %s turn status to an interrupted lifecycle with outcome %s', - (status, outcome) => { + 'maps a %s turn status to a %s lifecycle with outcome %s', + (status, state, outcome) => { const tap = recorder() const translator = translatorFor(tap) @@ -405,7 +405,7 @@ describe('codex turn lifecycle rows', () => { body: { kind: 'turn', turnId: TURN_ID, - state: 'interrupted', + state, ...(outcome ? { outcome } : {}), userItemId: USER_ITEM_ID, startedAt: 1_000, @@ -512,6 +512,21 @@ describe('codex turn lifecycle rows', () => { completedAt: 1_700_000_101, items: [] }, + { + id: 'turn-failed', + status: 'failed', + startedAt: 1_700_000_150, + completedAt: 1_700_000_152, + durationMs: 2_400, + items: [] + }, + { + id: 'turn-unplaced', + status: 'someFutureStatus', + startedAt: 1_700_000_170, + completedAt: 1_700_000_171, + items: [] + }, { id: 'turn-open', status: 'inProgress', startedAt: 1_700_000_200, items: [] }, { id: 'turn-untimed', status: 'completed', items: [] } ] @@ -544,6 +559,32 @@ describe('codex turn lifecycle rows', () => { startedAt: 1_700_000_100_000, completedAt: 1_700_000_101_000 } + }, + { + // The same shape a live failed completion writes. + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-failed', + body: { + kind: 'turn', + turnId: 'turn-failed', + state: 'completed', + outcome: 'failure', + userItemId: 'codex:thread-abc:turn-failed:0', + startedAt: 1_700_000_150_000, + completedAt: 1_700_000_152_000, + durationMs: 2_400 + } + }, + { + // Ended, but not a status this build can place: no verdict, never a clean finish. + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-unplaced', + body: { + kind: 'turn', + turnId: 'turn-unplaced', + state: 'completed', + userItemId: 'codex:thread-abc:turn-unplaced:0', + startedAt: 1_700_000_170_000, + completedAt: 1_700_000_171_000 + } } ]) expect(tap.tombstones).toEqual([]) diff --git a/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts b/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts index a5c02bd3bfe..77638a7d1ea 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts @@ -21,13 +21,28 @@ type Row = { key: string; body: AgentJournalItemBody } function recorder() { const rows: Row[] = [] + let refuseTerminal = 0 const sink: StructuredAgentSessionEventSink = { appendItem: (identity: AgentJournalItemIdentity, body) => rows.push({ key: agentJournalItemKey(identity), body }), + tryAppendItem: (identity: AgentJournalItemIdentity, body) => { + if (body.kind === 'turn' && body.state !== 'running' && refuseTerminal > 0) { + refuseTerminal -= 1 + return { accepted: false, reason: 'backpressure' } + } + rows.push({ key: agentJournalItemKey(identity), body }) + return { accepted: true } + }, appendTombstone: () => {}, publish: () => {} } - return { sink, rows } + return { + sink, + rows, + refuseNextTerminalWrite: () => { + refuseTerminal += 1 + } + } } function notification(method: string, params: unknown, observedAt: number) { @@ -50,11 +65,15 @@ function translator(tap: ReturnType) { }) } -/** Every terminal write for a turn, in order: what a reader could observe. */ +/** Every lifecycle write for a turn, in order: what a reader could observe. */ +function turnWrites(rows: readonly Row[], turnId: string) { + return rows.flatMap((row) => + row.body.kind === 'turn' && row.body.turnId === turnId ? [row.body] : [] + ) +} + function terminalWrites(rows: readonly Row[], turnId: string) { - return rows - .map((row) => row.body) - .filter((body) => body.kind === 'turn' && body.turnId === turnId && body.state !== 'running') + return turnWrites(rows, turnId).filter((body) => body.state !== 'running') } /** The body the journal reducer keeps for a turn's lifecycle row. */ @@ -109,8 +128,8 @@ function runFailedTurn(handle: (event: CodexStructuredSessionEvent) => unknown) ) } -describe('a Codex turn settles once', () => { - it('keeps the failure the error settled when the failed completion follows it', () => { +describe('a Codex turn ends once, on its completion', () => { + it('records the failed completion Codex sends after the error, with the start this host saw', () => { const tap = recorder() const codex = translator(tap) @@ -125,35 +144,105 @@ describe('a Codex turn settles once', () => { userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), startedAt: 1_000, requestedAt: 900, - completedAt: 2_000 + completedAt: 2_100, + durationMs: 1_100 }) expect( tap.rows.filter((row) => row.body.kind === 'status' && row.body.tone === 'error') ).toHaveLength(1) }) - it('ignores a duplicate completion for a turn it already settled', () => { + it('records an exit between the error and the completion as interrupted, with no verdict', () => { + // Orca records only an end it observed. The exit is that end; the failure + // Codex would have named arrives only in the completion, which never came. const tap = recorder() const codex = translator(tap) + const events: CodexStructuredSessionEvent[] = [] + runFailedTurn((event) => events.push(event)) + + for (const event of events.slice(0, -1)) { + codex.handle(event) + } + codex.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'lost child', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1', + observedAt: 2_050 + }) + + expect(terminalWrites(tap.rows, TURN_ID)).toEqual([ + { + kind: 'turn', + turnId: TURN_ID, + state: 'interrupted', + userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), + startedAt: 1_000, + requestedAt: 900, + completedAt: 2_050 + } + ]) + expect( + tap.rows.filter((row) => row.body.kind === 'status' && row.body.tone === 'error') + ).toHaveLength(1) + }) + + it('revises the still-open turn with a send Codex echoes between the error and the completion', () => { + // Codex records a failed turn's pending input after its `error` frame and + // before `turn/completed`, so the echo belongs to a turn that is still open. + const tap = recorder() + const codex = translator(tap) + const events: CodexStructuredSessionEvent[] = [] + runFailedTurn((event) => events.push(event)) + const [started, echo, reply, error, completion] = events + + for (const event of [started, reply, error, echo, completion]) { + if (event) { + codex.handle(event) + } + } + + expect(turnWrites(tap.rows, TURN_ID).map((body) => body.state)).toEqual([ + 'running', + 'running', + 'completed' + ]) + expect(settledRecord(tap.rows, TURN_ID)).toMatchObject({ + outcome: 'failure', + userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), + requestedAt: 900, + startedAt: 1_000, + durationMs: 1_100 + }) + }) + + it('settles once when the sink refuses the completion and its retry lands', () => { + // A refused frame is Orca's only redelivery of a completion, and a refused + // end changes nothing, so the retry settles the turn exactly once. + const tap = recorder() + const codex = translator(tap) + const completion = notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'completed', durationMs: 900 } }, + 2_000 + ) codex.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) - codex.handle( - notification( - 'turn/completed', - { turn: { id: TURN_ID, status: 'completed', durationMs: 900 } }, - 2_000 - ) - ) - codex.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } }, 3_000)) + tap.refuseNextTerminalWrite() + expect(codex.handle(completion)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(codex.handle(completion)).toEqual({ accepted: true }) - expect(terminalWrites(tap.rows, TURN_ID)).toHaveLength(1) - expect(settledRecord(tap.rows, TURN_ID)).toMatchObject({ - state: 'completed', - outcome: 'success', - startedAt: 1_000, - completedAt: 2_000, - durationMs: 900 - }) + expect(terminalWrites(tap.rows, TURN_ID)).toEqual([ + expect.objectContaining({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000, + durationMs: 900 + }) + ]) }) it('settles an ordinary turn exactly as before', () => { diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.ts b/src/main/codex/codex-structured-journal-translation-turn-state.ts index 832f57f3096..4c1e739705d 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.ts @@ -57,12 +57,6 @@ export class CodexJournalActiveTurns { return [...(this.byThread.get(threadId) ?? [])].at(-1) ?? null } - /** Whether this turn is still open here. A terminal row already written carries - * the turn's start and duration, so a later end must not overwrite it. */ - isActive(threadId: string, turnId: string): boolean { - return this.byThread.get(threadId)?.has(turnId) === true - } - startedAt(threadId: string, turnId: string): number | undefined { return this.startedAtByTurn.get(this.turnKey(threadId, turnId)) } @@ -167,8 +161,7 @@ type RecentTurn = { bytes: number } -/** Bounded terminal lifecycle window: exact echoes that arrive after completion - * revise it, and a later end for a turn in it is not a second settlement. */ +/** Bounded terminal lifecycle window: exact echoes that arrive after completion revise it. */ export class CodexJournalRecentTurns { private readonly turns = new Map() private retainedBytes = 0 @@ -232,10 +225,6 @@ export class CodexJournalRecentTurns { } } - has(threadId: string, turnId: string): boolean { - return this.turns.has(this.turnKey(threadId, turnId)) - } - requestOriginRevision( threadId: string, turnId: string, diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 8678c175599..52dca66a35b 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -43,12 +43,12 @@ export function codexTurnLifecycleBody( return agentJournalTurnBody(turnLifecycle) } -/** Maps a `turn/completed` status; a missing one is a clean finish. A terminal - * `error` also ends a turn and names its own outcome rather than coming here. */ +/** Maps a `turn/completed` status, live or restored. Only `interrupted` is a stop; + * a failed turn completed, and `codexTurnOutcome` says it failed. */ export function codexTurnLifecycleState( status: string | null ): Extract { - return status === null || status === 'completed' ? 'completed' : 'interrupted' + return status === 'interrupted' ? 'interrupted' : 'completed' } /** diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index c4fba2c1370..72c1026452d 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -8,7 +8,6 @@ import { CodexJournalGenericFrames } from './codex-structured-journal-generic-fr import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' -import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { CodexSubagentRoster } from './codex-subagent-roster' @@ -39,7 +38,6 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator producerDeps, (threadId, itemId) => items.detailFor(threadId, itemId), activeTurn - ), - settleOversizedNotification: createCodexOversizedNotificationSettler(producerDeps, items) + ) } } diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index d902b7b4725..676d9054ed7 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -287,7 +287,7 @@ describe('codex journal translation', () => { ).toBe('idle') }) - describe('a terminal error ends the turn it names', () => { + describe('a turn-ending error is a row on the turn it names, and the completion ends it', () => { function statusOf(rows: readonly Row[]) { return projectStructuredAgentSessionStatus( reduced(rows).map((row, sequence) => ({ @@ -308,7 +308,11 @@ describe('codex journal translation', () => { }) } - it('settles the turn and keeps the provider sentence as its own row', () => { + const FAILED_COMPLETION = notification('turn/completed', { + turn: { id: TURN_ID, status: 'failed', durationMs: 2_400 } + }) + + it('keeps the turn working through the error, and the failed completion settles it', () => { const tap = recorder() const translator = createCodexJournalTranslator({ sink: tap.sink, @@ -316,17 +320,21 @@ describe('codex journal translation', () => { }) translator.handle(TURN_STARTED) - expect(statusOf(tap.rows)).toBe('working') - translator.handle(errorNotification({ turnId: TURN_ID, willRetry: false })) + expect(statusOf(tap.rows)).toBe('working') + expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(1) + + translator.handle(FAILED_COMPLETION) + expect(statusOf(tap.rows)).toBe('idle') expect(reduced(tap.rows).map((row) => row.body)).toContainEqual( expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'completed', - outcome: 'failure' + outcome: 'failure', + durationMs: 2_400 }) ) // The message the user reads is still a row of its own. @@ -353,7 +361,7 @@ describe('codex journal translation', () => { expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(1) }) - it('does not overwrite the terminal row of a turn that already completed', () => { + it('writes only its row for an error after the turn completed', () => { const tap = recorder() const translator = createCodexJournalTranslator({ sink: tap.sink, @@ -361,21 +369,16 @@ describe('codex journal translation', () => { }) translator.handle(TURN_STARTED) - translator.handle( - notification('turn/completed', { - turn: { id: TURN_ID, status: 'failed', durationMs: 4_000 } - }) - ) + translator.handle(FAILED_COMPLETION) const settled = reduced(tap.rows).find((row) => row.body.kind === 'turn')?.body translator.handle(errorNotification({ turnId: TURN_ID, willRetry: false })) - // The completion carries the duration a late error could not reconstruct. expect(reduced(tap.rows).find((row) => row.body.kind === 'turn')?.body).toEqual(settled) expect(statusOf(tap.rows)).toBe('idle') }) - it('settles the running turn when the error names no turn', () => { + it('settles nothing when the error names no turn', () => { const tap = recorder() const translator = createCodexJournalTranslator({ sink: tap.sink, @@ -385,21 +388,8 @@ describe('codex journal translation', () => { translator.handle(TURN_STARTED) translator.handle(errorNotification({ willRetry: false })) - expect(statusOf(tap.rows)).toBe('idle') - }) - - it('does not reopen a running row when the completion arrives after the error', () => { - const tap = recorder() - const translator = createCodexJournalTranslator({ - sink: tap.sink, - primaryThreadId: () => THREAD_ID - }) - - translator.handle(TURN_STARTED) - translator.handle(errorNotification({ turnId: TURN_ID, willRetry: false })) - translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } })) - - expect(statusOf(tap.rows)).toBe('idle') + expect(statusOf(tap.rows)).toBe('working') + expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(1) }) }) @@ -446,14 +436,12 @@ describe('codex journal translation', () => { expect(stopped).toEqual([]) }) - it('releases after systemError arrives before the terminal error notification', () => { + it('releases after the failed completion that follows systemError and the error', () => { const stopped: string[] = [] const { translator } = translatorReporting(stopped) translator.handle(TURN_STARTED) translator.handle(statusChanged('systemError')) - expect(stopped).toEqual([]) - translator.handle( notification('error', { turnId: TURN_ID, @@ -461,6 +449,9 @@ describe('codex journal translation', () => { error: { message: 'fatal' } }) ) + expect(stopped).toEqual([]) + + translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } })) expect(stopped).toEqual([THREAD_ID]) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fb90cb73128..fadab8b4589 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -11,9 +11,8 @@ import { restoreCodexJournalThread } from './codex-structured-journal-translatio import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { createCodexJournalTranslatorWriters } from './codex-structured-journal-translation-writers' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' -import { readCodexProviderVerdict } from './codex-structured-journal-provider-verdicts' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' -import { readCodexTurnId } from './codex-structured-thread-facts' +import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' export type { @@ -36,17 +35,8 @@ export { export function createCodexJournalTranslator( deps: CodexJournalTranslatorDeps ): CodexJournalTranslator { - const { - activeTurns, - subagents, - linkageFor, - genericFrames, - items, - compactions, - goals, - prompts, - settleOversizedNotification - } = createCodexJournalTranslatorWriters(deps) + const { activeTurns, subagents, linkageFor, genericFrames, items, compactions, goals, prompts } = + createCodexJournalTranslatorWriters(deps) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } let readActivity = createCodexProviderActivityReader() @@ -205,10 +195,6 @@ export function createCodexJournalTranslator( ) } if (event.type === 'provider-frame') { - const settlement = settleOversizedNotification(event) - if (settlement && !settlement.accepted) { - return settlement - } return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } if (event.method === 'turn/started' || event.method === 'turn/completed') { @@ -249,28 +235,24 @@ export function createCodexJournalTranslator( return publishActivity(event, routed) } } - const verdict = readCodexProviderVerdict(event.method, event.params) + // A thread that stopped running settles no open turn: Codex clears `running` + // on every error, and an open turn ends on its `turn/completed`. It releases + // a send whose dispatch was never answered, which nothing else re-derives live. if ( - verdict === 'thread-stopped-running' && + event.method === 'thread/status/changed' && + codexThreadStoppedRunning(event.params) && event.threadId === (deps.primaryThreadId?.() ?? null) ) { primaryThreadStoppedRunning = true reportPrimaryThreadStoppedRunning() } - // The row carries the provider's sentence and is written first, so it lands - // inside the turn this same frame is about to end. + // A turn-ending `error` is a row inside the turn it names; the failed + // `turn/completed` Codex sends after it is that turn's end. const unhandled = genericFrames.appendUnhandled( `notification:${event.method}`, event.params, event.threadId ) - if (unhandled.accepted && verdict === 'turn-failed') { - const failed = turnBoundaries.fail(event) - if (!failed.accepted) { - return failed - } - reportPrimaryThreadStoppedRunning() - } return publishActivity(event, unhandled) }, cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), diff --git a/src/main/codex/codex-structured-journal-turn-endings-replay.test.ts b/src/main/codex/codex-structured-journal-turn-endings-replay.test.ts new file mode 100644 index 00000000000..d1de7e74766 --- /dev/null +++ b/src/main/codex/codex-structured-journal-turn-endings-replay.test.ts @@ -0,0 +1,206 @@ +// Real `codex app-server` frame orders, replayed through the translator. +// +// The fixture is trimmed from captures of the real binary on 0.141.0 (the oldest +// version Orca exercises) and 0.158.0, with only the upstream provider faked: +// statuses, `willRetry`, the provider's sentence and `durationMs`, at the host +// receipt time `t` each frame arrived. Ids are replaced with synthetic ones. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { + readCodexThreadId, + readCodexTurnDurationMs, + readCodexTurnId, + readCodexTurnStatus +} from './codex-structured-thread-facts' + +const SESSION_ID = 'session-1' + +type CapturedFrame = { case: string; t: number; method: string; params: Record } +type Row = { key: string; body: AgentJournalItemBody } + +const FRAMES: CapturedFrame[] = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' +) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + +function framesOf(name: string): CapturedFrame[] { + const frames = FRAMES.filter((frame) => frame.case === name) + expect(frames.length).toBeGreaterThan(0) + return frames +} + +/** Replays a case's frames, or only its first `count`. */ +function replay(name: string, count = Number.POSITIVE_INFINITY) { + const frames = framesOf(name).slice(0, count) + const threadId = readCodexThreadId(frames[0]?.params) ?? '' + const rows: Row[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => {}, + publish: () => {} + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: SESSION_ID, + primaryThreadId: () => threadId + }) + for (const frame of frames) { + expect( + translator.handle({ + type: 'notification', + sessionId: SESSION_ID, + threadId, + method: frame.method, + params: frame.params, + observedAt: frame.t + }) + ).toEqual({ accepted: true }) + } + return { frames, rows, translator } +} + +function turnWrites(rows: readonly Row[], turnId: string) { + return rows + .map((row) => row.body) + .filter((body) => body.kind === 'turn' && body.turnId === turnId) +} + +function terminalWrites(rows: readonly Row[], turnId: string) { + return turnWrites(rows, turnId).filter((body) => body.kind === 'turn' && body.state !== 'running') +} + +function receipt(frames: readonly CapturedFrame[], method: string, turnId: string): number { + const frame = frames.find( + (entry) => entry.method === method && readCodexTurnId(entry.params) === turnId + ) + if (!frame) { + throw new Error(`no ${method} for ${turnId}`) + } + return frame.t +} + +function completion(frames: readonly CapturedFrame[], turnId: string) { + const frame = frames.find( + (entry) => entry.method === 'turn/completed' && readCodexTurnId(entry.params) === turnId + ) + return frame + ? { + t: frame.t, + status: readCodexTurnStatus(frame.params), + durationMs: readCodexTurnDurationMs(frame.params) + } + : null +} + +const VERDICT: Record = { + completed: { state: 'completed', outcome: 'success' }, + failed: { state: 'completed', outcome: 'failure' }, + interrupted: { state: 'interrupted', outcome: 'cancellation' } +} + +const ENDED_CASES = [...new Set(FRAMES.map((frame) => frame.case))].filter( + (name) => name !== '0.158.0-conn-refused' +) + +describe('captured Codex turns end once, on their own completion', () => { + it.each(ENDED_CASES)('%s', (name) => { + const { frames, rows } = replay(name) + const turnIds = frames + .filter((frame) => frame.method === 'turn/started') + .map((frame) => readCodexTurnId(frame.params) ?? '') + + expect(turnIds.length).toBeGreaterThan(0) + for (const turnId of turnIds) { + const end = completion(frames, turnId) + expect(end).not.toBeNull() + if (!end) { + continue + } + const verdict = VERDICT[end.status ?? ''] + expect(verdict).toBeDefined() + // One terminal write per turn, and it is Codex's own completion: its time, + // its duration and its verdict, with the start this host saw. + expect(terminalWrites(rows, turnId)).toEqual([ + expect.objectContaining({ + state: verdict?.state, + outcome: verdict?.outcome, + startedAt: receipt(frames, 'turn/started', turnId), + completedAt: end.t, + durationMs: end.durationMs + }) + ]) + } + }) + + it('keeps every failed turn working through its error until the completion', () => { + for (const name of ENDED_CASES) { + const frames = framesOf(name) + const failedTurns = frames + .filter((frame) => frame.method === 'error' && frame.params.willRetry === false) + .map((frame) => readCodexTurnId(frame.params) ?? '') + for (const turnId of failedTurns) { + const errorAt = frames.findIndex( + (frame) => + frame.method === 'error' && + frame.params.willRetry === false && + readCodexTurnId(frame.params) === turnId + ) + // Through the error, the turn is open; the completion right after it ends it. + expect(terminalWrites(replay(name, errorAt + 1).rows, turnId)).toEqual([]) + expect(terminalWrites(replay(name).rows, turnId)).toHaveLength(1) + } + } + }) + + it('keeps a turn Codex is still retrying on 0.158.0 running, and the exit sweep ends it', () => { + const { frames, rows, translator } = replay('0.158.0-conn-refused') + const turnId = + readCodexTurnId(frames.find((frame) => frame.method === 'turn/started')?.params) ?? '' + const retries = frames.filter((frame) => frame.method === 'error') + + // Seven "Reconnecting... waiting for network" frames over four minutes, all + // willRetry, and no completion: the turn is genuinely still open. + expect(retries).toHaveLength(7) + expect(retries.every((frame) => frame.params.willRetry === true)).toBe(true) + expect(turnWrites(rows, turnId)).toEqual([ + expect.objectContaining({ + state: 'running', + startedAt: receipt(frames, 'turn/started', turnId) + }) + ]) + + translator.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'lost child', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1', + observedAt: 250_000 + }) + + expect(terminalWrites(rows, turnId)).toEqual([ + { + kind: 'turn', + turnId, + state: 'interrupted', + userItemId: `codex:${readCodexThreadId(frames[0]?.params)}:${turnId}:0`, + startedAt: receipt(frames, 'turn/started', turnId), + completedAt: 250_000 + } + ]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index 6459b59755c..4e3caaa8bc0 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -73,8 +73,14 @@ async function session() { } } +function turnRecord(rows: Awaited>['items']>>) { + return rows + .map((item) => readAgentJournalTurn(item.body)) + .findLast((record) => record?.turnId === TURN) +} + describe('a failed Codex turn in the journal', () => { - it('keeps the failure and its duration when the failed completion lands while the error is still queued', async () => { + it('keeps the failed completion, its duration and the start, when it lands while the error is still queued', async () => { const { on, drained, items } = await session() on('turn/started', { turn: { id: TURN } }, 1_000) on( @@ -85,7 +91,7 @@ describe('a failed Codex turn in the journal', () => { await drained() // Codex writes both frames back to back; the error's status row is still being - // written when the failed completion arrives, so the error's settlement is queued. + // written when the failed completion arrives. on( 'error', { turnId: TURN, willRetry: false, error: { message: 'stream disconnected' } }, @@ -94,18 +100,50 @@ describe('a failed Codex turn in the journal', () => { on('turn/completed', { turn: { id: TURN, status: 'failed', durationMs: 1_900 } }, 3_100) const rows = await items() - const turn = rows - .map((item) => readAgentJournalTurn(item.body)) - .findLast((record) => record?.turnId === TURN) - expect(turn).toMatchObject({ + expect(turnRecord(rows)).toMatchObject({ state: 'completed', outcome: 'failure', startedAt: 1_000, - completedAt: 3_000 + completedAt: 3_100, + durationMs: 1_900 }) - // The duration "Worked for" shows under the turn's message. + // "Worked for" under the turn's message reads Codex's own duration. expect( completedStructuredAgentTurnSeconds(selectStructuredAgentRunningTurnTiming(rows, TURN)) - ).toBe(2) + ).toBe(1) }) + + // Codex ends a turn once, so a second completion is not expected. If one came, + // the settlement id keeps the first record, whether it is still queued or written. + it.each([ + ['still queued', false], + ['already written', true] + ] as const)( + 'keeps the first completion when a second one arrives while the first is %s', + async (_label, drainFirst) => { + const { on, drained, items } = await session() + on('turn/started', { turn: { id: TURN } }, 1_000) + await drained() + + // The reply's row is being written, so the first completion waits behind it. + on( + 'item/completed', + { turnId: TURN, item: { type: 'agentMessage', id: 'agent-1', text: 'Done' } }, + 1_900 + ) + on('turn/completed', { turn: { id: TURN, status: 'completed', durationMs: 900 } }, 2_000) + if (drainFirst) { + await drained() + } + on('turn/completed', { turn: { id: TURN, status: 'failed' } }, 3_000) + + expect(turnRecord(await items())).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000, + durationMs: 900 + }) + } + ) }) diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index 755006d17fa..f2a42e6d3db 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -62,12 +62,6 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null } -/** `error` carries `willRetry`: Codex sets it on a stream error it is about to - * retry, and omits it (false) on one that ended the turn the frame names. */ -export function readCodexErrorWillRetry(payload: unknown): boolean { - return record(payload)?.willRetry === true -} - /** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a * bare string. `idle` and `systemError` are the two arms that mean the thread * is not running; `active` and `notLoaded` are not. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index c9a32533db4..428a3c1c0d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -15,6 +15,8 @@ export type StructuredAgentSessionSinkOperation = { lifecycleBytes?: number lifecycle?: boolean coalescingKey?: string + /** The queued operation with this key wins, as the journal keeps a settlement's first batch. */ + keepsFirst?: boolean run: (target: StructuredAgentSessionEventTarget) => Promise | void } @@ -117,10 +119,13 @@ export class StructuredAgentSessionSinkQueue { if (this.failure !== null) { return { accepted: false, reason: 'failed' } } - const sequence = ++this.acceptedSequence const key = options.coalescingKey ?? operation.coalescingKey const replaceAt = key ? this.queue.findIndex((queued) => queued.coalescingKey === key) : -1 const replaced = replaceAt >= 0 ? this.queue[replaceAt] : undefined + if (replaced && operation.keepsFirst) { + return { accepted: true } + } + const sequence = ++this.acceptedSequence const lifecycle = operation.lifecycle ?? options.lifecycle === true const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 const nextBytes = this.queuedBytes - (replaced?.bytes ?? 0) + operation.bytes diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 6c3747fb824..b8c1d0b7c0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -384,6 +384,32 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 6, ordinal: 1 }]) }) + it('keeps the first queued lifecycle batch for a settlement, as the journal does', async () => { + // The journal applies a settlement id once and skips any later batch with it, + // so the queue must not let a later batch replace one it has not run yet. + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + const batch = (ordinal: number) => [ + { kind: 'item' as const, identity: identity(ordinal), body: BODY } + ] + + deferred.sink.appendLifecycleBatch?.('turn-completed:turn-1', batch(0)) + expect(deferred.sink.tryAppendLifecycleBatch?.('turn-completed:turn-1', batch(1))).toEqual({ + accepted: true + }) + expect(deferred.state().queuedOperations).toBe(1) + + const bound = target(6, log) + deferred.bind(bound) + await deferred.drained() + + expect( + vi + .mocked(bound.journal.appendLifecycleBatch) + .mock.calls.map(([input]) => input.mutations.map((mutation) => mutation.identity)) + ).toEqual([[identity(0)]]) + }) + it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 923342b7fa6..ad92e1c24ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -205,6 +205,7 @@ export function createDeferredStructuredAgentSessionEventSink( { bytes: Buffer.byteLength(JSON.stringify({ settlementId, mutations }), 'utf8') + 512, coalescingKey: `lifecycle:${settlementId}`, + keepsFirst: true, run: (bound) => bound.journal.appendLifecycleBatch({ settlementId, From b283a096885a999cacaf847162f6808c0b267321 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:47:51 -0700 Subject: [PATCH 328/387] fix(native-chat): stop flashing "still starting" on every chat launch (#23666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): stop flashing "still starting" on every chat launch Every structured chat passes through a short startup phase, and the pane showed " is still starting…" for all of it, so a normal launch flashed the notice for a fraction of a second. The notice now goes through a keyed delayed status: it appears only once startup outlasts a grace period, stays up for a minimum time once shown, and resets per session. * fix(native-chat): reset startup notice for each provider child --- ...ed-agent-session-status-feed-child.test.ts | 76 ++++++++++++++ ...-agent-session-status-feed-test-session.ts | 3 +- ...ructured-agent-session-status-feed.test.ts | 28 ++---- .../structured-agent-session-status-feed.ts | 19 +++- ...red-agent-session-subagent-recency.test.ts | 8 +- ...ree-structured-agent-rows-liveness.test.ts | 2 +- .../NativeChatStructuredSession.tsx | 9 +- ...NativeChatStructuredSessionStatus.test.tsx | 98 +++++++++++++++++-- .../NativeChatStructuredSessionStatus.tsx | 12 ++- ...tructuredAgentSessionStatusBridge.test.tsx | 96 +++++++++++++++--- .../StructuredAgentSessionStatusBridge.tsx | 20 +++- src/renderer/src/hooks/use-delayed-status.ts | 17 ++++ src/renderer/src/lib/delayed-status.test.ts | 89 +++++++++++++++++ src/renderer/src/lib/delayed-status.ts | 92 +++++++++++++++++ ...gent-session-status-feed-lifecycle.test.ts | 11 ++- .../structured-agent-session-status-feed.ts | 7 +- src/shared/agent-session-wire.ts | 3 + 17 files changed, 533 insertions(+), 57 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts create mode 100644 src/renderer/src/hooks/use-delayed-status.ts create mode 100644 src/renderer/src/lib/delayed-status.test.ts create mode 100644 src/renderer/src/lib/delayed-status.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts new file mode 100644 index 00000000000..78e10bec076 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' + +const SESSION = 'status-session' +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-child-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +it('publishes each provider child even when a replacement has the same startup phase', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) + const sessions = new Map>() + const setChild = ( + child: { + phase: 'starting' | 'ready' + generation: string + fence: number + } | null + ) => sessions.set(SESSION, indexedStatusFeedSession({ journal, child })) + setChild({ phase: 'starting', generation: 'child-1', fence: 1 }) + const events: AgentSessionStatusEvent[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => 1 + }) + const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) + expect(events.at(-1)).toMatchObject({ + type: 'snapshot', + sessions: [ + { + hostExecutionOwned: true, + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-1', fence: 1 } + } + ] + }) + setChild({ phase: 'starting', generation: 'child-2', fence: 2 }) + feed.publish(SESSION, journal) + expect(events.at(-1)).toMatchObject({ + session: { + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-2', fence: 2 } + } + }) + setChild({ phase: 'ready', generation: 'child-2', fence: 2 }) + feed.publish(SESSION, journal) + expect(events.at(-1)).toMatchObject({ session: { hostExecutionPhase: 'ready' } }) + setChild(null) + feed.publish(SESSION, journal) + expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionPhase: expect.any(String) } }) + expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionChild: expect.any(Object) } }) + dispose() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts index c130aaf9d59..da7275cc641 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -1,8 +1,9 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' export function indexedStatusFeedSession(session: { journal: AgentSessionJournal - child?: { phase: 'starting' | 'ready' } | null + child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null }) { return { journal: session.journal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index a26cd324217..eda619694e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -95,27 +95,11 @@ function feedFor( } describe('StructuredAgentSessionStatusFeed', () => { - it('projects whether the owned child has proven its start, and nothing once it is not owned', async () => { - const journal = await openJournal() - const session = { journal, child: { phase: 'starting' as const } } - const sessions = new Map[0]>([[SESSION, session]]) - const { feed, events, dispose } = feedFor(sessions) - expect(events.at(-1)).toMatchObject({ - type: 'snapshot', - sessions: [{ hostExecutionOwned: true, hostExecutionPhase: 'starting' }] - }) - sessions.set(SESSION, { ...session, child: { phase: 'ready' } }) - feed.publish(SESSION, journal) - expect(events.at(-1)).toMatchObject({ session: { hostExecutionPhase: 'ready' } }) - sessions.set(SESSION, { ...session, child: null }) - feed.publish(SESSION, journal) - expect(events.at(-1)).not.toMatchObject({ session: { hostExecutionPhase: expect.any(String) } }) - dispose() - }) - it('publishes provider ownership transitions without changing journal time', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal, child: { phase: 'ready' } }]]) + const sessions = new Map([ + [SESSION, { journal, child: { phase: 'ready', generation: 'child-1', fence: 1 } }] + ]) const { feed, events, dispose } = feedFor(sessions) events.length = 0 await journal.appendItem( @@ -800,7 +784,9 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks it('receives every change once, ownership revocation, and the forget edge', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal, child: { phase: 'ready' } }]]) + const sessions = new Map([ + [SESSION, { journal, child: { phase: 'ready', generation: 'child-1', fence: 1 } }] + ]) const { sink, published, forgotten } = sinkFor() const { feed } = feedFor(sessions, null, undefined, undefined, sink) await journal.appendItem( @@ -822,6 +808,8 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks feed.revokeLive(SESSION) expect(published.at(-1)).toMatchObject({ sessionId: SESSION, status: 'idle' }) expect(published.at(-1)?.hostExecutionOwned).toBeUndefined() + expect(published.at(-1)?.hostExecutionPhase).toBeUndefined() + expect(published.at(-1)?.hostExecutionChild).toBeUndefined() // Exactly what `close` does after eviction: the cache keeps the projection, the sink does not. sessions.delete(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 05937aca514..22a61a006bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -25,7 +25,7 @@ import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child- import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' import { StructuredAgentSessionStatusOwnership, @@ -46,7 +46,7 @@ export type StructuredAgentSessionStatusSubscriber = { type StatusFeedSession = { journal: AgentSessionJournal params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } - child?: { phase: StructuredAgentSessionProviderChildPhase } | null + child?: Pick | null } export type StructuredAgentSessionStatusFeedDeps = { @@ -73,6 +73,8 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.status === b.status && a.hostExecutionOwned === b.hostExecutionOwned && a.hostExecutionPhase === b.hostExecutionPhase && + a.hostExecutionChild?.generation === b.hostExecutionChild?.generation && + a.hostExecutionChild?.fence === b.hostExecutionChild?.fence && a.rewindBlockedReason === b.rewindBlockedReason && // A moved state clock changes ranking; row activity alone, including a subagent's, does not. // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, @@ -203,7 +205,12 @@ export class StructuredAgentSessionStatusFeed { if (!previous) { return } - const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous + const { + hostExecutionOwned: _hostExecutionOwned, + hostExecutionPhase: _hostExecutionPhase, + hostExecutionChild: _hostExecutionChild, + ...retained + } = previous this.published.set(sessionId, retained) this.sink(retained) this.broadcast({ @@ -273,7 +280,11 @@ export class StructuredAgentSessionStatusFeed { workspaceId: session.params.location.workspaceId, agent: session.params.provider, ...(session.child - ? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase } + ? { + hostExecutionOwned: true as const, + hostExecutionPhase: session.child.phase, + hostExecutionChild: { generation: session.child.generation, fence: session.child.fence } + } : {}), ...projected, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 4053988233f..cb8b3d7d7c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -64,7 +64,13 @@ async function openSession() { const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ - [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] + [ + SESSION, + indexedStatusFeedSession({ + journal, + child: { phase: 'ready', generation: 'child-1', fence: 1 } + }) + ] ]), getRecord: () => null, now: () => 1, diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 27d5ba48b29..a1373f43c71 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -89,7 +89,7 @@ async function awaitingApproval() { SESSION, { journal, - child: { phase: 'ready' as const }, + child: { phase: 'ready' as const, generation: 'child-1', fence: 1 }, params: { location: { executionHostId: 'local' as const, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6daa3d3f62c..b66f1770d33 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -22,7 +22,7 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' +import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' @@ -42,10 +42,10 @@ export function NativeChatStructuredSession( ) const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. - const startupPhase = useStructuredAgentSessionHostExecutionPhase(props.sessionId, props.target) + const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) const controller = useStructuredAgentSession({ ...props, - providerStarting: startupPhase === 'starting', + providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) @@ -298,7 +298,8 @@ export function NativeChatStructuredSession( { + cleanup() + vi.useRealTimers() +}) const NO_TASKS = { show: false, @@ -17,12 +24,17 @@ const NO_TASKS = { supportsStopAll: false } -function renderStatus(startupPhase: 'starting' | 'ready' | null) { - return render( +function statusElement( + startupPhase: 'starting' | 'ready' | null, + sessionId = 'session-1', + startupChildKey: string | null = null +) { + return ( { - it('says the agent is still starting while the host reports a starting child', () => { + it('stays quiet through a normal startup', () => { + vi.useFakeTimers() + const view = renderStatus('starting') + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - 1)) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + view.rerender(statusElement('ready')) + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + }) + + it('says the agent is still starting once startup runs long', () => { + vi.useFakeTimers() renderStatus('starting') + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) expect(screen.getByText(/Claude is still starting/)).toBeInTheDocument() expect(screen.getByText(/close this chat/)).toBeInTheDocument() }) + it('restarts the grace period for a relaunch or another session', () => { + vi.useFakeTimers() + const view = renderStatus('starting') + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) + view.rerender(statusElement('ready')) + act(() => vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS)) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + view.rerender(statusElement('starting')) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) + expect(screen.getByText(/still starting/)).toBeInTheDocument() + view.rerender(statusElement('starting', 'session-2')) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + }) + + it('hides an old notice and gives a replacement child its full grace period', () => { + vi.useFakeTimers() + const view = render(statusElement('starting', 'session-1', 'child-1')) + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) + expect(screen.getByText(/still starting/)).toBeInTheDocument() + view.rerender(statusElement('ready', 'session-1', 'child-1')) + act(() => vi.advanceTimersByTime(100)) + view.rerender(statusElement('starting', 'session-1', 'child-2')) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS)) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - STATUS_MIN_VISIBLE_MS)) + expect(screen.getByText(/still starting/)).toBeInTheDocument() + }) + + it('resets for a replacement child even when no intermediate phase reaches the view', () => { + vi.useFakeTimers() + const view = render(statusElement('starting', 'session-1', 'child-1')) + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) + view.rerender(statusElement('starting', 'session-1', 'child-2')) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - 1)) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByText(/still starting/)).toBeInTheDocument() + }) + + it('resets on an observed restart when an older host has no child identity', () => { + vi.useFakeTimers() + const view = renderStatus('starting') + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS)) + view.rerender(statusElement('ready')) + act(() => vi.advanceTimersByTime(100)) + view.rerender(statusElement('starting')) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(SLOW_STARTUP_NOTICE_DELAY_MS - 1)) + expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByText(/still starting/)).toBeInTheDocument() + }) + it('shows nothing about startup once the child is ready or the host has no word', () => { renderStatus('ready') expect(screen.queryByText(/still starting/)).not.toBeInTheDocument() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index 2f33b0ed0c3..31518886150 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { translate } from '@/i18n/i18n' +import { useDelayedStatus } from '@/hooks/use-delayed-status' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' @@ -11,12 +12,16 @@ type StoppingBackgroundTasks = { const NO_STOPPING_TASKS: ReadonlySet = new Set() +// Why: every launch passes through `starting`; only a slow start deserves the notice. +export const SLOW_STARTUP_NOTICE_DELAY_MS = 5_000 + export function NativeChatStructuredSessionStatus(props: { sessionId: string /** What to call the agent in copy about its process. */ agentLabel: string /** The host's word on the provider child; `starting` is published but not yet answering. */ startupPhase: 'starting' | 'ready' | null + startupChildKey: string | number | null error: string | null /** A read the pane is reconnecting on its own: said plainly, not as an error. */ reconnecting?: boolean @@ -28,6 +33,11 @@ export function NativeChatStructuredSessionStatus(props: { const [stopping, setStopping] = useState(null) const [expanded, setExpanded] = useState<{ sessionId: string; expanded: boolean } | null>(null) const activeStopping = stopping?.sessionId === props.sessionId ? stopping : null + const shownStartupPhase = useDelayedStatus( + JSON.stringify([props.sessionId, typeof props.startupChildKey, props.startupChildKey]), + props.startupPhase === 'starting' ? 'starting' : null, + SLOW_STARTUP_NOTICE_DELAY_MS + ) const onStop = (taskId?: string) => { const sessionId = props.sessionId @@ -61,7 +71,7 @@ export function NativeChatStructuredSessionStatus(props: { return ( <> - {props.startupPhase === 'starting' ? ( + {shownStartupPhase === 'starting' ? (

{translate( 'auto.components.native.chat.NativeChatStructuredSessionStatus.starting', diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 6cdaa2646e4..42091c79de9 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -64,7 +64,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { getStructuredAgentSessionTabs, StructuredAgentSessionStatusBridge, - useStructuredAgentSessionHostExecutionPhase + useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' @@ -698,28 +698,102 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).not.toHaveBeenCalled() }) - it('re-renders a startup-phase reader only when the phase changes', async () => { - const phases: (string | null)[] = [] + it('re-renders a startup reader only when its phase or child changes', async () => { + const executions: ReturnType[] = [] function PhaseProbe(): null { - phases.push(useStructuredAgentSessionHostExecutionPhase('session-1', { kind: 'local' })) + executions.push(useStructuredAgentSessionHostExecution('session-1', { kind: 'local' })) return null } render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) - act(() => feed().emit({ type: 'status', session: summary({ hostExecutionPhase: 'starting' }) })) - const rendersWhileStarting = phases.length act(() => feed().emit({ type: 'status', - session: summary({ hostExecutionPhase: 'starting', latestPrompt: 'next', updatedAt: 2 }) + session: summary({ + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-1', fence: 1 } + }) }) ) - expect(phases).toHaveLength(rendersWhileStarting) + const rendersWhileStarting = executions.length + act(() => + feed().emit({ + type: 'status', + session: summary({ + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-1', fence: 1 }, + latestPrompt: 'next', + updatedAt: 2 + }) + }) + ) + expect(executions).toHaveLength(rendersWhileStarting) + act(() => + feed().emit({ + type: 'status', + session: summary({ + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-1', fence: 2 } + }) + }) + ) + expect(executions).toHaveLength(rendersWhileStarting) - act(() => feed().emit({ type: 'status', session: summary({ hostExecutionPhase: 'ready' }) })) - expect(phases.at(-1)).toBe('ready') - expect(phases).toContain('starting') + act(() => + feed().emit({ + type: 'status', + session: summary({ + hostExecutionPhase: 'ready', + hostExecutionChild: { generation: 'child-1', fence: 1 } + }) + }) + ) + expect(executions.at(-1)?.phase).toBe('ready') + act(() => + feed().emit({ + type: 'status', + session: summary({ + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-2', fence: 2 } + }) + }) + ) + expect(executions.at(-1)).toEqual({ + phase: 'starting', + childKey: 'child-2' + }) + expect(executions.some(({ phase }) => phase === 'starting')).toBe(true) + }) + + it('uses the fence for a child whose acquisition has no generation', async () => { + const executions: ReturnType[] = [] + function PhaseProbe(): null { + executions.push(useStructuredAgentSessionHostExecution('session-1', { kind: 'local' })) + return null + } + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => + feed().emit({ + type: 'status', + session: summary({ + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: null, fence: 1 } + }) + }) + ) + expect(executions.at(-1)?.childKey).toBe(1) + act(() => + feed().emit({ + type: 'status', + session: summary({ + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: null, fence: 2 } + }) + }) + ) + expect(executions.at(-1)?.childKey).toBe(2) }) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index b0c546b3f9b..8205d96a5af 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -47,18 +47,30 @@ export function useStructuredAgentSessionStatusSummary( return { summary, observation } } -/** Only the host's startup phase, so a chat re-renders when that changes, not on every status. */ -export function useStructuredAgentSessionHostExecutionPhase( +/** The host's child state, projected to stable primitives so journal updates do not re-render chat. */ +export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget -): NonNullable | null { +): { + phase: NonNullable | null + childKey: string | number | null +} { const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) useEffect(() => feed.activate(), [feed]) - return useSyncExternalStore( + const phase = useSyncExternalStore( feed.subscribe, () => feed.getSnapshot().get(sessionId)?.hostExecutionPhase ?? null, () => null ) + const childKey = useSyncExternalStore( + feed.subscribe, + () => { + const child = feed.getSnapshot().get(sessionId)?.hostExecutionChild + return child?.generation ?? child?.fence ?? null + }, + () => null + ) + return { phase, childKey } } function projectStatus( diff --git a/src/renderer/src/hooks/use-delayed-status.ts b/src/renderer/src/hooks/use-delayed-status.ts new file mode 100644 index 00000000000..c895107bb4a --- /dev/null +++ b/src/renderer/src/hooks/use-delayed-status.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' +import { createDelayedStatus, type ShownStatus } from '@/lib/delayed-status' + +/** + * Returns `value` only once it has lasted past `showDelayMs`, then holds it for a + * minimum time, so a short status never flashes. `key` is what the status belongs + * to (for example a session); a new key drops it at once. + */ +export function useDelayedStatus(key: string, value: A | null, showDelayMs: number): A | null { + const [shown, setShown] = useState | null>(null) + const [status] = useState(() => createDelayedStatus(setShown, { showDelayMs })) + useEffect(() => () => status.dispose(), [status]) + useEffect(() => { + status.update(key, value) + }, [status, key, value]) + return shown?.key === key ? shown.value : null +} diff --git a/src/renderer/src/lib/delayed-status.test.ts b/src/renderer/src/lib/delayed-status.test.ts new file mode 100644 index 00000000000..11778a3d414 --- /dev/null +++ b/src/renderer/src/lib/delayed-status.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDelayedStatus, STATUS_MIN_VISIBLE_MS, type ShownStatus } from './delayed-status' + +const SHOW_DELAY_MS = 1_000 + +function track() { + const changes: (ShownStatus | null)[] = [] + const status = createDelayedStatus((shown) => changes.push(shown), { + showDelayMs: SHOW_DELAY_MS + }) + return { status, changes } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createDelayedStatus', () => { + it('never shows a status that clears within the show delay', () => { + const { status, changes } = track() + status.update('a', 'starting') + vi.advanceTimersByTime(SHOW_DELAY_MS - 1) + status.update('a', null) + vi.advanceTimersByTime(SHOW_DELAY_MS * 2) + expect(changes).toEqual([]) + }) + + it('shows a status that outlasts the show delay', () => { + const { status, changes } = track() + status.update('a', 'starting') + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes).toEqual([{ key: 'a', value: 'starting' }]) + }) + + it('keeps a shown status up for the minimum visible time', () => { + const { status, changes } = track() + status.update('a', 'starting') + vi.advanceTimersByTime(SHOW_DELAY_MS) + status.update('a', null) + vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS - 1) + expect(changes).toEqual([{ key: 'a', value: 'starting' }]) + vi.advanceTimersByTime(1) + expect(changes).toEqual([{ key: 'a', value: 'starting' }, null]) + }) + + it('hides at once when the status clears after the minimum visible time', () => { + const { status, changes } = track() + status.update('a', 'starting') + vi.advanceTimersByTime(SHOW_DELAY_MS + STATUS_MIN_VISIBLE_MS) + status.update('a', null) + expect(changes).toEqual([{ key: 'a', value: 'starting' }, null]) + }) + + it('restarts the show delay when an unkeyed status clears and returns during its hold', () => { + const { status, changes } = track() + status.update('a', 'starting') + vi.advanceTimersByTime(SHOW_DELAY_MS) + status.update('a', null) + vi.advanceTimersByTime(100) + status.update('a', 'starting') + expect(changes).toEqual([{ key: 'a', value: 'starting' }, null]) + vi.advanceTimersByTime(SHOW_DELAY_MS - 1) + expect(changes).toHaveLength(2) + vi.advanceTimersByTime(1) + expect(changes.at(-1)).toEqual({ key: 'a', value: 'starting' }) + }) + + it('drops the shown status at once for a new key, which waits its own delay', () => { + const { status, changes } = track() + status.update('a', 'starting') + vi.advanceTimersByTime(SHOW_DELAY_MS) + status.update('b', 'starting') + expect(changes).toEqual([{ key: 'a', value: 'starting' }, null]) + vi.advanceTimersByTime(SHOW_DELAY_MS) + expect(changes.at(-1)).toEqual({ key: 'b', value: 'starting' }) + }) + + it('does not show after dispose', () => { + const { status, changes } = track() + status.update('a', 'starting') + status.dispose() + vi.advanceTimersByTime(SHOW_DELAY_MS * 2) + expect(changes).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/delayed-status.ts b/src/renderer/src/lib/delayed-status.ts new file mode 100644 index 00000000000..8c388c966e4 --- /dev/null +++ b/src/renderer/src/lib/delayed-status.ts @@ -0,0 +1,92 @@ +/** How long a shown status stays up, so it cannot flash right at the show delay. */ +export const STATUS_MIN_VISIBLE_MS = 400 + +/** The status a view should show, and the key it belongs to. */ +export type ShownStatus = { + key: string + value: A +} + +export type DelayedStatus = { + /** Reports the real status for `key`. A new key drops the shown status at once. */ + update: (key: string, value: A | null) => void + /** Cancels pending timers. A later `update` starts again from the real status. */ + dispose: () => void +} + +/** + * Turns a real status into the status a view shows. A status that clears within + * `showDelayMs` is never shown; a shown status stays for at least `minVisibleMs`. + * Values compare by identity, so use strings or other stable values. + */ +export function createDelayedStatus( + onChange: (shown: ShownStatus | null) => void, + options: { showDelayMs: number; minVisibleMs?: number } +): DelayedStatus { + const minVisibleMs = options.minVisibleMs ?? STATUS_MIN_VISIBLE_MS + let key = '' + let latest: A | null = null + let shown: A | null = null + // While hidden, this is the show delay. While shown, the minimum visible time. + let timer: ReturnType | undefined + + const clearTimer = (): void => { + clearTimeout(timer) + timer = undefined + } + const hide = (): void => { + shown = null + onChange(null) + } + const show = (value: A): void => { + shown = value + onChange({ key, value }) + clearTimer() + timer = setTimeout(() => { + timer = undefined + if (latest === null) { + hide() + } + }, minVisibleMs) + } + + return { + update: (nextKey, value) => { + if (nextKey !== key) { + key = nextKey + clearTimer() + if (shown !== null) { + hide() + } + } + if (shown !== null && latest === null && value !== null) { + clearTimer() + hide() + } + latest = value + + if (shown === null) { + if (value === null) { + clearTimer() + } else if (timer === undefined) { + timer = setTimeout(() => { + timer = undefined + if (latest !== null) { + show(latest) + } + }, options.showDelayMs) + } + return + } + + if (value !== null) { + if (value !== shown) { + show(value) + } + } else if (timer === undefined) { + hide() + } + }, + dispose: clearTimer + } +} diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts index 4f98700d806..e192bddae52 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -27,7 +27,9 @@ const owned: AgentSessionStatusSummary = { status: 'working', latestPrompt: 'work', updatedAt: 1, - hostExecutionOwned: true + hostExecutionOwned: true, + hostExecutionPhase: 'starting', + hostExecutionChild: { generation: 'child-1', fence: 1 } } const done: AgentSessionStatusSummary = { ...owned, @@ -69,7 +71,12 @@ describe('structured status feed execution authority lifecycle', () => { subscription().emit({ type: 'snapshot', sessions: [owned, done] }) subscription().emit({ type: 'end' }) expect(subscription().unsubscribe).toHaveBeenCalledOnce() - expect(feed.getSnapshot().get('running')).toEqual({ ...owned, hostExecutionOwned: undefined }) + expect(feed.getSnapshot().get('running')).toEqual({ + ...owned, + hostExecutionOwned: undefined, + hostExecutionPhase: undefined, + hostExecutionChild: undefined + }) expect(feed.getSnapshot().get('completed')).toBe(done) subscription().emit({ type: 'status', session: owned }) expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 84bbcacdecc..7e4d7f27eb8 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -95,7 +95,12 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { if (!next) { next = new Map(snapshot) } - const { hostExecutionOwned: _owned, hostExecutionPhase: _phase, ...retained } = summary + const { + hostExecutionOwned: _owned, + hostExecutionPhase: _phase, + hostExecutionChild: _child, + ...retained + } = summary next.set(sessionId, retained) } if (next) { diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 445582c4ad3..513243f8f54 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -187,6 +187,9 @@ export type AgentSessionStatusSummary = { /** With `hostExecutionOwned`: whether that child has proven its start. `starting` is a * published session whose provider has not yet answered startup; absent on older hosts. */ hostExecutionPhase?: 'starting' | 'ready' + /** The current provider child, distinct from the conversation and from replacement children. + * Absent on older hosts and whenever this host has no live child. */ + hostExecutionChild?: { generation: string | null; fence: number } latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string From 2dc2693953705275c1d80477c555baa938631ace Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:48:34 -0700 Subject: [PATCH 329/387] fix(native-chat): a turn a proven crash cut short reads interrupted (#23456) * fix(native-chat): a turn a proven crash cut short reads interrupted, ending when it was last seen working * fix(native-chat): end a probe-proven turn at the last row the journal wrote live A revised item keeps its first sighting's timestamp, so a long command or a streamed reply read as ending when it started. The reducer now tracks the latest live row the same way it tracks all activity. * test(native-chat): give the unexpected-exit fake journal its live-activity read * refactor(native-chat): read the journal's live bound only for a probe-proven death * fix(native-chat): mark what a journal open settles for a gone host as crash reconciliation A crashed host's working roster is retired when the journal reopens. That row was written live, so a probe-proven turn ended at the relaunch and counted the downtime. * fix(native-chat): bound a probe-proven death with the last time Orca proved the owner alive A crash mid-tool left the turn ending at the tool call's start, because Claude writes nothing while a Bash call runs. The death evidence now records the lease's last renewal before the death as lastProvenAliveAt, and the turn ends at the later of that and the last live row, capped at the probe. Parking a lease in recovery no longer stamps lastRenewedAt, since nothing proved the owner alive then; a child that outlived Orca would otherwise have its turn count the downtime. * test(native-chat): a failed acquisition parked in recovery keeps its last proof of life, and the timing read goes through the display selector * refactor(native-chat): move the submission dispatch folds out of the journal reducer Main grew the reducer to its line limit, so the live-activity bound tipped it over. The dispatch row and echo-acceptance folds move unchanged into their own module. * test(native-chat): a send or reader that opens a crashed chat settles a proven death interrupted Main's open-time settle test still asserted the old rule, where only a watched exit proved a death. * docs(native-chat): say which proofs of death record a last proof of life * fix(native-chat): a proof of life bounds only the owner that wrote the turn A start after a crash that spawned a child and then failed without proving it gone parks that child for recovery; when recovery finds it gone, the proof of death carries its proof of life, which is after the crash. The older turn then ended there and counted the downtime. The journal now derives the fence of its newest live writer, and the lease that holds the proof names the owner it released by the fence it moved to. The last renewal counts only when that move was one step past the writer of the turn. * test(native-chat): a crash with a send in doubt still ends at the last proof of life The reopen settles that send at the new fence, so the owner check must read the fence of live rows only. * fix(native-chat): a proof of death judges only the turn its own owner wrote The settle read the record's latest proof of death for whatever turn a gone generation left running. After a crash, a start that reserved a new fence cleared the relaunch's proof, and if it then failed, its own child's death (a watched exit at the failure, or a probe finding the child it left for recovery gone) ended the older turn an hour after the crash. Every proof of death now records ownerFence, the fence of the owner or reservation it is about; a fence names exactly one owner. The journal derives the fence each item was created at, and a running turn is interrupted only by a proof naming its own owner; otherwise it is unverifiable. Evidence older builds wrote keeps their rule. This replaces the derived one-step fence check. * test(native-chat): every writer of a watched exit names the owner it released A watched exit that names no owner reads the older rule, so the settle alone cannot tell a dropped field; the writers are pinned directly, including past a recovery floor. * test(native-chat): give the fake journal's cast its safety rationale * fix(native-chat): a proof of death written after a chat opened revises the turn it left unverifiable On desktop the chat on screen at relaunch opens before the startup reconcile has probed its owner, so the open settles the cut-off turn unverifiable. When the reconcile then records the proof, it re-runs the same settle for every open conversation, which revises that owner's unverifiable turns to interrupted with the proof's end. Any later open re-runs it too, so a failed write converges. Only upward, only for a proof that names the turn's own owner. * test(native-chat): a chat read before the reconcile reads unverifiable, then interrupted Covers the reconcile revising an open chat to the last renewal (27 s) and a subscriber being sent both states, a start after the crash whose running turn the queued revision leaves alone, a failed revision write converging at the next open, a proof about another owner or from an older build never revising, and a second settle writing nothing. * fix(native-chat): revise an open chat's turn wherever a proof of death is written The store tells its listeners, once committed, of each record a transaction gave a new proof of death, so every writer (the startup reconcile, a recovery that stopped a child which outlived Orca, a failed start, a watched exit) triggers the same serialized resettle for a chat already open. The reconcile's own callback is gone. Quit stops listening first, and a queued resettle is drained with the starts. * test(native-chat): a failed exit settlement is retried in place once the exit is recorded Recording a watched exit now queues the same settle an open runs, so the turn converges without waiting for the chat to be reopened. The reopen and read-after-restart cases now refuse that retry too, so they still pin the open's own settle. * test(native-chat): tests that pin a send settling a failed exit refuse the in-place retry too The exit's release now queues the same settle, so two tests named for the send's settle refuse that retry as well; the comments that said nothing retries it now say what does. * fix(native-chat): name the explanation row by the death it explains, so a retried settle adds no second row * fix(native-chat): end a crashed turn at its owner's provider output, never at a later send A send accepted into a crashed chat before the proof of death wrote a submission row live, and the journal-wide last-live-row bound counted it, so the revised turn ended at the send and counted Orca's downtime. The bound is now the last row the owner's provider child wrote, per writer fence: submissions, dispatch rows and crash reconciliation are Orca's or the user's, and a newer owner's work says nothing about the dead one. * fix(native-chat): end a crashed turn at its last proof of life, never at a timeline row The end of a probe-proven death was the later of the last renewal and the last live timeline row. A send accepted into a crashed chat before the proof writes a row live, so the revised turn ended at the send and counted Orca's downtime. Rows cannot tell the agent's output from Orca's or the user's, so the end is now the last renewal alone, never after the probe, and never before the turn began. The journal's live-activity bound and the reopen's recovered marker, which existed only for it, are gone. * test(native-chat): drop a stale reference to the removed live-activity bound --- .../agent-session-journal/journal-reducer.ts | 20 +- .../agent-session-journal/journal-store.ts | 3 + ...uctured-agent-session-conversation-open.ts | 59 +- ...tured-agent-session-crash-turn-end.test.ts | 528 ++++++++++++++++++ ...gent-session-dead-generation-settlement.ts | 31 +- .../structured-agent-session-host-delivery.ts | 20 + ...uctured-agent-session-host-test-abandon.ts | 2 +- .../structured-agent-session-host.ts | 2 +- ...agent-session-send-open-stale-turn.test.ts | 27 +- ...ured-agent-session-stale-turn-open.test.ts | 23 +- ...d-agent-session-stale-turn-verdict.test.ts | 347 +++++++++++- ...ctured-agent-session-stale-turn-verdict.ts | 96 +++- ...red-agent-session-surface-lifetime.test.ts | 4 +- ...ured-agent-session-unexpected-exit.test.ts | 3 +- ...tructured-agent-session-unexpected-exit.ts | 4 +- ...t-session-wedged-profile-migration.test.ts | 53 +- ...-session-acquisition-failure-settlement.ts | 66 ++- ...session-death-evidence-persistence.test.ts | 228 ++++++++ .../runtime/agent-session-record-store.ts | 30 +- ...agent-session-restart-lease-transitions.ts | 15 +- ...session-surface-release-transition.test.ts | 2 + ...gent-session-surface-release-transition.ts | 3 +- .../structured-agent-session-at-rest.test.ts | 6 +- .../agent-session-lease-adjudication.test.ts | 32 +- .../agent-session-lease-adjudication.ts | 24 +- src/shared/agent-session-record.ts | 20 +- ...sion-death-evidence-downgrade.unit.test.ts | 61 ++ 27 files changed, 1569 insertions(+), 140 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts create mode 100644 src/main/runtime/agent-session-death-evidence-persistence.test.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 7ce1f4ef1c1..d7e22575702 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -42,6 +42,8 @@ export type JournalReducerState = { oldestSequence: number highestFence: number items: Map + /** Fence of the writer that created each item: the generation a running turn belongs to. */ + itemFences: Map /** Revision of a removed item, so a late lower revision cannot resurrect it. */ tombstones: Map submissions: Map @@ -61,6 +63,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou oldestSequence: 1, highestFence: 0, items: new Map(), + itemFences: new Map(), tombstones: new Map(), submissions: new Map(), receipts: new Map(), @@ -82,7 +85,13 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, row.itemId, row.body) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) - upsertItem(state, itemId, row.revision, journalRenderItem(itemId, row.revision, row.body, row)) + upsertItem( + state, + itemId, + row.revision, + journalRenderItem(itemId, row.revision, row.body, row), + row.fence + ) return } if (row.kind === 'tombstone') { @@ -103,7 +112,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) const producer = journalBatchMutationProducer(row, mutation) const item = journalRenderItem(itemId, revision, body, row, producer, sequenceIndex) - upsertItem(state, itemId, revision, item) + upsertItem(state, itemId, revision, item, row.fence) } else { removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } @@ -187,7 +196,8 @@ function upsertItem( state: JournalReducerState, itemId: string, revision: number, - next: AgentJournalRenderItem + next: AgentJournalRenderItem, + fence: number ): void { const tombstoned = state.tombstones.get(itemId) if (tombstoned !== undefined && revision <= tombstoned) { @@ -199,6 +209,7 @@ function upsertItem( } if (!existing) { state.items.set(itemId, next) + state.itemFences.set(itemId, fence) state.tombstones.delete(itemId) return } @@ -237,6 +248,7 @@ function removeItem(state: JournalReducerState, itemId: string, revision: number } state.tombstones.set(itemId, revision) state.items.delete(itemId) + state.itemFences.delete(itemId) } function applySubmission( @@ -255,7 +267,7 @@ function applySubmission( ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row)) + upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row), row.fence) } function acceptSubmissionFromProviderItem( diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 45c42b45f8a..3be3aebbc51 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -234,6 +234,9 @@ export class AgentSessionJournal { /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ lastActivityAt = (): number => this.state.lastActivityAt + /** Fence of the writer that created the item, while it is in the timeline. */ + itemFence = (itemId: string): number | undefined => this.state.itemFences.get(itemId) + submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] pendingSubmissions = (): AgentJournalSubmission[] => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index bce79ca71f8..dd8853954e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -10,6 +10,7 @@ import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -103,23 +104,11 @@ export async function openStructuredAgentSessionConversationJournal( } catch (error) { deps.onEventSinkError?.({ sessionId, error }) } - try { - // No child in this process writes to a journal nobody had open, so whatever it shows running - // belongs to a generation that is gone, whatever the lease still claims. Settled before any - // reader or child sees it. - if (!options.acquisition) { - await settleStaleStructuredAgentSessionState({ - journal: opened.journal, - sessionId, - fence, - acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null, - failureTextContext: structuredAgentSessionFailureWordsContext(record) - }) - } - } catch (error) { - // Best effort: the next acquire re-derives it. - deps.onEventSinkError?.({ sessionId, error }) + // No child in this process writes to a journal nobody had open, so whatever it shows running + // belongs to a generation that is gone, whatever the lease still claims. Settled before any + // reader or child sees it. + if (!options.acquisition) { + await settleGoneGeneration(deps, record, opened.journal) } return { session: { journal: opened.journal, params, child: null }, @@ -127,6 +116,42 @@ export async function openStructuredAgentSessionConversationJournal( } } +/** + * The open's settle again, for a conversation already open: a proof of death written since it + * opened (the startup reconcile, a recovery) revises what the open could only call `unverifiable`. + * A record holds a proof only while released, so no child here is writing. A no-op once revised. + */ +export async function resettleOpenStructuredAgentSessionConversation( + deps: StructuredAgentSessionConversationOpenDeps, + sessionId: string, + session: StructuredAgentSessionHostSession | undefined +): Promise { + const record = deps.store.getRecord(sessionId) + if (session && record?.lease.deathEvidence) { + await settleGoneGeneration(deps, record, session.journal) + } +} + +async function settleGoneGeneration( + deps: Pick, + record: AgentSessionRecord, + journal: AgentSessionJournal +): Promise { + try { + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + acquisitionGeneration: null, + deathEvidence: record.lease.deathEvidence ?? null, + failureTextContext: structuredAgentSessionFailureWordsContext(record) + }) + } catch (error) { + // Best effort: the next open or acquire re-derives it. + deps.onEventSinkError?.({ sessionId: record.sessionId, error }) + } +} + export function attachParamsForRecord( record: AgentSessionRecord, input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts new file mode 100644 index 00000000000..0d83491ee21 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -0,0 +1,528 @@ +// Where a turn a crash cut short ends, when the provider wrote nothing while it worked. +// +// Claude reports a Bash call once when it starts and again only when it finishes, so a command +// that runs for half a minute leaves one journal row at its start. The lease renewal the host +// wrote every ten seconds is what saw the child working after that row. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { + completedStructuredAgentTurnSeconds, + selectStructuredAgentTurnTimings +} from '../../../shared/structured-agent-session-turn-timing' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + AgentSessionAcquisitionExitUnprovenError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + hostTestAttachParams, + HOST_TEST_LOCATION as LOCATION, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +const PROVIDER_SESSION = 'provider-session-alpha-1' +/** The tool call's row: the last thing the provider wrote before the crash. */ +const TOOL_STARTED_AT = 1_800_000_000_000 +/** The last renewal before the crash, while the command was still running. */ +const LAST_RENEWED_AT = TOOL_STARTED_AT + 25_000 +/** Orca comes back an hour later. */ +const RELAUNCHED_AT = TOOL_STARTED_AT + 60 * 60 * 1000 + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost + +function crashedClaudeRecord(): AgentSessionRecord { + const linkId = 'claude-13-link' + return { + schemaVersion: 2, + sessionId: SESSION, + location: LOCATION, + provider: 'claude', + providerHandleChain: [ + { + linkId, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + origin: 'created', + mintedAtFence: 13, + observedAt: TOOL_STARTED_AT - 60_000 + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + createdAt: TOOL_STARTED_AT - 60_000, + updatedAt: LAST_RENEWED_AT, + lease: { + sessionId: SESSION, + runtimeKind: 'native', + runtimeFence: 13, + handoffStage: null, + provenHandleLinkId: linkId, + ownerProcess: { + hostId: 'local', + pid: 12_546, + processStartTimeMs: TOOL_STARTED_AT - 60_000, + spawnToken: 'spawn-crashed' + }, + reservedSpawnToken: 'spawn-crashed', + leaseDeadlineAt: LAST_RENEWED_AT + 30_000, + lastRenewedAt: LAST_RENEWED_AT, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null + } + } +} + +async function seedCrashedStore(): Promise { + const directory = join(root, 'store') + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, AGENT_SESSION_STORE_FILE_NAME), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: crashedClaudeRecord() }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }), + 'utf-8' + ) + store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +/** A running turn whose only row after its start is a Bash call that never reported back, for a + * send the provider never acknowledged: the reopen settles it at the fence after the crash. */ +async function seedClaudeToolTurn(): Promise { + let now = TOOL_STARTED_AT - 2_000 + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: LOCATION.workspaceId, + hostId: LOCATION.executionHostId, + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } + }, + journalDir: journalDirectoryFor(root, { + workspaceId: LOCATION.workspaceId, + sessionId: SESSION + }), + now: () => now + }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: '0'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, + fence: 13 + }) + await journal.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: now }, + { fence: 13 } + ) + now = TOOL_STARTED_AT + await journal.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-bash' }, + { + kind: 'tool-call', + name: 'Bash', + input: { command: 'for i in $(seq 90); do sleep 1; done' }, + state: 'running' + }, + { fence: 13 } + ) + await journal.close() +} + +function openHost(overrides: Partial): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-new', + now: () => RELAUNCHED_AT, + ...overrides + }) +} + +/** What a client reads over the wire. */ +async function settledTurn() { + return (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)) + .find(Boolean) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-crash-turn-end-')) + await seedCrashedStore() + await seedClaudeToolTurn() +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a turn a crash cut short mid-tool', () => { + it('ends at the last renewal, not at the tool call the provider last reported', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'pid-absent', + observedAt: RELAUNCHED_AT, + lastProvenAliveAt: LAST_RENEWED_AT + }) + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + // "Worked for 27s", where the tool call's row alone reads 2s. + const [timing] = selectStructuredAgentTurnTimings( + (await host.journalSnapshot(SESSION)).items + ).values() + expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) + }) + + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { + // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. + let alive = true + const probeOwner = async (): Promise => + alive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' } + const stopOwnerProcess = vi.fn(() => { + alive = false + }) + openHost({ probeOwner, stopOwnerProcess }) + + await host.restoreReadableSessions() + + expect(stopOwnerProcess).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'pid-absent', + lastProvenAliveAt: LAST_RENEWED_AT + }) + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + }) +}) + +/** Every turn state a subscriber was sent, in order: snapshots, then live batches. */ +function turnStatesSent(events: readonly AgentSessionSubscribeEvent[]) { + return events + .flatMap((event) => + event.type === 'snapshot' ? event.page.items : event.type === 'batch' ? event.batch.items : [] + ) + .flatMap((item) => readAgentJournalTurn(item.body) ?? []) +} + +function attach(fence: number) { + return host.attach( + { callerKey: 'client-1' }, + hostTestAttachParams(fence, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } + }) + ) +} + +/** Runs whatever this session's serialize already has queued. */ +function drainSession(): Promise { + return host.collaboratorsForTests().serialize(SESSION, async () => {}) +} + +// On desktop the chat on screen at relaunch opens before the startup reconcile has probed its owner, +// so the open can only call the turn unverifiable; the proof, whoever writes it, then revises it. +describe('a turn a read reached before the reconcile proved its owner dead', () => { + it('reads unverifiable, then interrupted at the last renewal, and a subscriber is sent both', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = await host.subscribe({ + id: 'reader-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + + await host.reconcileRestartLeases() + await drainSession() + + // A paired or phone client learns of the revision through the ordinary journal publish. + await vi.waitFor(() => + expect(turnStatesSent(events)).toMatchObject([ + { state: 'unverifiable' }, + { state: 'interrupted', completedAt: LAST_RENEWED_AT } + ]) + ) + const { items } = await host.journalSnapshot(SESSION) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) + expect(items.filter((item) => item.body.kind === 'status')).toHaveLength(1) + unsubscribe() + }) + + it('revises nothing twice, whoever re-runs the settle', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + await host.reconcileRestartLeases() + await drainSession() + const settled = await host.journalSnapshot(SESSION) + + await host + .collaboratorsForTests() + .serialize(SESSION, () => + resettleOpenStructuredAgentSessionConversation( + host.deps, + SESSION, + host.collaboratorsForTests().sessions.get(SESSION) + ) + ) + await host.restoreReadableSessions() + + expect(await host.journalSnapshot(SESSION)).toEqual(settled) + }) + + it('revises it when recovery stops a child that outlived Orca, with no send', async () => { + let alive = true + const probeOwner = async (): Promise => + alive + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'pid-absent' } + const stopOwnerProcess = vi.fn(() => { + alive = false + }) + const acquire = vi.fn() + openHost({ + adapter: { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + probeOwner, + stopOwnerProcess + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + + // The reconcile only parks the live orphan in recovery; recovery's stop is what proves it gone. + await host.restoreReadableSessions() + await drainSession() + + expect(stopOwnerProcess).toHaveBeenCalledOnce() + expect(acquire).not.toHaveBeenCalled() + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + }) + + it('never touches the turn a start after the crash is writing', async () => { + const acquire = vi.fn( + async ({ fence, spawnToken, onSpawned, events }) => { + const process = { + hostId: 'local', + pid: 8_000, + processStartTimeMs: RELAUNCHED_AT, + spawnToken + } + await onSpawned?.(process) + // The new child is already working when its start lands, ahead of the queued revision. + events?.appendItem( + { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn-2' }, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT } + ) + return { + process, + link: { + linkId: `claude-${fence}-link`, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: RELAUNCHED_AT + } + } + } + ) + openHost({ + adapter: { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + + // The start runs the reconcile itself, so the revision it queues waits behind the start. + await expect(attach(14)).resolves.toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + await drainSession() + + const turns = (await host.journalSnapshot(SESSION)).items.flatMap( + (item) => readAgentJournalTurn(item.body) ?? [] + ) + expect(turns).toMatchObject([ + { turnId: 'turn-1', state: 'interrupted', completedAt: LAST_RENEWED_AT }, + { turnId: 'turn-2', state: 'running' } + ]) + }) + + it('stays unverifiable when the revision cannot be written, and a later open revises it', async () => { + let now = RELAUNCHED_AT + const onEventSinkError = vi.fn() + openHost({ + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => now, + onEventSinkError + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + const { journal } = host.collaboratorsForTests().sessions.get(SESSION)! + vi.spyOn(journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + + await host.reconcileRestartLeases() + await drainSession() + + expect(onEventSinkError).toHaveBeenCalledOnce() + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + // The proof is durable on the record, so the next open converges. + now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(host.hasSession(SESSION)).toBe(false) + expect(await settledTurn()).toMatchObject({ + state: 'interrupted', + completedAt: LAST_RENEWED_AT + }) + }) +}) + +/** A relaunched host whose first start after the crash, at fence 15, fails with `failure`; every + * later start succeeds. The crashed owner, and any child left behind, probe gone. */ +async function hostWithFailingFirstStart(failure: Error) { + let now = RELAUNCHED_AT + const acquire = vi.fn( + async ({ fence, spawnToken, onSpawned }) => { + const process = { hostId: 'local', pid: 7_000 + fence, processStartTimeMs: now, spawnToken } + await onSpawned?.(process) + if (fence === 15) { + throw failure + } + return { + process, + link: { + linkId: `claude-${fence}-link`, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: now + } + } + } + ) + openHost({ + adapter: { + acquire, + releaseAcquisition: async () => true, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => now + }) + await host.reconcileRestartLeases() + return { + attach, + advance: (ms: number) => { + now += ms + } + } +} + +/** The turn as the crashed owner left it, with no end: nothing proves when that owner stopped. */ +const UNVERIFIABLE_TURN = { + turnId: 'turn-1', + state: 'unverifiable', + startedAt: TOOL_STARTED_AT - 2_000 +} + +// The relaunch proved the fence-13 owner gone, but a start reserving fence 15 clears that proof +// before the turn is settled; what the record holds afterwards is about the start's own child. +describe('a turn a newer start could not settle before it failed', () => { + it('is not judged by the death of the child it left for recovery', async () => { + const { attach, advance } = await hostWithFailingFirstStart( + new AgentSessionAcquisitionExitUnprovenError(new Error('hung')) + ) + + await attach(14).catch(() => undefined) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 15, + handoffStage: 'recovering', + lastRenewedAt: RELAUNCHED_AT + }) + advance(60_000) + // Recovery records that child's death, then the client retries at the fence it was told. + await expect(attach(15)).resolves.toMatchObject({ refusal: { currentFence: 16 } }) + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + ownerFence: 15, + lastProvenAliveAt: RELAUNCHED_AT + }) + await expect(attach(16)).resolves.toMatchObject({ ok: true }) + + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + }) + + it('is not judged by the watched exit of a start that failed', async () => { + const { attach, advance } = await hostWithFailingFirstStart(new Error('claude exited (code 1)')) + + await expect(attach(14)).resolves.toMatchObject({ ok: false }) + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + ownerFence: 15, + observedAt: RELAUNCHED_AT + }) + advance(60_000) + await expect(attach(16)).resolves.toMatchObject({ ok: true }) + + // Main ended it at the failed start, an hour after the crash, with the start's reason. + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index f6a166c7c02..8ee3430c5c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { + provenUnverifiableTurnRevisions, runningTurnLifecycleRevisions, turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict @@ -185,9 +186,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** * Settles whatever a generation with no child in this process left running: found when a new child * is acquired, or when a chat is reopened for reading. Derived from the journal and the lease's - * death evidence each time, so nothing is owed in between. Only an observed exit earns an end time - * and the exit copy. Must run before a new child's buffered events land, or a live turn would be - * judged. + * death evidence each time, so nothing is owed in between. Proven death ends the turn interrupted, + * and a proof written after an earlier settle revises what that settle left `unverifiable`. Must + * run before a new child's buffered events land, or a live turn would be judged. */ export async function settleStaleStructuredAgentSessionState(input: { journal: AgentSessionJournal @@ -200,7 +201,10 @@ export async function settleStaleStructuredAgentSessionState(input: { }): Promise { const { journal } = input const items = journal.snapshot().items - const verdict = turnVerdictFromDeathEvidence(input.deathEvidence) + // Each turn is judged by the evidence only if it names that turn's owner. + const verdictFor = (item: AgentJournalRenderItem) => + turnVerdictFromDeathEvidence(input.deathEvidence, journal.itemFence(item.itemId)) + // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` const mutations: JournalLifecycleMutationInput[] = [] @@ -211,11 +215,24 @@ export async function settleStaleStructuredAgentSessionState(input: { mutations.push({ kind: 'item', identity, body }) } } - mutations.push(...runningTurnLifecycleRevisions(items, verdict)) - if (verdict.state === 'interrupted' && items.some(isInProgressItem)) { + const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) + mutations.push( + ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), + ...proven + ) + const evidence = input.deathEvidence + if ( + evidence && + (proven.length > 0 || + items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) + ) { mutations.unshift({ kind: 'item', - identity: { provider: 'orca', clientMessageId: settlementId }, + // Named by the death it explains, so a retry after a partly written settle adds no second row. + identity: { + provider: 'orca', + clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + }, // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped. body: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index dace93edc62..d85d1dfee0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -7,6 +7,7 @@ import type { AgentJournalResetReason } from '../../../shared/agent-session-jour import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openStructuredAgentSessionConversation, + resettleOpenStructuredAgentSessionConversation, type OpenedStructuredAgentSessionConversation, type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' @@ -28,6 +29,8 @@ export type StructuredAgentSessionConversationDelivery = { sessionId: string, options?: StructuredAgentSessionConversationOpenOptions ) => Promise + /** Stops the loop and the resettle on a proof of death; quit's first step. */ + dispose: () => void /** Indexes a conversation some other open produced, as `open` would have. */ adoptOpened: ( sessionId: string, @@ -76,9 +79,26 @@ export function createStructuredAgentSessionConversationDelivery(input: { loop.wake(sessionId) } } + // A chat open before its owner's death was proven revises what its open settled. Queued, never + // awaited: the writer can hold this session's serialize (an attach recovering its lease). + const stopResettling = deps.store.onDeathEvidence((sessionId) => { + if (sessions.has(sessionId)) { + void input + .trackStart( + input.serialize(sessionId, () => + resettleOpenStructuredAgentSessionConversation(deps, sessionId, sessions.get(sessionId)) + ) + ) + .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) + } + }) return { loop, adoptOpened, + dispose: () => { + loop.dispose() + stopResettling() + }, open: (sessionId, options) => openStructuredAgentSessionConversation({ deps, sessions, adoptOpened }, sessionId, options) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts index de928eb32e7..a4069875bad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts @@ -16,7 +16,7 @@ export async function abandonStructuredAgentSessionHost( host: StructuredAgentSessionHost ): Promise { // First, so the drain below cannot race the loop into enqueueing another step. - host['conversationDelivery'].loop.dispose() + host['conversationDelivery'].dispose() host['lifetime'].dispose() await host['runtimeState'].stopLeaseRenewal() await host['tasks'].drainAttaches() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index eba44143074..26697d3f681 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -247,7 +247,7 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { - this.conversationDelivery.loop.dispose() + this.conversationDelivery.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), idleSweep: this.lifetime, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index a1f196d8b61..0a14f436119 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -86,7 +86,6 @@ async function relaunchAfterCrashMidTurn( return { host, acquire } } -/** Neither proof of the old owner's death is an observed exit, so the turn ends `unverifiable`. */ async function turnStates(host: StructuredAgentSessionHost) { return (await host.journalSnapshot(SESSION)).items .flatMap((item) => readAgentJournalTurn(item.body) ?? []) @@ -94,18 +93,20 @@ async function turnStates(host: StructuredAgentSessionHost) { } // A host that cannot prove the old owner gone leaves its lease in recovery, still claimed, until -// the next acquire resolves it: the open is not that acquire, and the turn is no less gone. +// the next acquire resolves it: the open is not that acquire, and the turn is no less gone. Only +// the proof decides whether it reads interrupted. const PROBES = [ - ['the old owner is proven gone', { outcome: 'pid-absent' }], + ['the old owner is proven gone', { outcome: 'pid-absent' }, 'interrupted'], [ 'nothing proves the old owner gone', - { outcome: 'indeterminate', reason: 'This host cannot probe structured session owners.' } + { outcome: 'indeterminate', reason: 'This host cannot probe structured session owners.' }, + 'unverifiable' ] -] as const satisfies readonly (readonly [string, AgentSessionOwnerProbe])[] +] as const satisfies readonly (readonly [string, AgentSessionOwnerProbe, string])[] it.each(PROBES)( 'settles a turn a dead generation left running when a send opens the chat and its start fails, when %s', - async (_when, probe) => { + async (_when, probe, settled) => { const { host, acquire } = await relaunchAfterCrashMidTurn(probe) expect(host.hasSession(SESSION)).toBe(false) @@ -123,32 +124,34 @@ it.each(PROBES)( ) expect(acquire).toHaveBeenCalledOnce() - expect(await turnStates(host)).toEqual(['unverifiable']) + expect(await turnStates(host)).toEqual([settled]) await host.flushAllStreamedEvents() } ) it.each(PROBES)( 'settles the same turn when a reader opens the chat, when %s', - async (_when, probe) => { + async (_when, probe, settled) => { const { host } = await relaunchAfterCrashMidTurn(probe) await host.revealSession(SESSION) - expect(await turnStates(host)).toEqual(['unverifiable']) + expect(await turnStates(host)).toEqual([settled]) await host.flushAllStreamedEvents() } ) -// On desktop the chat on screen at relaunch reads before startup reconciles the leases. -it('settles it when a read reaches the chat before the startup reconcile', async () => { +// On desktop the chat on screen at relaunch reads before startup reconciles the leases: nothing +// has proved its owner gone yet, and the reconcile's proof then revises what the open settled. +it('settles it when a read reaches the chat before the startup reconcile, then revises it', async () => { const { host } = await relaunchAfterCrashMidTurn({ outcome: 'pid-absent' }, { reconcile: false }) expect(hostTestState().store.getRecord(SESSION)?.lease.claimStatus).toBe('live') await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(await turnStates(host)).toEqual(['unverifiable']) await host.reconcileRestartLeases() await host.restoreReadableSessions([SESSION]) - expect(await turnStates(host)).toEqual(['unverifiable']) + expect(await turnStates(host)).toEqual(['interrupted']) await host.flushAllStreamedEvents() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 5598cd3c4f7..83959233f6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -28,8 +28,9 @@ afterEach(async () => { await rig.dispose() }) -/** A turn in flight whose agent exits, and whose exit settlement the journal refuses. */ -async function exitWithUnwrittenSettlement(): Promise { +/** A turn in flight whose agent exits, and whose exit settlement the journal refuses: by default + * also the retry that recording the exit queues, so only an open is left to settle it. */ +async function exitWithUnwrittenSettlement(refusedWrites = 2): Promise { await foundRestTestChat(rig) const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! const running = open.child! @@ -40,7 +41,10 @@ async function exitWithUnwrittenSettlement(): Promise { { kind: 'turn', turnId: 'working', state: 'running' } ) await rig.host.flushStreamedEvents(SESSION) - vi.spyOn(open.journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + const append = vi.spyOn(open.journal, 'appendLifecycleBatch') + for (let refused = 0; refused < refusedWrites; refused += 1) { + append.mockRejectedValueOnce(new Error('disk full')) + } await rig.host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -55,6 +59,7 @@ async function exitWithUnwrittenSettlement(): Promise { deathEvidence: { kind: 'exit-observed' } }) ) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) } async function workingTurnState(): Promise { @@ -65,6 +70,18 @@ async function workingTurnState(): Promise { } describe('a turn its gone agent left running', () => { + it('is settled in place by the retry recording the exit queues, with no reopen', async () => { + await exitWithUnwrittenSettlement(1) + + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(await workingTurnState()).toBe('interrupted') + const { items } = await rig.host.journalSnapshot(SESSION) + expect(JSON.stringify(items)).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + }) + it('is settled when the idle-closed conversation is opened again, and its reader sees it', async () => { await exitWithUnwrittenSettlement() expect(await workingTurnState()).toBe('running') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 4ab7b3ad010..2c7a91e7637 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' @@ -75,17 +77,77 @@ function promptItem(state: 'pending' | 'resolved', sequence: number): AgentJourn } describe('turn verdict from death evidence', () => { - it('earns an end time only from an observed exit', () => { + /** Judges a turn the fence-1 owner wrote, by evidence naming that owner. */ + const verdictForTurn = (evidence: AgentSessionDeathEvidence | null | undefined) => + turnVerdictFromDeathEvidence(evidence && { ownerFence: 1, ...evidence }, 1) + + it('ends a watched exit at the exit', () => { + expect(verdictForTurn({ kind: 'exit-observed', detail: 'exit', observedAt: 500 })).toEqual({ + state: 'interrupted', + completedAt: 500 + }) + }) + + it.each(['pid-absent', 'identity-mismatch'] as const)( + 'ends a %s proof at the last proof of life, never after the probe', + (kind) => { + const proof = (lastProvenAliveAt?: number) => ({ + kind, + detail: 'gone', + observedAt: 9_000, + ...(lastProvenAliveAt === undefined ? {} : { lastProvenAliveAt }) + }) + // Probed at 9000, long after the crash: the downtime is never counted as work. + expect(verdictForTurn(proof(8_000))).toEqual({ state: 'interrupted', completedAt: 8_000 }) + expect(verdictForTurn(proof(9_500))).toEqual({ state: 'interrupted', completedAt: 9_000 }) + // A proof that recorded no proof of life has only the probe. + expect(verdictForTurn(proof())).toEqual({ state: 'interrupted', completedAt: 9_000 }) + } + ) + + it('ends a watched exit at the exit even when a renewal is recorded', () => { expect( - turnVerdictFromDeathEvidence({ kind: 'exit-observed', detail: 'exit', observedAt: 500 }) + verdictForTurn({ + kind: 'exit-observed', + detail: 'exit', + observedAt: 500, + lastProvenAliveAt: 400 + }) ).toEqual({ state: 'interrupted', completedAt: 500 }) - expect( - turnVerdictFromDeathEvidence({ kind: 'pid-absent', detail: 'gone', observedAt: 500 }) - ).toEqual({ state: 'unverifiable' }) - expect( - turnVerdictFromDeathEvidence({ kind: 'identity-mismatch', detail: 'pid', observedAt: 500 }) - ).toEqual({ state: 'unverifiable' }) - expect(turnVerdictFromDeathEvidence(null)).toEqual({ state: 'unverifiable' }) + }) + + it('leaves a release nothing proved unverifiable', () => { + expect(verdictForTurn(null)).toEqual({ state: 'unverifiable' }) + expect(verdictForTurn(undefined)).toEqual({ state: 'unverifiable' }) + }) + it('keeps the rule an older build applied to evidence that names no owner', () => { + // Only a watched exit was proof then; a probe's proof stays unverifiable. + const legacy = { detail: 'gone', observedAt: 9_000, lastProvenAliveAt: 8_000 } + expect(turnVerdictFromDeathEvidence({ ...legacy, kind: 'exit-observed' }, 1)).toEqual({ + state: 'interrupted', + completedAt: 9_000 + }) + expect(turnVerdictFromDeathEvidence({ ...legacy, kind: 'pid-absent' }, 1)).toEqual({ + state: 'unverifiable' + }) + }) + + it('gives a turn no end from evidence about any other owner', () => { + const proof = { + kind: 'pid-absent' as const, + detail: 'gone', + observedAt: 9_000, + lastProvenAliveAt: 8_000 + } + // A newer start's death, and a turn whose writer the timeline no longer knows: neither proves + // the turn's own owner gone. + for (const [evidence, turnFence] of [ + [{ ...proof, ownerFence: 3 }, 1], + [{ ...proof, kind: 'exit-observed' as const, ownerFence: 3 }, 1], + [{ ...proof, ownerFence: 1 }, undefined] + ] as const) { + expect(turnVerdictFromDeathEvidence(evidence, turnFence)).toEqual({ state: 'unverifiable' }) + } }) }) @@ -162,6 +224,13 @@ describe('running turn lifecycle revisions', () => { }) }) + it('never ends a turn before it began, when the last proof of life predates it', () => { + const item = lifecycleItem('turn-2', 'running', 2, { startedAt: 500 }) + expect( + runningTurnLifecycleRevisions([item], { state: 'interrupted', completedAt: 300 }) + ).toMatchObject([{ body: { kind: 'turn', state: 'interrupted', completedAt: 500 } }]) + }) + it('revises a legacy status-form running row from an older host into a typed turn', () => { expect( runningTurnLifecycleRevisions([legacyLifecycleItem('turn-2', 30)], { state: 'unverifiable' }) @@ -180,11 +249,22 @@ describe('running turn lifecycle revisions', () => { }) }) +/** A probe that found the fence-1 owner gone at 9000, last proven alive at 100. */ +const PROVEN: AgentSessionDeathEvidence = { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ownerFence: 1, + lastProvenAliveAt: 100 +} + describe('stale session state on a cold acquire', () => { function journalWith(items: AgentJournalRenderItem[]) { const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the settle reads only these journal members. const journal = { snapshot: () => ({ items }), + itemFence: () => 1, cursor: () => ({ epoch: 'epoch-1', sequence: 8 }), appendLifecycleBatch } as unknown as AgentSessionJournal @@ -310,6 +390,255 @@ describe('stale session state on a cold acquire', () => { } }) + it('ends a probe-proven turn at its last proof of life, not at a row written after it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + const command = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + const shell = { kind: 'tool-call' as const, name: 'shell', input: { command: 'pnpm test' } } + await journal.appendItem( + { ...command, ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 100 }, + { fence: 1 } + ) + now = 200 + await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1 }) + // Rows can outlast the last renewal; only the renewal is proof of life. + now = 700 + await journal.appendItem( + command, + { ...shell, input: { command: 'pnpm test', streamed: 'ok' }, state: 'running' }, + { fence: 1 } + ) + now = 9_000 + + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence: { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ownerFence: 1, + lastProvenAliveAt: 650 + } + }) + + const items = journal.snapshot().items + expect(items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)).toMatchObject({ + state: 'interrupted', + completedAt: 650 + }) + // The probe's detail is Orca's, so the row carries none. + expect( + items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + ).toEqual([ + 'The agent stopped while this response was in progress. You can continue in this conversation.' + ]) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + + it('ends a crashed turn at its last proof of life, not at a send accepted before the proof', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + await journal.appendItem( + RUNNING_IDENTITY, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, + { fence: 1 } + ) + now = 200 + await journal.appendItem( + { ...RUNNING_IDENTITY, ordinal: 1 }, + { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, + { fence: 1 } + ) + const settle = (deathEvidence: AgentSessionDeathEvidence | null) => + settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 1, + acquisitionGeneration: null, + deathEvidence + }) + // An hour later the relaunch opens the chat before anything proved the owner gone. + now = 3_600_000 + await settle(null) + const turn = () => journal.snapshot().items.map((item) => readAgentJournalTurn(item.body))[0] + expect(turn()).toMatchObject({ state: 'unverifiable' }) + now = 3_605_000 + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'fingerprint-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'still there?' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.resolveDispatch({ clientMessageId: 'send-1', state: 'pending', fence: 1 }) + now = 3_606_000 + + await settle({ + kind: 'pid-absent', + detail: 'gone', + observedAt: 3_606_000, + ownerFence: 1, + lastProvenAliveAt: 210 + }) + + expect(turn()).toMatchObject({ state: 'interrupted', completedAt: 210 }) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + + it('revises an unverifiable turn only from a proof naming its own owner, and only once', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + await journal.appendItem( + RUNNING_IDENTITY, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, + { fence: 1 } + ) + now = 400 + // The open, before anything proved the fence-1 owner gone. + const settle = (deathEvidence: AgentSessionDeathEvidence | null) => + settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: null, + deathEvidence + }) + await settle(null) + now = 9_000 + const turn = () => journal.snapshot().items.map((item) => readAgentJournalTurn(item.body))[0] + expect(turn()).toMatchObject({ state: 'unverifiable' }) + + const unrevised = journal.cursor() + await expect(settle({ ...PROVEN, ownerFence: 2 })).resolves.toBe(0) + const { ownerFence: _ownerFence, ...olderBuildProof } = PROVEN + await expect(settle({ ...olderBuildProof, kind: 'exit-observed' })).resolves.toBe(0) + expect(journal.cursor()).toEqual(unrevised) + expect(turn()).toMatchObject({ state: 'unverifiable' }) + + await expect(settle(PROVEN)).resolves.toBe(2) + expect(turn()).toEqual({ + turnId: 'turn-2', + state: 'interrupted', + startedAt: 100, + completedAt: 100 + }) + const revised = journal.cursor() + await expect(settle(PROVEN)).resolves.toBe(0) + expect(journal.cursor()).toEqual(revised) + expect(journal.snapshot().items.filter((item) => item.body.kind === 'status')).toHaveLength(1) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + + it('adds one row for a death however many attempts its settle takes to write', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-stale-session-')) + const journals = createTrackedJournalOpener() + let now = 100 + try { + const journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => now + }) + // Enough running turns that the settle writes two batches, and its retry two again. + for (let index = 0; index < 399; index++) { + const turnId = `turn-${index}` + await journal.appendItem( + { ...RUNNING_IDENTITY, turnId }, + { kind: 'turn', turnId, state: 'running', startedAt: 100 }, + { fence: 1 } + ) + } + now = 9_000 + const settle = () => + settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'session-1', + fence: 2, + acquisitionGeneration: null, + deathEvidence: PROVEN + }) + const turns = () => + journal.snapshot().items.flatMap((item) => readAgentJournalTurn(item.body) ?? []) + const statusRows = () => + journal.snapshot().items.filter((item) => item.body.kind === 'status') + const append = journal.appendLifecycleBatch.bind(journal) + const secondBatchFails = vi + .spyOn(journal, 'appendLifecycleBatch') + .mockImplementationOnce(append) + .mockRejectedValueOnce(new Error('disk full')) + + await expect(settle()).rejects.toThrow('disk full') + secondBatchFails.mockRestore() + expect(turns().filter((turn) => turn.state === 'running')).toHaveLength(200) + expect(statusRows()).toHaveLength(1) + + await settle() + expect(turns().filter((turn) => turn.state !== 'interrupted')).toEqual([]) + expect(statusRows()).toHaveLength(1) + } finally { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + } + }) + it('writes nothing when no turn is running and keys on the journal position without a generation', async () => { const idle = journalWith([ lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index b6e9b9bde5e..b23018aeba8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -1,8 +1,10 @@ // What the host may durably say about a turn whose provider child is gone. // -// `interrupted` requires the host to have seen the child exit; that receipt is the only end time it -// is allowed to record. Everything weaker — a pid probe, an identity mismatch, a journal found -// running with no observed exit on the record — is `unverifiable` and carries no end at all. +// `interrupted` requires proof that the child which wrote the turn is gone: death evidence naming +// that turn's owner by fence — a watched exit, or a local probe that found the recorded pid gone or +// reused. A release nothing proved — lost contact, an unverifiable identity, a stop that outlived the +// ladder — carries none, and neither does a later owner's death; the turn is then `unverifiable` +// with no end at all, until a proof naming its owner is written and revises it. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -15,6 +17,7 @@ import { } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredAgentSessionTurnVerdict = | { state: 'interrupted'; completedAt: number } @@ -25,11 +28,31 @@ export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { } export function turnVerdictFromDeathEvidence( - evidence: AgentSessionDeathEvidence | null | undefined + evidence: AgentSessionDeathEvidence | null | undefined, + /** Fence of the owner that wrote the turn. */ + turnFence: number | undefined ): StructuredAgentSessionTurnVerdict { - return evidence?.kind === 'exit-observed' - ? { state: 'interrupted', completedAt: evidence.observedAt } - : UNVERIFIABLE_TURN_VERDICT + if (!evidence) { + return UNVERIFIABLE_TURN_VERDICT + } + if (evidence.ownerFence === undefined) { + // Evidence an older build wrote names no owner; it keeps the rule that build applied. + return evidence.kind === 'exit-observed' + ? { state: 'interrupted', completedAt: evidence.observedAt } + : UNVERIFIABLE_TURN_VERDICT + } + if (evidence.ownerFence !== turnFence) { + return UNVERIFIABLE_TURN_VERDICT + } + if (evidence.kind === 'exit-observed') { + return { state: 'interrupted', completedAt: evidence.observedAt } + } + // A probe finds a dead child long after it died; its last renewal bounds the end, so the turn never + // counts the time Orca was down. Timeline rows don't: a send can land there after the death. + return { + state: 'interrupted', + completedAt: Math.min(evidence.lastProvenAliveAt ?? evidence.observedAt, evidence.observedAt) + } } /** Revises every still-running lifecycle item in place, keeping its identity and start. */ @@ -37,23 +60,43 @@ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], verdict: StructuredAgentSessionTurnVerdict ): JournalLifecycleMutationInput[] { - const revisions: JournalLifecycleMutationInput[] = [] - for (const item of items) { + return items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) - if (turn?.state !== 'running') { - continue - } - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - continue - } - revisions.push({ - kind: 'item', - identity, - body: agentJournalTurnBody(settledLifecycle(turn, verdict)) - }) + return turn?.state === 'running' ? turnLifecycleRevision(item, turn, verdict) : [] + }) +} + +/** + * A turn an earlier settle could only call `unverifiable`, because the proof had not been written + * yet, revised once a proof names the owner that wrote it. Only ever upward, and never from an + * older build's proof, which names no owner. + */ +export function provenUnverifiableTurnRevisions( + items: readonly AgentJournalRenderItem[], + evidence: AgentSessionDeathEvidence | null | undefined, + journal: Pick +): JournalLifecycleMutationInput[] { + const ownerFence = evidence?.ownerFence + if (ownerFence === undefined) { + return [] } - return revisions + return items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === ownerFence + ? turnLifecycleRevision(item, turn, turnVerdictFromDeathEvidence(evidence, ownerFence)) + : [] + }) +} + +function turnLifecycleRevision( + item: AgentJournalRenderItem, + turn: AgentJournalTurnLifecycle, + verdict: StructuredAgentSessionTurnVerdict +): JournalLifecycleMutationInput[] { + const identity = parseAgentJournalItemKey(item.itemId) + return identity + ? [{ kind: 'item', identity, body: agentJournalTurnBody(settledLifecycle(turn, verdict)) }] + : [] } /** The verdict owns the turn's end and nothing else; every other field the row @@ -69,7 +112,10 @@ function settledLifecycle( durationMs: _durationMs, ...kept } = lifecycle - return verdict.state === 'interrupted' - ? { ...kept, state: verdict.state, completedAt: verdict.completedAt } - : { ...kept, state: verdict.state } + if (verdict.state !== 'interrupted') { + return { ...kept, state: verdict.state } + } + // A renewal can predate the turn, which started with its owner alive; it never ends before that. + const began = Math.max(lifecycle.requestedAt ?? 0, lifecycle.startedAt ?? 0) + return { ...kept, state: verdict.state, completedAt: Math.max(verdict.completedAt, began) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 98e50783bdf..b04a9b3d820 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -767,7 +767,9 @@ describe('an unexpected provider exit', () => { runtimeFence: exitedFence + 1, deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } }) - // Nothing retries the settlement; the journal writes again, and the next acquire re-derives it. + // The retry recording the exit queues is refused too; once the journal writes again, the next + // acquire re-derives it. + await host.collaboratorsForTests().serialize(SESSION, async () => {}) refusing.mockRestore() dispatch.mockResolvedValueOnce({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 0685af60549..451629f7b45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -96,6 +96,7 @@ describe('provider-exit settlement', () => { snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), + itemFence: () => 7, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } @@ -123,7 +124,7 @@ describe('provider-exit settlement', () => { observedAt } ) - // Released, not latched: the next acquire or read restore settles what this write left. + // Released, not latched: a later settle from this evidence finishes what this write left. expect(record.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 3a47d1240f2..5025c5a6f82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -131,8 +131,8 @@ export async function settleUnexpectedStructuredAgentSessionExit< acquisitionGeneration: child.generation, now: context.now(), exitObservedAt: observedAt, - // Bare cause: whatever this settlement could not write is settled from it later, by the - // next acquire or read restore, and `exit-observed` already says the rest. + // Bare cause: whatever this settlement could not write is settled from it later (the + // settle recording it queues, or the next open or acquire); `exit-observed` says the rest. exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) }) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 98b212a8606..a492b0b65ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -191,6 +191,9 @@ function isAcquirable(lease: NonNullable>['le ) } +/** After the owner's last renewal, so a turn it proves dead ends at its start, never before. */ +const SEEDED_TURN_STARTED_AT = NOW - 5_000 + async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise { const journal = await openAgentSessionJournal({ identity: { @@ -209,7 +212,7 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< provider === 'codex' ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: NOW - 5_000 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: SEEDED_TURN_STARTED_AT }, { fence: 13 } ) await journal.close() @@ -306,6 +309,16 @@ describe('already-wedged profiles become usable on load', () => { 'a quit that left the owner for a probe to prove gone', wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }), null, + { state: 'interrupted', completedAt: SEEDED_TURN_STARTED_AT } + ], + [ + 'a quit that left an owner on a host this one cannot probe', + wedgedRecord({ + claimStatus: 'live', + handoffStage: null, + ownerProcess: { ...DEAD_OWNER, hostId: 'remote-host' } + }), + null, { state: 'unverifiable' } ] ] as const)( @@ -314,8 +327,18 @@ describe('already-wedged profiles become usable on load', () => { await seedStore({ ...seeded, lease: { ...seeded.lease, deathEvidence } }) await seedRunningTurn() const published: AgentSessionStatusSummary[] = [] + const remote = seeded.lease.ownerProcess?.hostId === 'remote-host' openHost({ - statusSink: { publish: (summary) => published.push(summary), forget: () => {} } + statusSink: { publish: (summary) => published.push(summary), forget: () => {} }, + // Loss of contact is never proof of death: a remote owner only ever probes indeterminate. + ...(remote + ? { + probeOwner: async () => ({ + outcome: 'indeterminate' as const, + reason: 'owner runs on remote-host, which this host cannot probe' + }) + } + : {}) }) await host.restoreReadableSessions() @@ -328,9 +351,25 @@ describe('already-wedged profiles become usable on load', () => { ...verdict }) expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + // The row never carries the proof's detail, which is Orca's log text. + const statusRows = restoredJournal() + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + expect(statusRows).toEqual( + remote + ? [] + : [ + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ] + ) // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) expect(published.map((summary) => summary.status)).not.toContain('working') + // A crash is not something the user did: no outcome is claimed, so no reader files it as a + // cancellation the user already knows about. + expect(published.map((summary) => summary.turnOutcome)).toEqual( + published.map(() => undefined) + ) } ) @@ -350,11 +389,12 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) - // A pid probe proved the owner gone; nobody saw it exit, so the turn has no end. + // A pid probe proved the owner gone, so the turn was cut short when it was last proven alive. expect(turnLifecycle('turn-1')).toEqual({ turnId: 'turn-1', - state: 'unverifiable', + state: 'interrupted', startedAt: NOW - 5_000, + completedAt: SEEDED_TURN_STARTED_AT, recovered: true }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -443,11 +483,12 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) - // A pid probe proved the owner gone; nobody saw it exit, so the turn has no end. + // A pid probe proved the owner gone, so the turn was cut short when it was last proven alive. expect(turnLifecycle('turn-1')).toEqual({ turnId: 'turn-1', - state: 'unverifiable', + state: 'interrupted', startedAt: NOW - 5_000, + completedAt: SEEDED_TURN_STARTED_AT, recovered: true }) } diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index a3e11408118..f6efaa890c3 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -86,12 +86,14 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( } const next = args.exitProof === 'unproven' - ? withLease(record, { - ...record.lease, - handoffStage: 'recovering', - handoffOperationId: null, - lastRenewedAt: args.now - }) + ? { + ...withLease(record, { + ...record.lease, + handoffStage: 'recovering', + handoffOperationId: null + }), + updatedAt: args.now + } : withLease(record, { ...record.lease, runtimeFence: nextAgentSessionFence(record.lease), @@ -101,18 +103,15 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: - args.exitProof === 'root-exit-observed' - ? { - kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not proven gone', - observedAt: args.now - } - : { - kind: 'exit-observed', - detail: 'post-acquisition cleanup proved no provider child remains', - observedAt: args.now - } + deathEvidence: { + kind: 'exit-observed', + detail: + args.exitProof === 'root-exit-observed' + ? 'the provider process exited; its descendants were not proven gone' + : 'post-acquisition cleanup proved no provider child remains', + observedAt: args.now, + ownerFence: record.lease.runtimeFence + } }) state.records.set(args.sessionId, next) state.operations = settleAgentSessionOperation(state.operations, args) @@ -133,14 +132,17 @@ function settleFailedLease( throw new Error('agent_session_ownership_unknown') } if (args.exitProof === 'unproven' && record.lease.ownerProcess) { - return withLease(record, { - ...record.lease, - handoffStage: 'recovering', - // The operation is durably settled failed below; a lease still naming it would - // read as an in-flight transfer to every consumer that keys on the stage + id pair. - handoffOperationId: null, - lastRenewedAt: args.now - }) + // Parking in recovery proves nothing alive, so `lastRenewedAt` keeps the spawn's proof. + return { + ...withLease(record, { + ...record.lease, + handoffStage: 'recovering', + // The operation is durably settled failed below; a lease still naming it would + // read as an in-flight transfer to every consumer that keys on the stage + id pair. + handoffOperationId: null + }), + updatedAt: args.now + } } return withLease(record, { ...record.lease, @@ -151,7 +153,7 @@ function settleFailedLease( claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: acquisitionDeathEvidence(args.exitProof, args.now) + deathEvidence: acquisitionDeathEvidence(args.exitProof, args.now, record.lease.runtimeFence) }) } @@ -159,25 +161,27 @@ function settleFailedLease( * when nothing was. */ function acquisitionDeathEvidence( exitProof: AgentSessionAcquisitionExitProof, - observedAt: number + observedAt: number, + ownerFence: number ): AgentSessionDeathEvidence | null { if (exitProof === 'unproven') { return null } + const proof = { observedAt, ownerFence } if (exitProof === 'processless') { - return { kind: 'pid-absent', detail: 'reservation failed before spawn', observedAt } + return { kind: 'pid-absent', detail: 'reservation failed before spawn', ...proof } } if (exitProof === 'root-exit-observed') { return { kind: 'exit-observed', detail: 'the provider process exited; its descendants were not proven gone', - observedAt + ...proof } } // Cleanup proved no child of this attempt remains; it may never have spawned. return { kind: 'exit-observed', detail: 'acquisition cleanup proved no provider child remains', - observedAt + ...proof } } diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts new file mode 100644 index 00000000000..6e0bff8098c --- /dev/null +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -0,0 +1,228 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionDeathEvidence } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionFailedAcquisitionSettlement } from './agent-session-acquisition-failure-settlement' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' + +const SESSION = 'session-alpha-1' +/** The fixture lease's last renewal. */ +const LAST_RENEWED_AT = 30_000 + +let directory: string + +async function seed(deathEvidence: AgentSessionDeathEvidence | null): Promise { + const lease = + deathEvidence === null + ? agentSessionLeaseFixture() + : agentSessionLeaseFixture({ + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence + }) + await writeFile( + agentSessionStorePath(directory), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: agentSessionRecordFixture(lease) }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) +} + +function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-death-evidence-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('death evidence on disk', () => { + it('records the last renewal before the crash and reads it back after another restart', async () => { + await seed(null) + const crashed = await open() + await crashed.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: 90_000 + }) + const evidence = { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 90_000, + ownerFence: 7, + lastProvenAliveAt: LAST_RENEWED_AT + } + expect(crashed.getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) + expect((await open()).getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) + }) + + it('loads evidence an older build wrote without a proven-alive time', async () => { + const olderBuild = { kind: 'pid-absent' as const, detail: 'gone', observedAt: 90_000 } + await seed(olderBuild) + const store = await open() + expect(store.isSessionUnreadable(SESSION)).toBe(false) + expect(store.getRecord(SESSION)?.lease.deathEvidence).toEqual(olderBuild) + }) + + it.each([ + ['proves the owner alive after the probe found it gone', { lastProvenAliveAt: 90_001 }], + ['names no possible owner', { ownerFence: -1 }] + ])('quarantines evidence that %s', async (_why, field) => { + await seed({ kind: 'pid-absent', detail: 'gone', observedAt: 90_000, ...field }) + expect((await open()).isSessionUnreadable(SESSION)).toBe(true) + }) +}) + +describe('who is told a proof of death landed', () => { + it('tells a listener once the proof is committed, and never for a write that proves nothing', async () => { + await seed(null) + const store = await open() + const told: unknown[] = [] + store.onDeathEvidence((sessionId) => told.push(store.getRecord(sessionId)?.lease.deathEvidence)) + const unsubscribed = vi.fn() + store.onDeathEvidence(unsubscribed)() + + await expect( + store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: 6, + probe: { outcome: 'pid-absent' }, + now: 80_000 + }) + ).rejects.toThrow() + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + // The proof already on the record is not news to a later write. + await store.setSessionTabVisibility(SESSION, true) + + expect(told).toEqual([expect.objectContaining({ kind: 'pid-absent', ownerFence: 7 })]) + expect(unsubscribed).not.toHaveBeenCalled() + }) +}) + +describe('a failed acquisition', () => { + const NOW = 1_800_000_000_000 + const OPERATION_ID = `${NOW}-${'1'.padStart(32, '0')}` + + /** Reserve and observe the spawn at NOW, as an attach does before it can fail. */ + async function spawnedOwner(store: AgentSessionRecordStore): Promise { + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: agentSessionRecordFixture().location, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION_ID, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { callerKey: 'client-1', operationId: OPERATION_ID, fingerprint: 'fp-1' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, + now: NOW + }) + return fence + } + + function unproven(fence: number, now: number): AgentSessionFailedAcquisitionSettlement { + return { + sessionId: SESSION, + fence, + spawnToken: 'spawn-a', + callerKey: 'client-1', + operationId: OPERATION_ID, + outcome: { status: 'failed', code: 'agent_session_operation_invalid', message: 'failed' }, + exitProof: 'unproven', + now + } + } + + it.each([ + ['before the owner proved its handle', false], + ['after the owner proved its handle', true] + ] as const)( + 'parks in recovery keeping the last proof of life, not the failure, %s', + async (_when, proved) => { + const store = await open() + const fence = await spawnedOwner(store) + if (proved) { + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + await store.settleFailedPostAcquisitionAttachment(unproven(fence, NOW + 60_000)) + } else { + await store.settleFailedAcquisition(unproven(fence, NOW + 60_000)) + } + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: 'recovering', + lastRenewedAt: NOW + }) + + const evicted = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: fence, + probe: { outcome: 'pid-absent' }, + now: NOW + 120_000 + }) + expect(evicted.lease.deathEvidence).toMatchObject({ lastProvenAliveAt: NOW }) + } + ) + + it.each(['exit-proven', 'root-exit-observed', 'processless'] as const)( + 'names its own fence in the proof when cleanup settles it %s', + async (exitProof) => { + const store = await open() + const fence = await spawnedOwner(store) + const settled = + exitProof === 'processless' + ? await store.settleFailedAcquisition({ ...unproven(fence, NOW), exitProof }) + : await (async () => { + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + return store.settleFailedPostAcquisitionAttachment({ + ...unproven(fence, NOW), + exitProof + }) + })() + expect(settled.lease.deathEvidence).toMatchObject({ ownerFence: fence }) + } + ) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 314323a3238..925fa38d8d1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -77,6 +77,8 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 export class AgentSessionRecordStore { + private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() + private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} static async open(args: { directory: string; hostId: string }): Promise { @@ -328,6 +330,32 @@ export class AgentSessionRecordStore { }) } + /** Told, once committed, of each session a transaction wrote a proof of death for — whichever + * transition wrote it, since every one lands here. Must not throw. */ + onDeathEvidence(listener: (sessionId: string) => void): () => void { + this.deathEvidenceListeners.add(listener) + return () => this.deathEvidenceListeners.delete(listener) + } + /** Serialize every mutation against the latest committed disk state. */ - private transact = (apply: () => T): Promise => this.transactions.transact(apply) + private transact = async (apply: () => T): Promise => { + let proven: string[] = [] + const result = await this.transactions.transact(() => { + if (this.deathEvidenceListeners.size === 0) { + return apply() + } + const before = new Map( + [...this.state.records].map(([id, record]) => [id, record.lease.deathEvidence]) + ) + const applied = apply() + proven = [...this.state.records] + .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) + .map(([id]) => id) + return applied + }) + for (const sessionId of proven) { + this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) + } + return result + } } diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index 70104588a58..b4269d21c67 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -52,10 +52,13 @@ export function applyAgentSessionRestartAdjudication(args: { deathEvidence: adjudication.evidence }) } - return withLease(record, { - ...record.lease, - handoffStage: adjudication.stage, - unreconciled: false, - lastRenewedAt: args.now - }) + // Parking in recovery proves nothing alive, so `lastRenewedAt` keeps the pre-crash proof. + return { + ...withLease(record, { + ...record.lease, + handoffStage: adjudication.stage, + unreconciled: false + }), + updatedAt: args.now + } } diff --git a/src/main/runtime/agent-session-surface-release-transition.test.ts b/src/main/runtime/agent-session-surface-release-transition.test.ts index 5e671acc77f..0ea30d90197 100644 --- a/src/main/runtime/agent-session-surface-release-transition.test.ts +++ b/src/main/runtime/agent-session-surface-release-transition.test.ts @@ -18,5 +18,7 @@ describe('agent session surface release transition', () => { }) expect(released.lease.runtimeFence).toBe(9) + // The proof names the owner it released, not the fence the floor moved to. + expect(released.lease.deathEvidence).toMatchObject({ kind: 'exit-observed', ownerFence: 7 }) }) }) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 7da1e7633ef..fe98a870524 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -51,7 +51,8 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { deathEvidence: { kind: 'exit-observed', detail: args.exitReason ?? 'the last surface holding this session released it', - observedAt: args.exitObservedAt ?? args.now + observedAt: args.exitObservedAt ?? args.now, + ownerFence: record.lease.runtimeFence } }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index df00957215f..1cc5050d803 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -424,7 +424,10 @@ describe('an agent exit', () => { { kind: 'turn', turnId: 'working', state: 'running' } ) await rig.host.flushStreamedEvents(SESSION) - vi.spyOn(open.journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + // The exit's own write and the retry recording the exit queues are both refused. + vi.spyOn(open.journal, 'appendLifecycleBatch') + .mockRejectedValueOnce(new Error('disk full')) + .mockRejectedValueOnce(new Error('disk full')) await rig.host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -440,6 +443,7 @@ describe('an agent exit', () => { deathEvidence: { kind: 'exit-observed' } }) ) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) const sent = await rig.host.send( CALLER, diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 7256f940aa4..35e794efd0d 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -222,14 +222,28 @@ describe('restart reconciliation', () => { expect(result).toEqual({ disposition: 'evicted', nextFence: 8, + // The death happened between the last renewal and the probe. evidence: { kind: 'identity-mismatch', detail: 'mismatched process-start-time', - observedAt: 9_000 + observedAt: 9_000, + ownerFence: 7, + lastProvenAliveAt: 500 } }) }) + it('never records the owner alive after the probe that found it gone', () => { + // A clock stepped back across the restart would otherwise write an interval load rejects. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ lastRenewedAt: 9_500 }), + probe: { outcome: 'pid-absent' }, + observedAt: 9_000 + }) + ).toMatchObject({ evidence: { observedAt: 9_000, lastProvenAliveAt: 9_000 } }) + }) + it('hands an unverifiable owner to recovery resolution instead of evicting it', () => { expect( adjudicateAgentSessionRestart({ @@ -269,7 +283,13 @@ describe('restart reconciliation', () => { ).toEqual({ disposition: 'evicted', nextFence: 8, - evidence: { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt: 9_000 } + evidence: { + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 9_000, + ownerFence: 7, + lastProvenAliveAt: 500 + } }) }) @@ -308,7 +328,13 @@ describe('restart reconciliation', () => { ).toEqual({ disposition: 'evicted', nextFence: 8, - evidence: { kind: 'pid-absent', detail: 'reservation never spawned', observedAt: 9_000 } + // The proof is about this reservation's own fence. + evidence: { + kind: 'pid-absent', + detail: 'reservation never spawned', + observedAt: 9_000, + ownerFence: 7 + } }) } ) diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index b0b806ddad7..271b136456f 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -80,16 +80,23 @@ export function isProvenAliveProbe(probe: AgentSessionOwnerProbe): boolean { function deathEvidenceFor( probe: AgentSessionOwnerProbe, - observedAt: number + observedAt: number, + lease: AgentSessionLease ): AgentSessionDeathEvidence | null { + // Capped so a clock that stepped back across the restart still writes a valid interval. + const interval = { + observedAt, + ownerFence: lease.runtimeFence, + lastProvenAliveAt: Math.min(lease.lastRenewedAt, observedAt) + } if (probe.outcome === 'exit-observed') { - return { kind: 'exit-observed', detail: 'observed process exit', observedAt } + return { kind: 'exit-observed', detail: 'observed process exit', ...interval } } if (probe.outcome === 'pid-absent') { - return { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt } + return { kind: 'pid-absent', detail: 'recorded pid absent on host', ...interval } } if (probe.outcome === 'identity-mismatch') { - return { kind: 'identity-mismatch', detail: `mismatched ${probe.field}`, observedAt } + return { kind: 'identity-mismatch', detail: `mismatched ${probe.field}`, ...interval } } return null } @@ -240,7 +247,12 @@ export function adjudicateAgentSessionRestart(args: { nextFence: nextAgentSessionFence(lease), evidence: probe.outcome === 'reservation-unused' - ? { kind: 'pid-absent', detail: 'reservation never spawned', observedAt } + ? { + kind: 'pid-absent', + detail: 'reservation never spawned', + observedAt, + ownerFence: lease.runtimeFence + } : null } } @@ -253,7 +265,7 @@ export function adjudicateAgentSessionRestart(args: { reason: 'owner outlived the runtime that held its transport' } } - const evidence = deathEvidenceFor(probe, observedAt) + const evidence = deathEvidenceFor(probe, observedAt, lease) if (evidence) { return { disposition: 'evicted', nextFence: nextAgentSessionFence(lease), evidence } } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 2b85695a845..12a26e64ac7 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -87,6 +87,13 @@ export type AgentSessionDeathEvidence = { kind: 'exit-observed' | 'pid-absent' | 'identity-mismatch' detail: string observedAt: number + /** Fence of the owner (or reservation) this death is about; a fence names exactly one. Absent on + * evidence older builds wrote, which then speaks for no turn. */ + ownerFence?: number + /** The death interval's lower bound: the last time the runtime holding the owner's transport + * proved it alive. Only a probe's proof records it: absent on a surface-release exit, a failed + * start, and evidence older builds wrote. */ + lastProvenAliveAt?: number } export type AgentSessionLease = { @@ -102,6 +109,8 @@ export type AgentSessionLease = { /** Reserved before any process exists, then matched against the child's environment. */ reservedSpawnToken: string | null leaseDeadlineAt: number + /** While `ownerProcess` is set, the last time its transport holder proved it alive; parking in + * `recovering` proves nothing, so it leaves this alone. */ lastRenewedAt: number handoffOperationId: string | null journalCheckpoint: AgentSessionJournalCheckpoint | null @@ -276,13 +285,20 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath return false } const evidence = value as Partial + const { observedAt, lastProvenAliveAt, ownerFence } = evidence return ( (evidence.kind === 'exit-observed' || evidence.kind === 'pid-absent' || evidence.kind === 'identity-mismatch') && isBoundedString(evidence.detail, MAX_ID_LENGTH) && - Number.isSafeInteger(evidence.observedAt) && - (evidence.observedAt as number) >= 0 + typeof observedAt === 'number' && + Number.isSafeInteger(observedAt) && + observedAt >= 0 && + (ownerFence === undefined || (Number.isSafeInteger(ownerFence) && ownerFence >= 0)) && + (lastProvenAliveAt === undefined || + (Number.isSafeInteger(lastProvenAliveAt) && + lastProvenAliveAt >= 0 && + lastProvenAliveAt <= observedAt)) ) } diff --git a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts new file mode 100644 index 00000000000..96780a18517 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../src/shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { agentSessionStorePath } from '../../../src/main/runtime/agent-session-record-store-file' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// The last release before a proof of death named its owner and its last proof of life. +const BASELINE_REF = 'v1.4.211' +const SESSION = 'session-alpha-1' + +test('an older build loads a proof of death that names its owner and its last proof of life', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) + try { + // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. + writeFileSync( + agentSessionStorePath(directory), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + const evidence = store.getRecord(SESSION)?.lease.deathEvidence + expect(evidence).toEqual({ + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 90_000, + ownerFence: 7, + lastProvenAliveAt: 30_000 + }) + + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-record-store.ts' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. + const OldStore = baseline.AgentSessionRecordStore as { + open: (args: { directory: string; hostId: string }) => Promise + } + const old = await OldStore.open({ directory, hostId: 'local' }) + expect(old.isSessionUnreadable(SESSION)).toBe(false) + expect(old.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: evidence + }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) From b4c19f12c47887d1ab5c459823a2ed21f79ccdb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:32 -0700 Subject: [PATCH 330/387] fix(claude): run structured Claude under the POSIX provider supervisor (#23476) * fix(codex): the provider supervisor outlives its provider group when stopped A signalled supervisor forwards the signal to the provider group, escalates to SIGKILL after the grace, and exits only once the group is gone, so recovery's proof that the recorded pid is dead also proves the provider is. It refuses to spawn when its parent is already not the owner named in its spec, and watches that owner rather than whichever parent it first saw. The grace is a spec field. Recovery's SIGTERM stage now outlasts the supervisor's own stop, since a SIGKILL that lands first cannot be handled and leaves the group running. * fix(codex): a closed owner pipe no longer ends the supervisor before its provider group When Orca dies, the supervisor's stdout pipe has no reader. Provider output in the window before the parent-death watch fired raised an unhandled EPIPE that exited the supervisor with the provider group still running. * fix(codex): bound the supervisor grace so recovery's SIGTERM stage always covers it Recovery sized its SIGTERM stage from the default grace, so a launch with a longer grace would be SIGKILLed mid-stop and orphan its group with no test noticing. The spec now refuses any grace above one exported maximum, and recovery derives its SIGTERM stage from that maximum. * fix(codex): every supervisor stop asks the provider with SIGTERM first Owner death, stdin end after the grace, and a signal to the supervisor now all take one path: SIGTERM the provider group, SIGKILL it after the grace, and exit only once it is gone. The signal handlers are registered before the provider is spawned, so a stop that lands in the spawn window still reaps it. The longest stop grows to two graces plus the reap wait, and both recovery's SIGTERM stage and the connection's graceful close now wait that long before forcing, since forcing the supervisor sooner can orphan its group. * fix(claude): run the structured Claude child under the POSIX provider supervisor A close now stops Claude with a SIGTERM through the supervisor instead of letting stdin end finish the turn, and Orca's death stops it through the supervisor. * test(claude): pin the supervised stop against a real Claude CLI, opt-in * test(claude): a requested stop reads interrupted through the frames the supervised SIGTERM makes Claude emit * test(claude): show what the real CLI did when it never ran the tool * test(claude): Orca's death now reaps Claude's own tool through its SIGTERM * refactor(claude): take supervision from the spawn spec so the close ladder cannot disagree with the spawn createProviderSpawnSpec now reports whether it wrapped the provider in the supervisor, and the Claude spawner reads that instead of repeating the platform check. The close ladder's SIGTERM follows the process actually spawned. * fix(native-chat): derive quit's chat-eviction bound from the longest supervised provider close Quit's child-eviction phase was a hand-picked 8 s. It is now the sink drain plus the longest supervised close over Claude and Codex plus a named 1 s margin, so a provider close that grows widens it instead of silently outrunning it. A close's tree-kill fallback stays outside the bound: once main exits, the supervisor stops its provider group on owner death, which a new test now proves for a clean owner exit, and next launch's recovery settles the lease. --- .../claude-agent-sdk-process-spawn.test.ts | 104 +++++++- .../claude/claude-agent-sdk-process-spawn.ts | 38 ++- .../claude-child-exit-proof-ladder.test.ts | 72 ++++++ .../claude/claude-child-exit-proof-ladder.ts | 19 +- .../claude/claude-child-root-termination.ts | 3 + .../claude-stream-json-connection.test.ts | 101 +++++--- .../claude/claude-stream-json-connection.ts | 7 +- .../claude-structured-requested-stop.test.ts | 90 +++++++ .../claude-supervised-stop-real-cli.test.ts | 244 ++++++++++++++++++ ...claude-supervised-stop.integration.test.ts | 225 ++++++++++++++++ src/main/codex/codex-app-server-connection.ts | 2 +- ...erver-posix-supervisor.integration.test.ts | 32 ++- .../codex-app-server-posix-supervisor.test.ts | 3 +- .../codex-app-server-posix-supervisor.ts | 26 +- ...ctured-agent-session-host-teardown.test.ts | 68 ++++- .../structured-agent-session-host-teardown.ts | 21 +- ...tured-agent-session-recovery-resolution.ts | 4 +- 17 files changed, 979 insertions(+), 80 deletions(-) create mode 100644 src/main/claude/claude-child-exit-proof-ladder.test.ts create mode 100644 src/main/claude/claude-structured-requested-stop.test.ts create mode 100644 src/main/claude/claude-supervised-stop-real-cli.test.ts create mode 100644 src/main/claude/claude-supervised-stop.integration.test.ts diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts index cd3520cf6d5..1b6c192d433 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.test.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -4,14 +4,22 @@ import { describe, expect, it, vi } from 'vitest' import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process' import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type * as ProviderSupervisor from '../codex/codex-app-server-posix-supervisor' +import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' +import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' + +vi.mock('../codex/codex-app-server-posix-supervisor', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createProviderSpawnSpec: vi.fn(actual.createProviderSpawnSpec) } +}) type FakeChild = EventEmitter & { pid: number stdin: PassThrough stdout: PassThrough stderr: PassThrough - kill: ReturnType + kill: ReturnType boolean>> } function fakeSpawn() { @@ -20,7 +28,7 @@ function fakeSpawn() { child.stdin = new PassThrough() child.stdout = new PassThrough() child.stderr = new PassThrough() - child.kill = vi.fn(() => true) + child.kill = vi.fn((_signal?: NodeJS.Signals | number) => true) const specs: ProcessSpec[] = [] const spawnImpl = ((spec: ProcessSpec) => { specs.push(spec) @@ -43,7 +51,7 @@ function sdkOptions(overrides: Partial = {}): SdkSpawnOptions { describe('claude agent SDK process spawn', () => { it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => { const process = fakeSpawn() - const spawn = createClaudeCodeProcessSpawn(process.spawnImpl) + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, 'win32') expect(spawn.pid).toBeUndefined() expect(spawn.child).toBeNull() @@ -52,15 +60,103 @@ describe('claude agent SDK process spawn', () => { expect(child).toBe(process.child) expect(spawn.child).toBe(process.child) expect(spawn.pid).toBe(4321) + // Windows has no supervisor: Claude itself is the child. + expect(spawn.supervised).toBe(false) expect(process.specs[0]).toEqual({ program: '/usr/local/bin/claude', args: ['--output-format', 'stream-json'], cwd: '/work/repo', env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' }, + detached: false, stdio: ['pipe', 'pipe', 'pipe'] }) }) + it.each(['darwin', 'linux'] as const)( + 'starts Claude under the provider supervisor on %s, which is then the pid the lease records', + (platform) => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, platform) + spawn.spawn(sdkOptions()) + + const [spec] = process.specs + if (!spec) { + throw new Error('the spawner never built a spec') + } + expect(spawn.supervised).toBe(true) + expect(spawn.pid).toBe(4321) + expect(spec.program).toBe(globalThis.process.execPath) + expect(spec.args?.[0]).toBe('-e') + expect(spec.detached).toBe(true) + expect(spec.cwd).toBe('/work/repo') + const supervisorSpec = JSON.parse( + Buffer.from(String(spec.env?.ORCA_PROVIDER_SUPERVISOR_SPEC), 'base64').toString() + ) + expect(supervisorSpec).toMatchObject({ + command: '/usr/local/bin/claude', + args: ['--output-format', 'stream-json'], + cwd: '/work/repo', + ownerPid: globalThis.process.pid + }) + // The supervisor passes its env to Claude minus its own two keys. + expect(spec.env).toMatchObject({ PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' }) + } + ) + + it.each([ + { platform: 'darwin', specSupervised: false }, + { platform: 'win32', specSupervised: true } + ] as const)( + 'stops Claude by the spawn spec\u2019s supervision on $platform, never the platform', + async ({ platform, specSupervised }) => { + const actual = await vi.importActual( + '../codex/codex-app-server-posix-supervisor' + ) + vi.mocked(createProviderSpawnSpec).mockImplementationOnce((...args) => ({ + ...actual.createProviderSpawnSpec(...args), + supervised: specSupervised + })) + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, platform) + spawn.spawn(sdkOptions()) + expect(spawn.supervised).toBe(specSupervised) + + let exited = false + let settle = (): void => {} + const exitPromise = new Promise((resolve) => { + settle = resolve + }) + // Claude leaves shortly after stdin ends, so the ladder never needs its forced rung. + process.child.stdin.on('finish', () => + setTimeout(() => { + exited = true + settle() + }, 10) + ) + const tree = { + capture: vi.fn(async () => {}), + reap: vi.fn(async () => 'exited' as const), + treeVerdict: 'exited' as const + } + await proveClaudeChildExitWithReaper( + { + child: process.child, + exitPromise, + exited: () => exited, + tree, + supervised: spawn.supervised + }, + () => tree + ) + // SIGTERM to an unsupervised Claude on Windows is TerminateProcess; a skipped one leaves it running. + if (specSupervised) { + expect(process.child.kill).toHaveBeenCalledWith('SIGTERM') + } else { + expect(process.child.kill).not.toHaveBeenCalled() + } + } + ) + it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => { const process = fakeSpawn() const controller = new AbortController() @@ -86,7 +182,7 @@ describe('claude agent SDK process spawn', () => { it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => { const process = fakeSpawn() - createClaudeCodeProcessSpawn(process.spawnImpl).spawn( + createClaudeCodeProcessSpawn(process.spawnImpl, 'win32').spawn( sdkOptions({ command: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd', args: ['--setting-sources=user,project,local', '--session-id', 'a b&c'] diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index a2b1ad7f158..bbb4b295aad 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -1,5 +1,6 @@ import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' +import { createProviderSpawnSpec } from '../codex/codex-app-server-posix-supervisor' /** Derived rather than imported: only src/shared/child-process may name node:child_process. */ type ClaudeCodeChild = ReturnType @@ -11,8 +12,13 @@ export type ClaudeCodeProcessSpawn = { spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */ readonly child: ClaudeCodeChild | null - /** Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn token. */ + /** + * Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn + * token. On POSIX it is the provider supervisor's, which outlives Claude by construction. + */ readonly pid: number | undefined + /** The spawn spec's verdict, so the close ladder never re-decides it. False until the SDK spawns. */ + readonly supervised: boolean readonly stderrTail: string } @@ -31,24 +37,41 @@ function definedEnv(env: Record): Record { + const spec = createProviderSpawnSpec( + { + command: options.command, + args: [...options.args], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }) + }, + definedEnv(options.env), + platform + ) // Why `options.signal` is dropped: it would let the SDK kill the child outside // Orca's ladder, and close() may never report an exit it did not observe. const spawned = spawnImpl({ - program: options.command, - args: [...options.args], - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - env: definedEnv(options.env), + program: spec.program, + args: spec.args, + cwd: spec.cwd, + env: spec.env, + detached: spec.detached, stdio: ['pipe', 'pipe', 'pipe'] }) child = spawned + supervised = spec.supervised // The SDK drains stderr only for its own local spawn, so a custom spawner must: // otherwise the child blocks on a full pipe and exit errors lose their tail. spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => { @@ -62,6 +85,9 @@ export function createClaudeCodeProcessSpawn( get pid() { return child?.pid }, + get supervised() { + return supervised + }, get stderrTail() { return stderrTail } diff --git a/src/main/claude/claude-child-exit-proof-ladder.test.ts b/src/main/claude/claude-child-exit-proof-ladder.test.ts new file mode 100644 index 00000000000..2d8da7f629d --- /dev/null +++ b/src/main/claude/claude-child-exit-proof-ladder.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../codex/codex-app-server-posix-supervisor' +import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { proveClaudeChildExitWithReaper } from './claude-child-exit-proof-ladder' + +function fakeTree(): ClaudeChildTreeReaper & { reap: ReturnType } { + return { + capture: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + reap: vi.fn(async () => 'exited' as const), + treeVerdict: 'exited' + } +} + +/** A root that leaves only once a SIGTERM has had `stopMs` to act, the way a supervisor does. */ +function rootStoppedBySigterm(stopMs: number) { + let exited = false + let settle = (): void => {} + const exitPromise = new Promise((resolve) => { + settle = resolve + }) + const kill = vi.fn((signal?: NodeJS.Signals | number) => { + if (signal === 'SIGTERM') { + setTimeout(() => { + exited = true + settle() + }, stopMs) + } + return true + }) + const stdin = { end: vi.fn() } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The ladder reads only pid, kill and stdin.end from its child. + child: { pid: 4321, kill, stdin } as unknown as Parameters< + typeof proveClaudeChildExitWithReaper + >[0]['child'], + kill, + stdin, + exitPromise, + exited: () => exited + } +} + +describe('Claude child exit proof ladder', () => { + it('stops a supervised child with SIGTERM and waits out the supervisor stop before forcing', async () => { + // Slower than the unsupervised 1.5 s grace, still inside the supervisor's own bound. + const root = rootStoppedBySigterm(PROVIDER_SUPERVISOR_MAX_STOP_MS - 500) + const tree = fakeTree() + + await expect( + proveClaudeChildExitWithReaper({ ...root, supervised: true, tree }, () => tree) + ).resolves.toBe(true) + + expect(root.stdin.end).toHaveBeenCalled() + expect(root.kill).toHaveBeenCalledWith('SIGTERM') + // Forcing here would SIGKILL the supervisor mid-stop and orphan Claude in its own group. + expect(root.kill).not.toHaveBeenCalledWith('SIGKILL') + expect(tree.reap).not.toHaveBeenCalled() + }, 10_000) + + it('never signals an unsupervised child for the graceful stop', async () => { + const root = rootStoppedBySigterm(0) + const tree = fakeTree() + + await proveClaudeChildExitWithReaper({ ...root, tree }, () => tree) + + // On Windows a direct SIGTERM is TerminateProcess: stdin end stays the only graceful rung. + expect(root.stdin.end).toHaveBeenCalled() + expect(root.kill).not.toHaveBeenCalledWith('SIGTERM') + expect(tree.reap).toHaveBeenCalled() + }, 10_000) +}) diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts index 85ed629f1b9..37d4e47508d 100644 --- a/src/main/claude/claude-child-exit-proof-ladder.ts +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -1,8 +1,11 @@ import type { SpawnedProcess } from '../../shared/child-process/run-process' import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../codex/codex-app-server-posix-supervisor' import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' -const GRACEFUL_EXIT_MS = 1_500 +export const GRACEFUL_EXIT_MS = 1_500 +// A signalled supervisor escalates on its own; forcing it sooner kills it and orphans Claude. +export const SUPERVISED_GRACEFUL_EXIT_MS = PROVIDER_SUPERVISOR_MAX_STOP_MS + 500 const FORCED_EXIT_MS = 1_000 export type ClaudeChildExitProofInput = { @@ -10,6 +13,8 @@ export type ClaudeChildExitProofInput = { exitPromise: Promise exited: () => boolean tree?: ClaudeChildTreeReaper + /** The child is the POSIX provider supervisor: SIGTERM stops Claude, which reaps its tools. */ + supervised?: boolean } export async function proveClaudeChildExitWithReaper( @@ -17,16 +22,24 @@ export async function proveClaudeChildExitWithReaper( createTree: () => ClaudeChildTreeReaper ): Promise { const tree = input.tree ?? createTree() - // Arm before stdin closes: only a live root can identify its descendants. + // Arm before the stop: only a live root can identify its descendants. await tree.capture() try { input.child.stdin?.end() } catch { // The reap below still owns the process. } + // Stdin end alone lets Claude finish its turn, tools and edits included; a close is a stop. + // Windows has no supervisor, and a direct SIGTERM there is TerminateProcess. + if (input.supervised && !input.exited()) { + input.child.kill('SIGTERM') + } let reaped = false if (!input.exited()) { - await waitForProcessExitUntil(input.exitPromise, GRACEFUL_EXIT_MS) + await waitForProcessExitUntil( + input.exitPromise, + input.supervised ? SUPERVISED_GRACEFUL_EXIT_MS : GRACEFUL_EXIT_MS + ) if (!input.exited()) { reaped = true await tree.refresh?.() diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts index bed422532e1..bba29919578 100644 --- a/src/main/claude/claude-child-root-termination.ts +++ b/src/main/claude/claude-child-root-termination.ts @@ -19,6 +19,9 @@ type RootTerminationInput = { * nothing. A probe here could only let an unreadable process table cost the tree * the one fallback that still works once every table read has failed. * + * On POSIX the root is the provider supervisor, killed only after its own stop had + * its whole bound; Claude, in its own group, is reached by the descendant kill. + * * False means no signal was sent, because the root had already left. */ export function terminateClaudeRoot(input: RootTerminationInput): boolean { diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 6d3f0d89f30..e3682f03306 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -107,6 +107,18 @@ async function open( return connection } +function launchedArgv(spec: ProcessSpec | undefined): string[] { + const supervised = spec?.env?.ORCA_PROVIDER_SUPERVISOR_SPEC + if (!supervised) { + return [spec?.program ?? '', ...(spec?.args ?? [])] + } + const launch: unknown = JSON.parse(Buffer.from(supervised, 'base64').toString()) + if (!launch || typeof launch !== 'object' || !('command' in launch) || !('args' in launch)) { + return [] + } + return [String(launch.command), ...(Array.isArray(launch.args) ? launch.args.map(String) : [])] +} + function childEnv(): Record { return (spawned.at(-1)?.env ?? {}) as Record } @@ -201,8 +213,9 @@ describe('Claude stream-json connection', () => { const report = await until(() => readReportSafely(scenario), 'the scripted CLI report') expect(report.argv[0]).toBe(FAKE_CLI) // The .mjs fixture makes the SDK run it under node; a real CLI path is the program - // itself. Either way the resolved path is what Orca's spawner is asked to execute. - expect([spawned.at(-1)?.program, ...(spawned.at(-1)?.args ?? [])]).toContain(FAKE_CLI) + // itself. Either way the resolved path is what Orca's spawner is asked to execute, + // through the POSIX supervisor's spec where there is one. + expect(launchedArgv(spawned.at(-1))).toContain(FAKE_CLI) expect(report.argv).toContain('--replay-user-messages') expect(report.argv).toContain(`--session-id=${SESSION_ID}`) }) @@ -647,37 +660,63 @@ describe('Claude stream-json connection', () => { 20_000 ) - it('settles a spawn error followed by close as processless and closes idempotently', async () => { - const scenario = scriptScenario([HOLD_OPEN]) - const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') - let fault: Error | null = null - let exit: Error | null = null - const connection = await open( - { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, - { - onFault: (error) => { - fault = error - }, - onExit: (error) => { - exit = error + it.skipIf(process.platform === 'win32')( + 'reports a missing CLI under the supervisor as a root exit that names the spawn error', + async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') + let exit: Error | null = null + const connection = await open( + { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, + { + onExit: (error) => { + exit = error + } } - } - ) + ) - await until( - () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), - 'the processless spawn settlement' - ) - expect(connection.pid).toBeUndefined() - expect(fault).toBeInstanceOf(Error) - expect(exit).toBeNull() - await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ - true, - true - ]) - await expect(connection.close()).resolves.toBe(true) - expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) - }) + const reported = await until(() => exit, 'the supervised spawn failure') + // The supervisor spawned, so this is its exit; only its stderr can say why. + expect(reported.message).toMatch(/\(code 127\).*ENOENT/s) + // A first-hand root exit, which releases the lease like a processless start did. + expect(connection.exitVerdict.root).toBe('exited') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'settles a spawn error followed by close as processless and closes idempotently', + async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') + let fault: Error | null = null + let exit: Error | null = null + const connection = await open( + { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, + { + onFault: (error) => { + fault = error + }, + onExit: (error) => { + exit = error + } + } + ) + + await until( + () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), + 'the processless spawn settlement' + ) + expect(connection.pid).toBeUndefined() + expect(fault).toBeInstanceOf(Error) + expect(exit).toBeNull() + await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ + true, + true + ]) + await expect(connection.close()).resolves.toBe(true) + expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) + } + ) it('does not treat a child error event as first-hand root exit proof', async () => { const scenario = scriptScenario([HOLD_OPEN]) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 486407c0137..6f67e04b36e 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -309,15 +309,16 @@ export async function openClaudeStreamJsonConnection( closePromise ??= (async () => { closing = true resumeReading() - // Arm the descendant proof before ending stdin. The SDK may exit the root - // immediately; a post-exit walk cannot recover descendants that reparented. + // Arm the descendant proof before the stop. The root may exit immediately; + // a post-exit walk cannot recover descendants that reparented. await (tree.refresh?.() ?? tree.capture()) inbox.end() const proven = await proveClaudeChildExit({ child, exitPromise, exited: rootSettled, - tree + tree, + supervised: spawner.supervised }) inbox.fail(new Error('claude stream-json connection closed')) if (!proven) { diff --git a/src/main/claude/claude-structured-requested-stop.test.ts b/src/main/claude/claude-structured-requested-stop.test.ts new file mode 100644 index 00000000000..993109f4a37 --- /dev/null +++ b/src/main/claude/claude-structured-requested-stop.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + adapterFor, + fakeClaude, + identityFor +} from './claude-structured-session-test-support' + +function journalSink() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: () => {} + } + return { sink, items } +} + +function frame(type: 'assistant' | 'user', uuid: string, content: unknown[]) { + return { + type, + uuid, + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null, + message: { role: type, content } + } +} + +describe('a requested stop of a structured Claude chat', () => { + it('reads interrupted, not failed or crashed, through the frames the stop makes Claude emit', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + const journal = journalSink() + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journal.sink + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + const connection = claude.connections[0]! + connection.handlers.onMessage?.( + frame('assistant', 'assistant-1', [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'sleep 120' } } + ]) + ) + // What Claude 2.1.283 emitted within 35 ms of the supervised SIGTERM mid-tool, recorded + // against the real CLI: the killed tool's result, a status frame, and no `result` frame. + connection.close = async () => { + connection.handlers.onMessage?.( + frame('user', 'user-2', [ + { type: 'tool_result', tool_use_id: 'tool-1', is_error: true, content: 'Exit code 137' } + ]) + ) + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'status', + uuid: 'status-1', + session_id: PROVIDER_SESSION_ID + }) + connection.closed = true + return true + } + + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + + const turns = journal.items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }) + expect(turns.at(-1)).toMatchObject({ turnId: 'turn-1', state: 'interrupted' }) + const ended = events.filter((event) => event.type === 'ended') + expect(ended).toEqual([expect.objectContaining({ reason: 'claude session closed' })]) + expect(ended[0]).not.toHaveProperty('cause') + }) +}) diff --git a/src/main/claude/claude-supervised-stop-real-cli.test.ts b/src/main/claude/claude-supervised-stop-real-cli.test.ts new file mode 100644 index 00000000000..82432e3fd6e --- /dev/null +++ b/src/main/claude/claude-supervised-stop-real-cli.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { SUPERVISED_GRACEFUL_EXIT_MS } from './claude-child-exit-proof-ladder' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeStructuredPermissionOptions +} from './claude-structured-launch-resolution' + +// Opt-in only: spends a real (haiku) turn per case. Run it in an isolated HOME with +// ORCA_REAL_CLAUDE_BIN set, and ORCA_REAL_CLAUDE_SETTINGS when auth lives in a settings file. +const CLAUDE_BIN = process.env.ORCA_REAL_CLAUDE_BIN ?? '' +const enabled = + process.env.ORCA_REAL_CLAUDE_SUPERVISED_STOP === '1' && + process.platform !== 'win32' && + CLAUDE_BIN.length > 0 +const FRAMES_OUT = process.env.ORCA_REAL_CLAUDE_FRAMES_OUT +const TOOL_MARKER = 'orca_supervised_stop_probe' +const TOOL_PROMPT = `Use the Bash tool to run exactly this command, with no timeout argument, and nothing else: python3 -c "import time; time.sleep(120) # ${TOOL_MARKER}"` + +type Frame = { at: number; message: Record } +type Row = { pid: number; ppid: number; command: string } + +const recordedPids = new Set() +const tempDirs: string[] = [] +const connections: ClaudeStreamJsonConnection[] = [] + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } +} + +async function processTable(): Promise { + const result = await runProcess({ program: 'ps', args: ['-axo', 'pid=,ppid=,command='] }) + return result.stdout.split('\n').flatMap((line) => { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line) + return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }] : [] + }) +} + +async function descendantsOf(rootPid: number): Promise { + const rows = await processTable() + const found: Row[] = [] + const frontier = [rootPid] + while (frontier.length > 0) { + const parent = frontier.pop() + for (const row of rows.filter((candidate) => candidate.ppid === parent)) { + found.push(row) + frontier.push(row.pid) + } + } + return found +} + +async function until(read: () => Promise | T | null, what: string, ms = 90_000) { + const deadline = Date.now() + ms + for (;;) { + const value = await read() + if (value !== null) { + return value + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${what}`) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } +} + +async function open(sessionId: string, cwd: string, resume: boolean, frames: Frame[]) { + const settings = process.env.ORCA_REAL_CLAUDE_SETTINGS + const permission = claudeStructuredPermissionOptions('bypassPermissions') + const connection = await openClaudeStreamJsonConnection( + { + pathToClaudeCodeExecutable: CLAUDE_BIN, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + model: 'haiku', + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...permission.extraArgs, + ...(settings ? { settings } : {}) + }, + ...(resume ? { resume: sessionId } : { sessionId }) + }, + cwd + }, + { onMessage: (message) => frames.push({ at: Date.now(), message }) } + ) + connections.push(connection) + recordedPids.add(connection.pid!) + return connection +} + +function userMessage(text: string): Record { + return { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + parent_tool_use_id: null, + session_id: '' + } +} + +async function transcriptLines(sessionId: string): Promise { + const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const path = await until( + () => + resolveSessionFilePath('claude', sessionId, { + claudeProjectsDir: join(configDir, 'projects') + }), + 'the transcript', + 15_000 + ) + return readFileSync(path, 'utf8') +} + +function expectLineAtomic(contents: string): void { + expect(contents.length).toBeGreaterThan(0) + expect(contents.endsWith('\n')).toBe(true) + for (const line of contents.split('\n').filter((entry) => entry.trim())) { + expect(() => JSON.parse(line)).not.toThrow() + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function summarize(frames: Frame[], since: number): string[] { + return frames + .filter((frame) => frame.at >= since) + .map(({ at, message }) => { + const inner = isRecord(message.message) ? message.message.content : undefined + const blocks = (Array.isArray(inner) ? inner : []) + .filter(isRecord) + .map((block) => + block.type === 'tool_result' + ? `tool_result(is_error=${String(block.is_error)}:${JSON.stringify(block.content).slice(0, 80)})` + : String(block.type) + ) + return `+${at - since}ms ${String(message.type)}/${String(message.subtype ?? '')}${ + message.type === 'result' ? `(is_error=${String(message.is_error)})` : '' + } ${blocks.join(',')}`.trim() + }) +} + +async function resumeAndAsk(sessionId: string, cwd: string): Promise { + const frames: Frame[] = [] + const resumed = await open(sessionId, cwd, true, frames) + await resumed.send(userMessage('In one short line: what command did I ask you to run?')) + const result = await until( + () => frames.find((frame) => frame.message.type === 'result')?.message ?? null, + 'the resumed turn result' + ) + expect(result.subtype).toBe('success') + await expect(resumed.close()).resolves.toBe(true) +} + +type StopCase = 'close mid-tool' | 'SIGTERM to the supervisor mid-tool' | 'close while idle' + +afterEach(async () => { + for (const pid of recordedPids) { + if (alive(pid)) { + process.kill(pid, 'SIGKILL') + } + } + recordedPids.clear() + connections.splice(0) + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.runIf(enabled)('real Claude stopped through the POSIX supervisor', () => { + it.each(['close mid-tool', 'SIGTERM to the supervisor mid-tool', 'close while idle'])( + '%s: stops Claude and its tool, keeps the transcript line-atomic, and resumes', + async (stopCase) => { + const cwd = mkdtempSync(join(tmpdir(), 'orca-real-claude-stop-')) + tempDirs.push(cwd) + const sessionId = randomUUID() + const frames: Frame[] = [] + const connection = await open(sessionId, cwd, false, frames) + const supervisor = connection.pid! + const midTool = stopCase !== 'close while idle' + await connection.send(userMessage(midTool ? TOOL_PROMPT : 'Reply with the single word: ok')) + const descendants = midTool + ? await until(async () => { + const rows = await descendantsOf(supervisor) + return rows.some((row) => row.command.includes(TOOL_MARKER)) ? rows : null + }, 'the Bash tool').catch((error: unknown) => { + console.log('[no tool] frames so far:', summarize(frames, 0)) + throw error + }) + : await until( + async () => + frames.some((frame) => frame.message.type === 'result') + ? await descendantsOf(supervisor) + : null, + 'the idle turn' + ) + for (const row of descendants) { + recordedPids.add(row.pid) + } + + const signalledAt = Date.now() + if (stopCase === 'SIGTERM to the supervisor mid-tool') { + process.kill(supervisor, 'SIGTERM') + } else { + await expect(connection.close()).resolves.toBe(true) + } + const gone = await until( + () => ([supervisor, ...descendants.map((row) => row.pid)].some(alive) ? null : true), + 'the supervisor, Claude and its tools to exit', + SUPERVISED_GRACEFUL_EXIT_MS + 2_000 + ) + const stoppedMs = Date.now() - signalledAt + const after = summarize(frames, signalledAt) + console.log(`[${stopCase}] stopped in ${stoppedMs} ms; frames after the stop:`, after) + if (FRAMES_OUT) { + writeFileSync( + `${FRAMES_OUT}.${stopCase.replaceAll(' ', '-')}.json`, + JSON.stringify({ stoppedMs, after }, null, 2) + ) + } + expect(gone).toBe(true) + + expectLineAtomic(await transcriptLines(sessionId)) + await resumeAndAsk(sessionId, cwd) + expectLineAtomic(await transcriptLines(sessionId)) + }, + 180_000 + ) +}) diff --git a/src/main/claude/claude-supervised-stop.integration.test.ts b/src/main/claude/claude-supervised-stop.integration.test.ts new file mode 100644 index 00000000000..d0a5f9ea0cc --- /dev/null +++ b/src/main/claude/claude-supervised-stop.integration.test.ts @@ -0,0 +1,225 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import { spawnProcess } from '../../shared/child-process/run-process' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { + PROVIDER_SIGTERM_GRACE_MS, + PROVIDER_STDIN_END_GRACE_MS, + PROVIDER_SUPERVISOR_MAX_STOP_MS +} from '../codex/codex-app-server-posix-supervisor' +import { proveClaudeChildExit } from './claude-agent-sdk-exit-proof' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' + +// Stands in for Claude mid-turn: stdin end does not stop it, the way EOF lets the real CLI finish +// its turn. Its tool leads its own group, as the CLI's Bash tool shells do, and only Claude's own +// SIGTERM handler reaps it. The daemon double-forks out of the tree before anything looks. +const FAKE_CLAUDE = String.raw` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + process.stdin.resume() + process.stdout.on('error', () => {}) + const tool = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + detached: true, + stdio: 'ignore' + }) + const forker = spawn( + process.execPath, + ['-e', "const d = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { detached: true, stdio: 'ignore' }); d.unref(); process.stdout.write(String(d.pid))"], + { stdio: ['ignore', 'pipe', 'ignore'] } + ) + let daemon = '' + forker.stdout.on('data', (chunk) => { daemon += chunk }) + forker.once('exit', () => { + process.stdout.write(JSON.stringify({ claude: process.pid, tool: tool.pid, daemon: Number(daemon) }) + '\n') + }) + process.on('SIGTERM', () => { + if (process.env.ORCA_TEST_CLAUDE_IGNORES_SIGTERM) return + try { process.kill(-tool.pid, 'SIGKILL') } catch {} + writeFileSync(process.env.ORCA_TEST_SIGTERM_MARKER, 'reaped') + process.exit(143) + }) + setInterval(() => {}, 60000) +` + +// Stands in for Orca's main: starts exactly the spec Claude's spawner built, then can be SIGKILLed. +const OWNER = String.raw` + const { spawn } = require('node:child_process') + const spec = JSON.parse(process.env.ORCA_TEST_SPAWN_SPEC) + const env = { ...spec.env } + if (env.ORCA_PROVIDER_SUPERVISOR_SPEC) { + const supervisor = JSON.parse(Buffer.from(env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) + supervisor.ownerPid = process.pid + env.ORCA_PROVIDER_SUPERVISOR_SPEC = Buffer.from(JSON.stringify(supervisor)).toString('base64') + } + const child = spawn(spec.program, spec.args, { + cwd: spec.cwd, + env, + detached: spec.detached, + stdio: ['pipe', 'pipe', 'ignore'] + }) + process.stdout.write(JSON.stringify({ root: child.pid }) + '\n') + child.stdout.pipe(process.stdout) + setInterval(() => {}, 60000) +` + +const recordedPids = new Set() +const tempDirs: string[] = [] + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + } +} + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return true +} + +function readPids(child: ChildProcess, keys: readonly string[]): Promise> { + return new Promise((resolve, reject) => { + const pids: Record = {} + let buffered = '' + const timeout = setTimeout(() => reject(new Error(`no ${keys.join('/')} pids`)), 10_000) + const onData = (chunk: Buffer): void => { + buffered += chunk.toString() + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) { + const parsed: unknown = JSON.parse(line) + for (const [key, pid] of Object.entries(parsed ?? {})) { + if (typeof pid === 'number' && pid > 0) { + pids[key] = pid + recordedPids.add(pid) + } + } + } + if (keys.every((key) => key in pids)) { + clearTimeout(timeout) + child.stdout!.off('data', onData) + child.stdout!.resume() + resolve(pids) + } + } + child.stdout!.on('data', onData) + }) +} + +function sdkOptions(env: Record): SdkSpawnOptions { + const dir = mkdtempSync(join(tmpdir(), 'orca-claude-supervised-')) + tempDirs.push(dir) + return { + command: process.execPath, + args: ['-e', FAKE_CLAUDE], + cwd: dir, + env: { + ...process.env, + ORCA_TEST_SIGTERM_MARKER: join(dir, 'sigterm-reap'), + ...env + }, + signal: new AbortController().signal + } +} + +/** Claude spawned through Orca's own spawner and stopped by Orca's own close ladder. */ +async function spawnClaude(env: Record = {}) { + const spawner = createClaudeCodeProcessSpawn(spawnProcess) + const options = sdkOptions(env) + const child = spawner.spawn(options) + recordedPids.add(child.pid!) + let exited = false + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => + child.once('exit', (code, signal) => { + exited = true + resolve({ code, signal }) + }) + ) + const pids = await readPids(child, ['claude', 'tool', 'daemon']) + const close = (): Promise => + proveClaudeChildExit({ + child, + exitPromise: exit.then(() => undefined), + exited: () => exited, + supervised: spawner.supervised + }) + return { child, exit, pids, close, marker: String(options.env.ORCA_TEST_SIGTERM_MARKER) } +} + +afterEach(() => { + for (const pid of recordedPids) { + if (alive(pid)) { + process.kill(pid, 'SIGKILL') + } + } + recordedPids.clear() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.runIf(process.platform !== 'win32')('Claude under the POSIX provider supervisor', () => { + it('stops a mid-turn Claude on close instead of letting stdin end finish its turn', async () => { + const { child, exit, pids, close, marker } = await spawnClaude() + expect(child.pid).not.toBe(pids.claude) + + const startedAt = Date.now() + await expect(close()).resolves.toBe(true) + + // Claude's own SIGTERM reap ran at once, not after the supervisor's stdin-end grace. + expect(Date.now() - startedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS) + expect(existsSync(marker)).toBe(true) + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + expect(alive(pids.claude)).toBe(false) + expect(alive(pids.tool)).toBe(false) + }) + + it('lets the supervisor escalate a Claude that ignores SIGTERM, and exits only after it', async () => { + const { exit, pids, close } = await spawnClaude({ ORCA_TEST_CLAUDE_IGNORES_SIGTERM: '1' }) + + const startedAt = Date.now() + await expect(close()).resolves.toBe(true) + + const elapsed = Date.now() - startedAt + expect(elapsed).toBeGreaterThanOrEqual(PROVIDER_SIGTERM_GRACE_MS) + expect(elapsed).toBeLessThan(PROVIDER_SUPERVISOR_MAX_STOP_MS + 1_000) + // The supervisor's own SIGTERM stop finished the job; nothing forced the supervisor itself. + await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' }) + expect(alive(pids.claude)).toBe(false) + }) + + it("stops Claude and its tool when Orca's main dies, and leaves a daemon that left its tree alone", async () => { + const specs: ProcessSpec[] = [] + createClaudeCodeProcessSpawn((spec) => { + specs.push(spec) + return spawnProcess({ program: 'true' }) + }).spawn(sdkOptions({})) + const owner = spawn(process.execPath, ['-e', OWNER], { + env: { ...process.env, ORCA_TEST_SPAWN_SPEC: JSON.stringify(specs[0]) }, + stdio: ['ignore', 'pipe', 'ignore'] + }) + recordedPids.add(owner.pid!) + const pids = await readPids(owner, ['root', 'claude', 'tool', 'daemon']) + + owner.kill('SIGKILL') + + expect(await waitFor(() => !alive(pids.claude), PROVIDER_SUPERVISOR_MAX_STOP_MS)).toBe(true) + expect(await waitFor(() => !alive(pids.root), PROVIDER_SUPERVISOR_MAX_STOP_MS)).toBe(true) + // In its own group, so only Claude's SIGTERM handler could have reaped it. + expect(alive(pids.tool)).toBe(false) + // Not the conversation's writer: nothing proves it orphaned, so the stop never reaches it. + expect(alive(pids.daemon)).toBe(true) + }) +}) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index ef566bca336..5bed3b594d7 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -46,7 +46,7 @@ export type CodexAppServerLaunch = { } const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 -const GRACEFUL_EXIT_MS = 1_500 +export const GRACEFUL_EXIT_MS = 1_500 const FORCED_EXIT_MS = 1_000 const STDERR_TAIL_MAX_BYTES = 8192 diff --git a/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts b/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts index e23cdbc88c3..846afaa973f 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.integration.test.ts @@ -52,8 +52,11 @@ const RECORDS_SIGTERM_PROVIDER = String.raw` // Stands in for Orca: launches the supervisor as its own child, then can be killed outright. A // second child holds the supervisor's stdin open, so only the parent-death watch can notice. +// A clean-quit owner has no holder and exits normally on SIGUSR2, the way Orca quits. const OWNER = String.raw` const { spawn } = require('node:child_process') + const quitsCleanly = Boolean(process.env.ORCA_TEST_OWNER_QUITS_CLEANLY) + if (quitsCleanly) process.on('SIGUSR2', () => process.exit(0)) const spec = JSON.parse(Buffer.from(process.env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) spec.ownerPid = process.pid const supervisor = spawn(process.execPath, ['-e', process.env.ORCA_TEST_SUPERVISOR_SCRIPT], { @@ -61,10 +64,12 @@ const OWNER = String.raw` stdio: ['pipe', 'pipe', 'ignore'], detached: true }) - const holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { - stdio: ['ignore', supervisor.stdin, 'ignore'] - }) - process.stdout.write(JSON.stringify({ supervisor: supervisor.pid, holder: holder.pid }) + '\n') + const holder = quitsCleanly + ? null + : spawn(process.execPath, ['-e', 'setInterval(() => {}, 60000)'], { + stdio: ['ignore', supervisor.stdin, 'ignore'] + }) + process.stdout.write(JSON.stringify({ supervisor: supervisor.pid, ...(holder && { holder: holder.pid }) }) + '\n') supervisor.stdout.pipe(process.stdout) setInterval(() => {}, 60000) ` @@ -175,7 +180,7 @@ async function launchUnderOwner( stdio: ['ignore', 'pipe', 'ignore'] }) recordedPids.add(owner.pid!) - const pids = await readPids(owner, ['supervisor', 'holder', 'provider', 'grandchild']) + const pids = await readPids(owner, ['supervisor', 'provider', 'grandchild']) return { owner, pids } } @@ -318,6 +323,23 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(alive(provider)).toBe(false) }) + it('reaps the provider group and exits when its owner quits cleanly', async () => { + const { owner, pids } = await launchUnderOwner( + { sigtermGraceMs: 300 }, + { ORCA_TEST_OWNER_QUITS_CLEANLY: '1' } + ) + const ownerExit = new Promise((resolve) => + owner.once('exit', (code, signal) => resolve({ code, signal })) + ) + + owner.kill('SIGUSR2') + + await expect(ownerExit).resolves.toEqual({ code: 0, signal: null }) + expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true) + expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true) + expect(alive(pids.grandchild)).toBe(false) + }) + it('asks the provider to stop with SIGTERM when its owner dies', async () => { const signalFile = join(tempDir(), 'provider-signal') const { owner, pids } = await launchUnderOwner( diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/codex/codex-app-server-posix-supervisor.test.ts index 28a088716f6..6dbe03e5b2a 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.test.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.test.ts @@ -72,7 +72,8 @@ describe('structured provider supervision', () => { args: ['app-server', '--flag'], env: { PATH: '/bin' }, cwd: '/work/repo', - detached: false + detached: false, + supervised: false }) }) }) diff --git a/src/main/codex/codex-app-server-posix-supervisor.ts b/src/main/codex/codex-app-server-posix-supervisor.ts index 30714ee7911..21dd56aeb5b 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.ts @@ -113,9 +113,10 @@ timer = setInterval(() => { if (ownerGone()) stopProviderGroup(null) }, 100) timer.unref() -child.once('error', () => { +child.once('error', (error) => { clearInterval(timer) - process.exit(127) + // The owner sees only this pid's exit; stderr is where a missing provider binary can say so. + process.stderr.write(String(error && error.message) + '\\n', () => process.exit(127)) }) child.once('exit', (code, signal) => { void reapProviderExit(code, signal) @@ -176,13 +177,22 @@ export function createProviderSpawnSpec( launch: CodexAppServerLaunch, childEnv: NodeJS.ProcessEnv, platform: NodeJS.Platform -): { program: string; args: string[]; env: NodeJS.ProcessEnv; cwd: string; detached: boolean } { - const supervised = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) +): { + program: string + args: string[] + env: NodeJS.ProcessEnv + cwd: string + detached: boolean + /** The child is the supervisor, whose SIGTERM stops the provider and then itself. */ + supervised: boolean +} { + const supervisor = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) return { - program: supervised?.command ?? launch.command, - args: supervised?.args ?? launch.args, - env: supervised?.env ?? childEnv, + program: supervisor?.command ?? launch.command, + args: supervisor?.args ?? launch.args, + env: supervisor?.env ?? childEnv, cwd: launch.cwd ?? process.cwd(), - detached: platform !== 'win32' + detached: platform !== 'win32', + supervised: supervisor !== null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index 760b2603d99..1baaafb6f6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it, vi } from 'vitest' +import { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../../../shared/quit-teardown-deadline' +import { + GRACEFUL_EXIT_MS as CLAUDE_WINDOWS_GRACEFUL_EXIT_MS, + SUPERVISED_GRACEFUL_EXIT_MS +} from '../../claude/claude-child-exit-proof-ladder' +import { GRACEFUL_EXIT_MS as CODEX_WINDOWS_GRACEFUL_EXIT_MS } from '../../codex/codex-app-server-connection' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import { CHILD_EVICTION_TIMEOUT_MS, + EVICTION_MARGIN_MS, + RESUME_MARKER_RECORD_TIMEOUT_MS, structuredAgentSessionHostTeardownPhases } from './structured-agent-session-host-teardown' -// Codex close observes the supervisor's exit after its timers fire, which run late on a loaded host. -const SUPERVISOR_EXIT_OBSERVATION_HEADROOM_MS = 1_000 +const noop = async (): Promise => undefined describe('structured agent-session host teardown', () => { it('names every phase, so the quit-path order is pinned rather than incidental', () => { - const noop = async (): Promise => undefined const phases = structuredAgentSessionHostTeardownPhases({ idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, @@ -31,14 +37,54 @@ describe('structured agent-session host teardown', () => { ]) }) - it("fits the Codex supervisor's longest stop inside quit's child-eviction bound", () => { - // A quit that times out first leaves the provider running and its lease unreleased. Eviction - // drains the sink for the resume offer before it stops the child, inside the same bound. - expect( - SNAPSHOT_DRAIN_TIMEOUT_MS + - PROVIDER_SUPERVISOR_MAX_STOP_MS + - SUPERVISOR_EXIT_OBSERVATION_HEADROOM_MS - ).toBeLessThan(CHILD_EVICTION_TIMEOUT_MS) + it("derives quit's child-eviction bound from every provider's supervised close", () => { + // Eviction drains the sink for the resume offer before it stops the child, inside one bound. + // Each close's tree-kill fallback is deliberately outside it: once main exits the supervisor + // stops its group itself, and next launch's recovery settles the lease. + const closes = { + claude: SUPERVISED_GRACEFUL_EXIT_MS, + codex: PROVIDER_SUPERVISOR_MAX_STOP_MS, + // No supervisor on Windows, so its closes wait less than any supervised one. + claudeWindows: CLAUDE_WINDOWS_GRACEFUL_EXIT_MS, + codexWindows: CODEX_WINDOWS_GRACEFUL_EXIT_MS + } + expect(CHILD_EVICTION_TIMEOUT_MS).toBe( + SNAPSHOT_DRAIN_TIMEOUT_MS + Math.max(closes.claude, closes.codex) + EVICTION_MARGIN_MS + ) + for (const closeMs of Object.values(closes)) { + expect(SNAPSHOT_DRAIN_TIMEOUT_MS + closeMs + EVICTION_MARGIN_MS).toBeLessThanOrEqual( + CHILD_EVICTION_TIMEOUT_MS + ) + } + // The resume markers recorded after eviction still fit under quit's global deadline. + expect(CHILD_EVICTION_TIMEOUT_MS + RESUME_MARKER_RECORD_TIMEOUT_MS).toBeLessThan( + WILL_QUIT_TEARDOWN_DEADLINE_MS + ) + }) + + it('ends child eviction as soon as every chat has closed, not at its bound', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const evict = structuredAgentSessionHostTeardownPhases({ + idleSweep: { dispose: noop }, + runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + tasks: { drainAttaches: noop }, + evictOwnedSessions: noop, + beginResumeMarkers: () => {}, + recordResumeMarkers: noop + }).find((phase) => phase.name === 'evict-owned-sessions') + try { + let finished = false + const run = Promise.resolve(evict?.run()).then(() => { + finished = true + }) + // No timer advances: the phase settles with the eviction, not at CHILD_EVICTION_TIMEOUT_MS. + await vi.advanceTimersByTimeAsync(0) + expect(finished).toBe(true) + await run + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } }) it('bounds stalled recovery publication without preventing later cleanup', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index eade95a7dd2..bc9a48c5dac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -7,6 +7,9 @@ // nothing can ever close them. import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../codex/codex-app-server-posix-supervisor' +import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { abandonQueuedStructuredAgentSessionMessages, @@ -23,12 +26,20 @@ export type StructuredAgentSessionTeardownPhase = { } /** Advisory persistence must not hold shutdown open. */ -const RESUME_MARKER_RECORD_TIMEOUT_MS = 2_000 +export const RESUME_MARKER_RECORD_TIMEOUT_MS = 2_000 -/** Eight steps at ten seconds each would outlast the global quit deadline, and a quit that dies - * mid-eviction leaves the lease unreleased — the exact state restart has to clean up. Bounded - * well below that deadline so the phases after this one still get to run. */ -export const CHILD_EVICTION_TIMEOUT_MS = 8_000 +/** Covers a provider's stop observed late on a loaded host. */ +export const EVICTION_MARGIN_MS = 1_000 + +/** A quit that dies mid-eviction leaves the lease unreleased — the exact state restart has to + * clean up — so this covers the sink drain plus the longest supervised provider close, well below + * the global quit deadline so later phases still run. A close's tree-kill fallback is outside it: + * once main exits the supervisor stops its group itself, and next launch's recovery settles the + * lease. Windows closes have no supervisor and wait less. */ +export const CHILD_EVICTION_TIMEOUT_MS = + SNAPSHOT_DRAIN_TIMEOUT_MS + + Math.max(SUPERVISED_GRACEFUL_EXIT_MS, PROVIDER_SUPERVISOR_MAX_STOP_MS) + + EVICTION_MARGIN_MS /** Bounds a phase without swallowing its failure, which `withTimeout` alone would. */ async function withPhaseTimeout(run: () => Promise, timeoutMs: number): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 36d950a09a4..385d438f7a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -30,8 +30,8 @@ export type StructuredSessionRecoveryResolutionDeps = { } const STOP_PROBE_INTERVAL_MS = 250 -// A POSIX Codex owner is its provider supervisor, which exits only after its provider group. A -// SIGKILL that lands first leaves the group running, so SIGTERM outlasts the supervisor's stop. +// A POSIX structured owner is its provider supervisor, which exits only after its provider +// group. A SIGKILL that lands first leaves the group running, so SIGTERM outlasts its stop. const STOP_PROBES: Record = { SIGTERM: Math.ceil(PROVIDER_SUPERVISOR_MAX_STOP_MS / STOP_PROBE_INTERVAL_MS) + 1, SIGKILL: 4 From b776e9ac9961d787325f23225790514173f1ca6f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:52:36 -0700 Subject: [PATCH 331/387] fix(browser): give a tab's identity one owner so viewport presets stop dropping client hints (#23718) * fix(browser): give a tab's identity one owner so viewport presets stop dropping client hints A desktop viewport preset installed a CDP user-agent override with no userAgentMetadata. Chromium then drops navigator.userAgentData and every sec-ch-ua header for that tab: a Chrome UA with no client hints. Identity was decided separately by the session request hook, the Google sign-in switch and the viewport code, and nothing decided per tab who it should claim to be. resolveBrowserTabIdentity now derives it from the process identity mode, whether the URL is a Google auth host, and whether a mobile preset is requested. applyTabIdentity is the one writer: it keeps the WebContents UA on the process or Firefox identity and clears the CDP override whenever that layer already presents the identity. Viewport emulation only records the requested preset; its metrics and touch steps log failures independently, so a rejected step can no longer skip the identity restore. * test(browser): read the presented identity instead of casting the guest stub * test(browser): drop a comment that described desktop presets writing a UA * fix(browser): keep same-document navigations and unapplied presets off the tab identity A same-document navigation (pushState/replaceState) now never rewrites the WebContents user agent. Chromium reloads a still-loading document when its user agent changes, so an OAuth callback that strips its code with replaceState after a redirect off Google sign-in was requested twice, replaying the one-time code. Measured on Electron 43.7.5: the callback URL hits the server twice with the write, once without. The session request hook now derives the mobile identity from the CDP override the tab actually holds instead of the requested preset. A preset whose write never landed (debugger attach refused while DevTools is open, a failed write, a detach) no longer puts the iPhone user agent and mobile client hints on the wire while the document reports desktop. * fix(browser): restore identity after a failed navigation without reloading the error page did-fail-load fires while the failed URL's error page is still loading, and WebContents.setUserAgent() at that moment makes Chromium reload it. After a redirect onto or off the Google sign-in host (identity moved over CDP only), the restore rewrote the WebContents UA there and replayed the failed request. The restore now goes over CDP; the next navigation rewrites the WebContents UA. * refactor(browser): let only a navigation start write the WebContents user agent Two review rounds each found a caller that asked the identity writer to rewrite the WebContents UA at a moment Chromium reloads or cancels the page (a same-document navigation, a failed load). A boolean at every call site left that decision to the callers. The writer now has two entry points: presentTabIdentityAtNavigationStart, the only one that may write the WebContents UA and only for a cross-document navigation, and retargetTabIdentity, which goes over CDP only and serves redirects, failed loads and preset changes. A table test pins the rule for every entry point. * test(browser): reject touch emulation regardless of payload in the identity-restore test The mock rejected only maxTouchPoints 0, so the test would stop exercising a failed touch step once the touch payload is fixed. --- .../browser-manager-auth-user-agent.test.ts | 58 +-- src/main/browser/browser-manager-bindings.ts | 4 +- .../browser/browser-manager-guest-cleanup.ts | 2 +- ...browser-manager-guest-navigation-policy.ts | 10 +- .../browser/browser-manager-guest-policy.ts | 4 +- .../browser/browser-manager-navigation.ts | 288 ++++++------- .../browser/browser-manager-registration.ts | 12 +- src/main/browser/browser-manager-state.ts | 18 +- .../browser-manager-tab-identity.test.ts | 385 ++++++++++++++++++ src/main/browser/browser-manager-types.ts | 17 +- .../browser-manager-viewport-override.test.ts | 197 +++++---- ...browser-manager-viewport-ownership.test.ts | 77 ++-- .../browser-manager-viewport-scroll-state.ts | 13 +- .../browser-manager-viewport-test-fixtures.ts | 40 +- src/main/browser/browser-manager-viewport.ts | 134 +++--- .../browser/browser-session-registry.test.ts | 2 +- ...ession-ua-cloudflare-live.electron.test.ts | 4 +- src/main/browser/browser-session-ua.ts | 17 +- src/main/browser/browser-tab-identity.test.ts | 78 ++++ src/main/browser/browser-tab-identity.ts | 90 ++++ .../browser-viewport-user-agent.test.ts | 66 --- .../browser/browser-viewport-user-agent.ts | 75 ---- tests/tools/google-signin-ua-probe.cjs | 2 +- 23 files changed, 1015 insertions(+), 578 deletions(-) create mode 100644 src/main/browser/browser-manager-tab-identity.test.ts create mode 100644 src/main/browser/browser-tab-identity.test.ts create mode 100644 src/main/browser/browser-tab-identity.ts delete mode 100644 src/main/browser/browser-viewport-user-agent.test.ts delete mode 100644 src/main/browser/browser-viewport-user-agent.ts diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index d85a785a887..1a24369f58c 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -137,10 +137,22 @@ describe('browserManager', () => { { userAgent: guestBaseUserAgent } ]) - // A navigation that doesn't change the required UA must not thrash setUserAgent. + // The next direct navigation is the first safe moment to rewrite the WebContents UA, so it + // restores the process identity there and drops the CDP override that stood in for it. setUserAgent.mockClear() + sendCommand.mockClear() didStartNavigation(null, 'https://example.com/', false, true) + expect(setUserAgent).toHaveBeenCalledOnce() + expect(setUserAgent).toHaveBeenLastCalledWith(guestBaseUserAgent) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { userAgent: '' }) + await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined() + + // A navigation that doesn't change the required UA must not thrash either layer. + setUserAgent.mockClear() + sendCommand.mockClear() + didStartNavigation(null, 'https://example.org/', false, true) expect(setUserAgent).not.toHaveBeenCalled() + expect(sendCommand).not.toHaveBeenCalled() }) it('leaves the UA untouched on Google auth hosts in native process mode', () => { @@ -270,7 +282,8 @@ describe('browserManager', () => { // Why: the CDP override outranks the WebContents UA, so getUserAgent() still reports the base // identity. Leaving the auth host must read the override, not that stale value, or the guest - // keeps presenting Firefox on every later host. + // keeps presenting Firefox on every later host. The WebContents UA already presents the process + // identity, so leaving clears the override rather than restating the UA without client hints. sendCommand.mockClear() const didStartNavigation = guestOnMock.mock.calls.find( ([event]) => event === 'did-start-navigation' @@ -282,8 +295,8 @@ describe('browserManager', () => { expect(guest.setUserAgent).not.toHaveBeenCalled() expect(sendCommand.mock.calls).toEqual([ - ['Emulation.setUserAgentOverride', { userAgent: guestBaseUserAgent }], - ['Emulation.setUserAgentOverride', { userAgent: guestBaseUserAgent }] + ['Emulation.setUserAgentOverride', { userAgent: '' }], + ['Emulation.setUserAgentOverride', { userAgent: '' }] ]) // A newer confirmed write outranks an older write that remains in flight. @@ -330,9 +343,7 @@ describe('browserManager', () => { sendCommand.mockClear() didStartNavigation(null, 'https://example.com/', false, true) - expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: guestBaseUserAgent - }) + expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { userAgent: '' }) browserManager.unregisterGuest('browser-redirect-ua') }) @@ -420,14 +431,14 @@ describe('browserManager', () => { ) }) - // Why: the direct-navigation branch still writes the Firefox UA through WebContents.setUserAgent, - // and nothing ever restores it once the guest switches to the CDP override. A viewport preset that - // read getUserAgent() back as its base identity would therefore republish Firefox on every ordinary - // host — the wire UA saying Firefox while sec-ch-ua still says Chrome, the exact cross-layer tell - // this scope exists to remove. - it('keeps a viewport preset on the session identity after an auth-host visit', async () => { + // Why: a direct navigation onto the auth host writes Firefox into the WebContents UA, and a redirect + // off it cannot rewrite that layer without cancelling the navigation. Clearing the CDP override + // there would expose that stale Firefox UA on an ordinary host, so the override must carry the + // process identity until the next direct navigation can restore the WebContents UA and clear it. + it('never clears the override onto a stale Firefox WebContents UA and heals it on the next navigation', async () => { browserMocks.processUserAgent = GUEST_CLEAN_UA - const { guest, debuggerSendCommand } = makeViewportGuest(9001) + const { guest, debuggerSendCommand, presentedUserAgent, standingUserAgentOverride } = + makeViewportGuest(9001) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ @@ -452,22 +463,23 @@ describe('browserManager', () => { didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) await flushViewportOps() - // The direct branch pins the WebContents UA to Firefox and never restores it. expect((guest.getUserAgent as () => string)()).toBe(googleAuthUserAgent()) + debuggerSendCommand.mockClear() willRedirect({ preventDefault: vi.fn() }, 'https://myaccount.google.com/', false, true) await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', { + userAgent: GUEST_CLEAN_UA + }) + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) debuggerSendCommand.mockClear() didStartNavigation(null, 'https://github.com/', false, true) await flushViewportOps() - - const uaWrites = debuggerSendCommand.mock.calls.filter( - ([method]) => method === 'Emulation.setUserAgentOverride' - ) - expect(uaWrites.length).toBeGreaterThan(0) - for (const [, params] of uaWrites) { - expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA) - } + expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', { + userAgent: '' + }) + expect(standingUserAgentOverride()).toBeNull() + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) }) }) diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts index 931c5c6bc3b..a87c7e73e08 100644 --- a/src/main/browser/browser-manager-bindings.ts +++ b/src/main/browser/browser-manager-bindings.ts @@ -77,8 +77,8 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab { resolveRenderer: (tabId) => resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), isViewportPresetActive: () => { - const state = this.viewportPresetActiveByTabId.get(browserTabId) - return state?.guestWebContentsId === guest.id && state.active + const state = this.viewportPresetByTabId.get(browserTabId) + return state?.guestWebContentsId === guest.id && state.override !== null }, canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), onViewportWheelConsumed: (deltaX, deltaY) => diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts index a815e95e431..67ded30cecc 100644 --- a/src/main/browser/browser-manager-guest-cleanup.ts +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -23,7 +23,7 @@ export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavi this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) - this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) + this.cdpUserAgentOverrideStateByGuestId.delete(guestWebContentsId) this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. if (isPrimaryGuest) { diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts index a2b8bf0b329..9e8a301476f 100644 --- a/src/main/browser/browser-manager-guest-navigation-policy.ts +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -35,7 +35,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager return } this.updatePendingNavigationForRedirect(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) + void this.retargetTabIdentity(guest, url) } const didFailLoadHandler = ( @@ -51,8 +51,8 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager // Why: a nav that never committed must not leave its target standing as the tab's host. const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) if (failedNavigationWasCurrent) { - // The attempted host never committed, so restore every UA layer to the document that remains. - this.applyGoogleAuthUserAgent(guest, guest.getURL()) + // Restore the identity of the document that remains (usually the failed URL's error page). + void this.retargetTabIdentity(guest, guest.getURL()) } const browserPageId = this.tabIdByWebContentsId.get(guest.id) const certificateFailure = browserPageId @@ -91,7 +91,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager const didStartNavigationHandler = ( _event: Electron.Event, url: string, - _isInPlace: boolean, + isInPlace: boolean, isMainFrame: boolean ): void => { if (!isMainFrame || isChromiumInternalErrorUrl(url)) { @@ -100,7 +100,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager // Why: getURL() still reports the previous committed URL until this navigation commits, so // every UA writer must read the in-flight target or they disagree about the tab's host. this.startPendingNavigation(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url) + void this.presentTabIdentityAtNavigationStart(guest, url, isInPlace) this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. this.pendingLoadFailuresByGuestId.delete(guest.id) diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts index dd418d71af7..9c99175096f 100644 --- a/src/main/browser/browser-manager-guest-policy.ts +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -26,7 +26,7 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } - const disposeAuthDetachTracking = this.trackDebuggerDetachForAuthUserAgent(guest) + const disposeDetachTracking = this.trackDebuggerDetachForUserAgentOverride(guest) // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) const disposePopupPolicy = this.installGuestPopupPolicy(guest, !inheritedOwnerContext) @@ -34,7 +34,7 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. this.policyCleanupByGuestId.set(guest.id, () => { - disposeAuthDetachTracking() + disposeDetachTracking() disposePopupPolicy() disposeNavigationPolicy() }) diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index a7dcf5cd4fc..d96d41e79be 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -1,145 +1,152 @@ import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' import type { BrowserSessionRequestUserAgentResolver } from './browser-session-ua' -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { isGoogleAuthUrl } from './browser-google-auth-ua' import { - buildViewportUserAgentOverride, - type ViewportUserAgentOverride -} from './browser-viewport-user-agent' + googleAuthTabIdentity, + resolveBrowserTabIdentity, + type BrowserTabIdentity +} from './browser-tab-identity' import { safeOrigin, - type AuthUserAgentOverrideOperation, - type AuthUserAgentOverrideState + type CdpUserAgentOverride, + type CdpUserAgentOverrideOperation, + type CdpUserAgentOverrideState } from './browser-manager-types' import { BrowserManagerVisibility } from './browser-manager-visibility' export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { resolveBrowserGuestRequestUserAgent( request: Parameters[0] - ): ViewportUserAgentOverride { - const identity = getBrowserProcessUserAgentIdentity() - const firefoxUa = googleAuthUserAgent() + ): BrowserTabIdentity { + const processIdentity = getBrowserProcessUserAgentIdentity() + const googleAuth = googleAuthTabIdentity() const pendingNavigation = request.webContentsId === undefined ? undefined : this.pendingNavigationByGuestId.get(request.webContentsId) // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve one // coherent identity per mode instead of pairing a Firefox document with native workers. - const googleAuthEnabled = identity.mode === 'clean' + const googleAuthEnabled = processIdentity.mode === 'clean' if ( googleAuthEnabled && - request.currentUserAgent === firefoxUa && + request.currentUserAgent === googleAuth.userAgent && (!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl)) ) { - return { userAgent: firefoxUa } + return googleAuth } - const overrideState = + const standingOverride = request.webContentsId === undefined ? undefined - : this.authUserAgentOverrideStateByGuestId.get(request.webContentsId) - const latestPendingOverride = overrideState?.pending.at(-1) - const currentOverride = - latestPendingOverride && - latestPendingOverride.sequence > (overrideState?.confirmed?.sequence ?? -1) - ? latestPendingOverride - : overrideState?.confirmed + : this.standingCdpUserAgentOverride(request.webContentsId) if ( googleAuthEnabled && - !currentOverride && - request.effectiveUserAgent === firefoxUa && + !standingOverride && + request.effectiveUserAgent === googleAuth.userAgent && (!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl)) ) { - return { userAgent: firefoxUa } + return googleAuth } - if (googleAuthEnabled && currentOverride?.userAgent === firefoxUa) { - return { userAgent: firefoxUa } + if (googleAuthEnabled && standingOverride?.userAgent === googleAuth.userAgent) { + return googleAuth } - const browserPageId = - request.webContentsId === undefined - ? undefined - : this.tabIdByWebContentsId.get(request.webContentsId) // Shared and service worker requests carry no webContentsId, and resolving a session-wide mobile // intent for one put the mobile UA on the wire for a context whose own navigator.userAgent is // desktop-clean — and for every tab sharing the session. One context, one identity: those workers // stay on the session identity, while emulation reaches documents and the emulated tab's dedicated - // workers, which carry the owning webContentsId and so resolve through browserPageId. - const mobile = browserPageId - ? (this.viewportUaOverrideMobileByTabId.get(browserPageId) ?? false) - : false - return buildViewportUserAgentOverride({ + // workers, which carry the owning webContentsId. + // Why the standing override and not the requested preset: the wire must match what the document + // presents, and a preset whose CDP write never landed (no debugger, a failed write) presents none. + return resolveBrowserTabIdentity({ url: request.url, - mobile, - baseUserAgent: identity.userAgent, - googleAuthEnabled + mobile: standingOverride?.userAgentMetadata?.mobile === true, + processIdentity }) } - // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the Firefox switch in the session request hook - // must be matched here per navigation or the two layers disagree — itself a bot tell. - protected applyGoogleAuthUserAgent( + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of its + // own, so inheriting the owner tab's preset would pair a mobile UA with a desktop viewport. + protected hasMobileViewportPreset(guestWebContentsId: number): boolean { + const browserPageId = this.tabIdByWebContentsId.get(guestWebContentsId) + const preset = browserPageId ? this.viewportPresetByTabId.get(browserPageId) : undefined + return preset?.guestWebContentsId === guestWebContentsId && preset.override?.mobile === true + } + + /** + * The only writer of the WebContents UA. Chromium cancels a redirect, and reloads a loading + * document, when that UA changes at any other moment; a cross-document did-start-navigation is + * the one point where it lands on the new document alone. Call it synchronously from that event. + */ + protected presentTabIdentityAtNavigationStart( guest: Electron.WebContents, url: string, - options: { duringRedirect?: boolean } = {} - ): void { - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - const identity = getBrowserProcessUserAgentIdentity() - if (identity.mode === 'native') { - if (browserPageId) { - this.reapplyViewportUserAgentOverride(guest, browserPageId, url) - } - return - } - const firefoxUa = googleAuthUserAgent() - const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) - const latestPendingOverride = overrideState?.pending.at(-1) - const confirmedOverride = overrideState?.confirmed - const currentOverride = - latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) - ? latestPendingOverride - : confirmedOverride - const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() - const nextUa = isGoogleAuthUrl(url) - ? firefoxUa - : // Only restore when the auth-host override is actually in place, so normal - // navigation never touches the session UA. - currentUa === firefoxUa + sameDocument: boolean + ): Promise { + const identity = this.resolveGuestTabIdentity(guest, url) + // The WebContents UA carries no mobile identity: that needs metadata only CDP can send. + const webContentsUserAgent = + identity.kind === 'google-auth' ? identity.userAgent - : null - let authOverrideIssuedOverCdp = false - if (nextUa !== null && nextUa !== currentUa) { - // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight - // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain - // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without - // touching the navigation, and it outranks the WebContents UA from then on, so a guest that - // switches to it stays on it. The wire UA never depended on this write: - // The session request hook rewrites User-Agent for auth-host URLs on its own. - if (options.duringRedirect === true || overrideState !== undefined) { - if (this.canOverrideUserAgentOverCdp(guest)) { - authOverrideIssuedOverCdp = true - // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers - // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off - // them, any mobile preset preserved. Writing the session UA directly would put the - // unlaundered Electron token back on the wire. - void this.applyAuthUserAgentOverrideOverCdp( - guest, - (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? - false, - url, - nextUa - ) - } - // Why: with no debugger there is no way to retarget the identity without cancelling the - // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. - } else { - guest.setUserAgent(nextUa) - } + : getBrowserProcessUserAgentIdentity().userAgent + let presentedByWebContents = guest.getUserAgent() + if (!sameDocument && presentedByWebContents !== webContentsUserAgent) { + guest.setUserAgent(webContentsUserAgent) + presentedByWebContents = webContentsUserAgent } - // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of - // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. - if (browserPageId && !authOverrideIssuedOverCdp) { - this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + return this.writeTabIdentityOverride(guest, identity, presentedByWebContents) + } + + /** Every other identity change (redirects, failed loads, preset changes) goes over CDP only. */ + protected retargetTabIdentity(guest: Electron.WebContents, url: string): Promise { + return this.writeTabIdentityOverride( + guest, + this.resolveGuestTabIdentity(guest, url), + guest.getUserAgent() + ) + } + + private resolveGuestTabIdentity(guest: Electron.WebContents, url: string): BrowserTabIdentity { + return resolveBrowserTabIdentity({ + url, + mobile: this.hasMobileViewportPreset(guest.id), + processIdentity: getBrowserProcessUserAgentIdentity() + }) + } + + // Why clear rather than restate: any override without userAgentMetadata makes Chromium drop + // navigator.userAgentData and every sec-ch-ua header, so it stands only when the WebContents UA + // cannot present the identity itself. + private writeTabIdentityOverride( + guest: Electron.WebContents, + identity: BrowserTabIdentity, + presentedByWebContents: string + ): Promise { + const override: CdpUserAgentOverride = + identity.kind === 'mobile' + ? { userAgent: identity.userAgent, userAgentMetadata: identity.userAgentMetadata } + : presentedByWebContents === identity.userAgent + ? { userAgent: '' } + : // Only reachable before a navigation start can rewrite the WebContents UA (a redirect + // off the auth host, a failed load); the next cross-document navigation clears it. + { userAgent: identity.userAgent } + if (override.userAgent === '' && !this.standingCdpUserAgentOverride(guest.id)) { + return Promise.resolve(true) } + // Why: with no debugger there is no way to retarget the identity without cancelling a redirect. + // A stale navigator.userAgent is recoverable; a dead navigation is not. The wire UA never + // depended on this write: the session request hook rewrites User-Agent on its own. + return this.writeCdpUserAgentOverride(guest, override) + } + + /** The override Chromium holds or is about to hold; undefined when none stands. */ + protected standingCdpUserAgentOverride(guestId: number): CdpUserAgentOverride | undefined { + const state = this.cdpUserAgentOverrideStateByGuestId.get(guestId) + const latestPending = state?.pending.at(-1) + const current = + latestPending && latestPending.sequence > (state?.confirmed?.sequence ?? -1) + ? latestPending + : state?.confirmed + return current && current.override.userAgent !== '' ? current.override : undefined } protected canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { @@ -150,39 +157,43 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility } } - protected applyAuthUserAgentOverrideOverCdp( + // Why no queue: debugger.sendCommand dispatches in call order over one channel, so the later-issued + // write wins. The sequence only keeps a failed or superseded write from being recorded as standing. + protected writeCdpUserAgentOverride( guest: Electron.WebContents, - mobile: boolean, - url: string, - userAgent: string + override: CdpUserAgentOverride ): Promise { if (!this.canOverrideUserAgentOverCdp(guest)) { return Promise.resolve(false) } - const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { + const state = this.cdpUserAgentOverrideStateByGuestId.get(guest.id) ?? { confirmed: null, nextSequence: 0, pending: [] } - const operation = { sequence: ++state.nextSequence, userAgent } + const operation = { sequence: ++state.nextSequence, override } state.pending.push(operation) - this.authUserAgentOverrideStateByGuestId.set(guest.id, state) - return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( - () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), - () => { - this.settleAuthUserAgentOverride(guest.id, state, operation, false) + this.cdpUserAgentOverrideStateByGuestId.set(guest.id, state) + return guest.debugger.sendCommand('Emulation.setUserAgentOverride', override).then( + () => this.settleCdpUserAgentOverride(guest.id, state, operation, true), + (error: unknown) => { + this.settleCdpUserAgentOverride(guest.id, state, operation, false) + console.warn('[browser-manager] failed to write the tab user agent override', { + guestWebContentsId: guest.id, + error: error instanceof Error ? error.message : String(error) + }) return false } ) } - protected settleAuthUserAgentOverride( + protected settleCdpUserAgentOverride( guestId: number, - state: AuthUserAgentOverrideState, - operation: AuthUserAgentOverrideOperation, + state: CdpUserAgentOverrideState, + operation: CdpUserAgentOverrideOperation, succeeded: boolean ): boolean { - if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { + if (this.cdpUserAgentOverrideStateByGuestId.get(guestId) !== state) { return false } if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { @@ -192,10 +203,13 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility if (pendingIndex !== -1) { state.pending.splice(pendingIndex, 1) } - if (state.confirmed === null && state.pending.length === 0) { - this.authUserAgentOverrideStateByGuestId.delete(guestId) + if ( + (state.confirmed === null || state.confirmed.override.userAgent === '') && + state.pending.length === 0 + ) { + this.cdpUserAgentOverrideStateByGuestId.delete(guestId) } - return true + return succeeded } protected startPendingNavigation(guestId: number, url: string): void { @@ -242,48 +256,6 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() } - // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, - // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an - // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the - // request header says Firefox — the two-layer disagreement this scope exists to remove. - protected reapplyViewportUserAgentOverride( - guest: Electron.WebContents, - browserTabId: string, - url: string - ): void { - const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - if (mobile === undefined) { - return - } - // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the - // later-issued write wins. What matters is that both writers resolve the SAME host, which they - // now do via the navigation target rather than the stale committed URL. - void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) - } - - protected async sendViewportUserAgentOverride( - guest: Electron.WebContents, - mobile: boolean, - url?: string, - baseUserAgent?: string - ): Promise { - if (guest.isDestroyed() || !guest.debugger.isAttached()) { - return - } - await guest.debugger.sendCommand( - 'Emulation.setUserAgentOverride', - buildViewportUserAgentOverride({ - url: url ?? this.resolveTabNavigationUrl(guest), - mobile, - // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: - // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to - // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: baseUserAgent ?? getBrowserProcessUserAgentIdentity().userAgent, - googleAuthEnabled: getBrowserProcessUserAgentIdentity().mode === 'clean' - }) - ) - } - /** Route guests own their own popup handler, so their denials arrive here instead. */ reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { this.forwardOrQueuePopupEvent(input.openerWebContentsId, { diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts index 816c0d61cb2..f6f7b41bbb9 100644 --- a/src/main/browser/browser-manager-registration.ts +++ b/src/main/browser/browser-manager-registration.ts @@ -46,7 +46,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportPresetByTabId.delete(browserTabId) this.viewportScrollStateByTabId.delete(browserTabId) } this.webContentsIdByTabId.set(browserTabId, webContentsId) @@ -135,8 +135,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.worktreeIdByTabId.delete(browserTabId) // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportPresetByTabId.delete(browserTabId) this.viewportScrollStateByTabId.delete(browserTabId) if (wcId !== undefined) { this.pendingNavigationByGuestId.delete(wcId) @@ -171,7 +170,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserPageId) + this.viewportPresetByTabId.delete(browserPageId) this.viewportScrollStateByTabId.delete(browserPageId) } this.webContentsIdByTabId.set(browserPageId, webContentsId) @@ -206,10 +205,9 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.pageInitiatedTabBudgetByRootGuestId.clear() this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() - this.viewportUaOverrideMobileByTabId.clear() - this.viewportPresetActiveByTabId.clear() + this.viewportPresetByTabId.clear() this.viewportScrollStateByTabId.clear() - this.authUserAgentOverrideStateByGuestId.clear() + this.cdpUserAgentOverrideStateByGuestId.clear() this.pendingNavigationByGuestId.clear() this.pendingLoadFailuresByGuestId.clear() this.loadErrorsByGuestId.clear() diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index da7fa256b4e..da56552d6c4 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -9,7 +9,7 @@ import type { BrowserLoadError } from '../../shared/browser-workspace-types' import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' import type { ActiveDownload, - AuthUserAgentOverrideState, + CdpUserAgentOverrideState, PendingMainFrameNavigation, PendingPermissionEvent, PendingPopupEvent, @@ -125,14 +125,11 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt protected readonly sessionProfileIdByPageId = new Map() // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. protected readonly viewportOpsByTabId = new Map>() - // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation - // can re-issue it against the target URL's identity. - protected readonly viewportUaOverrideMobileByTabId = new Map() // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations // ordered without claiming a failed write was installed. - protected readonly authUserAgentOverrideStateByGuestId = new Map< + protected readonly cdpUserAgentOverrideStateByGuestId = new Map< number, - AuthUserAgentOverrideState + CdpUserAgentOverrideState >() // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() // still reports the outgoing page until then. See resolveTabNavigationUrl. @@ -191,12 +188,11 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt this.settingsResolver = resolver } - // Why: a debugger detach clears every CDP override Chromium holds, including the Google auth-host - // UA override, so the confirmed-override record must be dropped or the next auth navigation - // believes the identity is still installed and skips the write. - protected trackDebuggerDetachForAuthUserAgent(guest: Electron.WebContents): () => void { + // Why: a debugger detach clears every CDP override Chromium holds, including the UA override, so + // the confirmed-override record must be dropped or navigation believes an override still stands. + protected trackDebuggerDetachForUserAgentOverride(guest: Electron.WebContents): () => void { const onDetach = (): void => { - this.authUserAgentOverrideStateByGuestId.delete(guest.id) + this.cdpUserAgentOverrideStateByGuestId.delete(guest.id) } try { guest.debugger.on('detach', onDetach) diff --git a/src/main/browser/browser-manager-tab-identity.test.ts b/src/main/browser/browser-manager-tab-identity.test.ts new file mode 100644 index 00000000000..d8678a6e2cd --- /dev/null +++ b/src/main/browser/browser-manager-tab-identity.test.ts @@ -0,0 +1,385 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn(), + processUserAgentMode: 'clean', + processUserAgent: '' +})) + +vi.mock('electron', () => ({ + app: { getPath: mocks.appGetPathMock }, + BrowserWindow: { fromWebContents: mocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: mocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: mocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: mocks.screenGetCursorScreenPointMock }, + webContents: { fromId: mocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: mocks.openPopupWithOriginBarMock +})) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ + mode: mocks.processUserAgentMode, + userAgent: mocks.processUserAgent + }) +})) + +import { browserManager } from './browser-manager' +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps, + GUEST_CLEAN_UA, + GUEST_ELECTRON_UA, + type ViewportGuestHandle +} from './browser-manager-viewport-test-fixtures' + +const makeGuest = createViewportGuestFactory(mocks) +const guests = new Map>() +const PRESETS = { + none: null, + desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, + mobile: { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } +} as const +type Preset = keyof typeof PRESETS +const MODES = [ + ['clean', GUEST_CLEAN_UA], + ['native', GUEST_ELECTRON_UA] +] as const +const ORDINARY_URL = 'https://example.com/' +const AUTH_URL = 'https://accounts.google.com/v3/signin/identifier' +const APP_TOKENS = /Electron\/|orca\//i +let nextGuestId = 7000 + +type Observed = { + presented: string + standingOverride: Record | null + requestIdentity: ReturnType +} + +/** Opens a tab on `url` the way a real load does: did-start-navigation runs before any preset. */ +function openTab(url: string): { tab: string; id: number; handle: ViewportGuestHandle } { + const id = nextGuestId++ + const tab = `tab-${id}` + const handle = makeGuest(id, url) + guests.set(id, handle.guest) + expect(browserManager.registerOffscreenGuest({ browserPageId: tab, webContentsId: id })).toBe( + true + ) + navigate(url) + return { tab, id, handle } +} + +function navigate(url: string, { inPlace = false } = {}): void { + const didStartNavigation = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'did-start-navigation' + )?.[1] + expect(didStartNavigation).toBeTypeOf('function') + didStartNavigation(null, url, inPlace, true) +} + +function redirectTo(url: string): void { + const willRedirect = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'will-redirect' + )?.[1] + willRedirect({ preventDefault: vi.fn() }, url, false, true) +} + +// A redirect target that fails: the one failed-load path whose restore could otherwise write the UA. +function failLoad(url: string): void { + redirectTo(url) + const didFailLoad = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'did-fail-load' + )?.[1] + didFailLoad(null, -102, 'ERR_CONNECTION_REFUSED', url, true) +} + +async function observe( + { id, handle }: { id: number; handle: ViewportGuestHandle }, + url: string +): Promise { + await flushViewportOps() + return { + presented: handle.presentedUserAgent(), + standingOverride: handle.standingUserAgentOverride(), + requestIdentity: browserManager.resolveBrowserGuestRequestUserAgent({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads no Session member. + session: handle.guest.session as Electron.Session, + url, + webContentsId: id + }) + } +} + +async function presentWith(url: string, preset: Preset): Promise { + const opened = openTab(url) + const override = PRESETS[preset] + if (override) { + await browserManager.setViewportOverride(opened.tab, override) + } + return observe(opened, url) +} + +// Why: Chromium drops navigator.userAgentData and every sec-ch-ua header for a UA override that +// carries no userAgentMetadata, so only Firefox (which sends no hints) may stand without it. +function expectClientHintsKept(observed: Observed): void { + const override = observed.standingOverride + if (override && override.userAgent !== googleAuthUserAgent()) { + expect(override.userAgentMetadata).toBeDefined() + } +} + +describe('tab identity ownership', () => { + beforeEach(() => { + expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + resetBrowserManagerMocks(mocks) + resetBrowserManagerState() + guests.clear() + mocks.webContentsFromIdMock.mockImplementation((id) => guests.get(id)) + }) + afterEach(() => { + browserManager.unregisterAll() + vi.restoreAllMocks() + }) + + describe.each(MODES)('in %s mode', (mode, processUserAgent) => { + beforeEach(() => { + mocks.processUserAgentMode = mode + mocks.processUserAgent = processUserAgent + }) + + it.each([ORDINARY_URL, AUTH_URL])( + 'presents the same identity with a desktop preset as with none on %s', + async (url) => { + const none = await presentWith(url, 'none') + const desktop = await presentWith(url, 'desktop') + expect(desktop).toEqual(none) + expectClientHintsKept(desktop) + const expected = + mode === 'clean' && url === AUTH_URL ? googleAuthUserAgent() : processUserAgent + expect(desktop.presented).toBe(expected) + expect(desktop.requestIdentity.userAgent).toBe(expected) + expect(desktop.requestIdentity.kind).toBe( + expected === processUserAgent ? 'process' : 'google-auth' + ) + } + ) + + it('presents the mobile identity with matching client hints on an ordinary host', async () => { + const observed = await presentWith(ORDINARY_URL, 'mobile') + expect(observed.presented).toContain('iPhone') + expect(observed.standingOverride).toMatchObject({ + userAgentMetadata: expect.objectContaining({ mobile: true, platform: 'iOS' }) + }) + expect(observed.requestIdentity.kind).toBe('mobile') + expect(observed.requestIdentity.userAgent).toBe(observed.presented) + }) + + it('keeps the Google sign-in identity ahead of a mobile preset only in clean mode', async () => { + const observed = await presentWith(AUTH_URL, 'mobile') + expectClientHintsKept(observed) + if (mode === 'clean') { + expect(observed.presented).toBe(googleAuthUserAgent()) + expect(observed.requestIdentity.kind).toBe('google-auth') + } else { + expect(observed.presented).toContain('iPhone') + expect(observed.requestIdentity.kind).toBe('mobile') + } + }) + + it.each(['desktop', 'none'] as const)( + 'restores the process identity when switching from mobile to %s', + async (next) => { + const opened = openTab(ORDINARY_URL) + await browserManager.setViewportOverride(opened.tab, PRESETS.mobile) + expect(opened.handle.presentedUserAgent()).toContain('iPhone') + + await browserManager.setViewportOverride(opened.tab, PRESETS[next]) + const observed = await observe(opened, ORDINARY_URL) + expect(observed.presented).toBe(processUserAgent) + expect(observed.standingOverride).toBeNull() + expect(observed.requestIdentity).toEqual({ kind: 'process', userAgent: processUserAgent }) + } + ) + }) + + it('never puts the Electron or app tokens on a clean-mode tab under any preset', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + for (const url of [ORDINARY_URL, AUTH_URL]) { + for (const preset of ['none', 'desktop', 'mobile'] as const) { + const observed = await presentWith(url, preset) + expect(observed.presented).not.toMatch(APP_TOKENS) + expect(observed.requestIdentity.userAgent).not.toMatch(APP_TOKENS) + } + } + }) + + // Why: Chromium rejects touch emulation with maxTouchPoints 0, so every step after it used to be + // skipped and the mobile UA outlived its preset. Identity is no longer a step after touch. + it('restores the process identity and reports the failure when touch emulation is rejected', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const opened = openTab(ORDINARY_URL) + await browserManager.setViewportOverride(opened.tab, PRESETS.mobile) + // Rejects whatever the payload, so the test outlives a fix to the payload itself. + opened.handle.debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setTouchEmulationEnabled' + ? Promise.reject(new Error('Touch points must be between 1 and 16')) + : Promise.resolve(undefined) + ) + + await expect(browserManager.setViewportOverride(opened.tab, PRESETS.desktop)).resolves.toBe( + false + ) + const observed = await observe(opened, ORDINARY_URL) + expect(observed.presented).toBe(GUEST_CLEAN_UA) + expect(observed.standingOverride).toBeNull() + expect(warn).toHaveBeenCalledWith( + '[browser-manager] setViewportOverride: touch emulation failed', + expect.objectContaining({ error: 'Touch points must be between 1 and 16' }) + ) + }) + + it('still presents the requested mobile identity when device metrics fail', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const opened = openTab(ORDINARY_URL) + opened.handle.debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setDeviceMetricsOverride' + ? Promise.reject(new Error('metrics failed')) + : Promise.resolve(undefined) + ) + + await expect(browserManager.setViewportOverride(opened.tab, PRESETS.mobile)).resolves.toBe( + false + ) + expect((await observe(opened, ORDINARY_URL)).presented).toContain('iPhone') + }) + + it('heals a mid-redirect process override on the next navigation', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + const opened = openTab(AUTH_URL) + await browserManager.setViewportOverride(opened.tab, PRESETS.desktop) + const willRedirect = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'will-redirect' + )?.[1] + willRedirect({ preventDefault: vi.fn() }, ORDINARY_URL, false, true) + // The WebContents UA still says Firefox, so clearing here would expose it on an ordinary host. + expect((await observe(opened, ORDINARY_URL)).presented).toBe(GUEST_CLEAN_UA) + + navigate('https://example.org/') + const healed = await observe(opened, 'https://example.org/') + expect(healed.presented).toBe(GUEST_CLEAN_UA) + expect(healed.standingOverride).toBeNull() + }) + + // Why a table over every entry point: Chromium cancels a redirect, and reloads a loading document, + // when the WebContents UA changes anywhere but a cross-document navigation start. + it.each([ + ['a redirect', () => redirectTo(ORDINARY_URL)], + ['a failed load', () => failLoad(ORDINARY_URL)], + ['a desktop preset', (tab: string) => browserManager.setViewportOverride(tab, PRESETS.desktop)], + ['a mobile preset', (tab: string) => browserManager.setViewportOverride(tab, PRESETS.mobile)], + ['clearing a preset', (tab: string) => browserManager.setViewportOverride(tab, null)], + ['a same-document navigation', () => navigate(`${AUTH_URL}#step`, { inPlace: true })] + ])( + 'writes the WebContents UA only at a cross-document navigation start, not on %s', + async (_label, act) => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + const opened = openTab(AUTH_URL) + expect(opened.handle.guest.setUserAgent).toHaveBeenCalledOnce() + + await act(opened.tab) + await flushViewportOps() + expect(opened.handle.guest.setUserAgent).toHaveBeenCalledOnce() + + navigate('https://example.org/') + // Synchronous: the write must land inside did-start-navigation, before any await. + expect(opened.handle.guest.setUserAgent).toHaveBeenCalledTimes(2) + expect(opened.handle.webContentsUserAgent()).toBe(GUEST_CLEAN_UA) + } + ) + + // Why: setUserAgent() while a document loads makes Chromium reload it, and an OAuth callback that + // strips its code with replaceState would be requested twice — the one-time code replayed. + it('leaves the WebContents UA alone on a same-document navigation', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + const opened = openTab(AUTH_URL) + const willRedirect = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'will-redirect' + )?.[1] + willRedirect({ preventDefault: vi.fn() }, `${ORDINARY_URL}callback?code=1`, false, true) + await flushViewportOps() + + navigate(`${ORDINARY_URL}callback`, { inPlace: true }) + expect(opened.handle.webContentsUserAgent()).toBe(googleAuthUserAgent()) + expect((await observe(opened, ORDINARY_URL)).presented).toBe(GUEST_CLEAN_UA) + + navigate('https://example.org/') + expect(opened.handle.webContentsUserAgent()).toBe(GUEST_CLEAN_UA) + }) + + // Why: a failed navigation commits an error page that is still loading at did-fail-load, and + // setUserAgent() then makes Chromium reload it — the failed request replayed. + it('leaves the WebContents UA alone when a redirected navigation fails', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + const opened = openTab(ORDINARY_URL) + navigate(`${ORDINARY_URL}start`) + const willRedirect = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'will-redirect' + )?.[1] + willRedirect({ preventDefault: vi.fn() }, AUTH_URL, false, true) + await flushViewportOps() + + opened.handle.commitNavigationTo(AUTH_URL) + const didFailLoad = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'did-fail-load' + )?.[1] + didFailLoad(null, -102, 'ERR_CONNECTION_REFUSED', AUTH_URL, true) + expect(opened.handle.webContentsUserAgent()).toBe(GUEST_CLEAN_UA) + const observed = await observe(opened, AUTH_URL) + expect(observed.presented).toBe(googleAuthUserAgent()) + expect(observed.requestIdentity.kind).toBe('google-auth') + }) + + // Why: a debugger that cannot attach (DevTools open on the guest) installs no mobile identity, so + // requests claiming one would disagree with the document's own navigator.userAgent. + it('keeps requests on the presented identity when a mobile preset cannot attach', async () => { + mocks.processUserAgentMode = 'clean' + mocks.processUserAgent = GUEST_CLEAN_UA + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const opened = openTab(ORDINARY_URL) + opened.handle.debuggerIsAttached.mockReturnValue(false) + opened.handle.debuggerAttach.mockImplementation(() => { + throw new Error('Another debugger is already attached') + }) + + await expect(browserManager.setViewportOverride(opened.tab, PRESETS.mobile)).resolves.toBe( + false + ) + navigate('https://example.org/') + const observed = await observe(opened, 'https://example.org/') + expect(observed.presented).toBe(GUEST_CLEAN_UA) + expect(observed.requestIdentity).toEqual({ kind: 'process', userAgent: GUEST_CLEAN_UA }) + }) +}) diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts index 3d3825d6ebe..302cf6468ed 100644 --- a/src/main/browser/browser-manager-types.ts +++ b/src/main/browser/browser-manager-types.ts @@ -1,4 +1,5 @@ import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import type { UserAgentMetadata } from './browser-tab-identity' import type { BrowserDownloadFinishedEvent, BrowserDownloadProgressEvent, @@ -60,15 +61,21 @@ export type PendingMainFrameNavigation = { supersededUrls: string[] } -export type AuthUserAgentOverrideOperation = { - sequence: number +/** An Emulation.setUserAgentOverride payload; an empty userAgent clears the override. */ +export type CdpUserAgentOverride = { userAgent: string + userAgentMetadata?: UserAgentMetadata } -export type AuthUserAgentOverrideState = { - confirmed: AuthUserAgentOverrideOperation | null +export type CdpUserAgentOverrideOperation = { + sequence: number + override: CdpUserAgentOverride +} + +export type CdpUserAgentOverrideState = { + confirmed: CdpUserAgentOverrideOperation | null nextSequence: number - pending: AuthUserAgentOverrideOperation[] + pending: CdpUserAgentOverrideOperation[] } export const SAFE_POPUP_WINDOW_OPTIONS = { diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index 41835a6cd3e..a8a4f3bacd4 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -156,7 +156,8 @@ describe('browserManager', () => { if (mobile) { expect(userAgentOverride).toMatchObject({ userAgent: expect.stringContaining('iPhone') }) } else { - expect(userAgentOverride).toEqual({ userAgent: GUEST_ELECTRON_UA }) + // A desktop preset presents the process identity itself, so there is nothing to override. + expect(userAgentOverride).toBeUndefined() } } ) @@ -193,8 +194,8 @@ describe('browserManager', () => { } ) - it('re-issues the standing UA override when navigating onto and back off an auth host', async () => { - const { guest, debuggerSendCommand } = makeGuest(4247) + it('keeps a desktop-preset tab on the right identity onto and back off an auth host', async () => { + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest(4247) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ @@ -214,30 +215,25 @@ describe('browserManager', () => { isMainFrame: boolean ) => void - // Case A: the desktop preset lands first, while the tab is still off the auth host. + // The desktop preset lands first, while the tab is still off the auth host. await browserManager.setViewportOverride('tab-auth-nav', { width: 1024, height: 768, deviceScaleFactor: 1, mobile: false }) - expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', { - userAgent: GUEST_CLEAN_UA - }) + expect(lastUserAgentOverride(debuggerSendCommand)).toBeUndefined() + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) - // Navigating to the auth host must move the standing override to the Firefox identity. - debuggerSendCommand.mockClear() didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) await flushViewportOps() - expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: googleAuthUserAgent() - }) + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) - // Leaving the auth host restores the clean Chrome-shaped preset UA. - debuggerSendCommand.mockClear() + // A redirect cannot rewrite the Firefox WebContents UA, so the override carries the process UA. willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + expect(guest.setUserAgent).toHaveBeenCalledTimes(1) + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) }) // Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so @@ -255,12 +251,13 @@ describe('browserManager', () => { return calls.at(-1)?.[1] as Record | undefined } - // Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent - // has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox - // UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile - // branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect. + // Why mobile: a desktop preset installs no override at all, so only the mobile write can leave a + // Chrome-shaped UA standing on the auth host. it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => { - const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/') + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( + 4251, + 'https://example.com/' + ) // Hold the preset's first CDP command open so the navigation lands inside its await window. let releaseMetrics = (): void => {} const metricsGate = new Promise((resolve) => { @@ -294,13 +291,18 @@ describe('browserManager', () => { // Without a shared chain + shared URL source, the preset resumes, reads getURL() as the // pre-navigation host, and pins the tab to the Chrome-shaped UA while it is on the auth host. - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ - userAgent: googleAuthUserAgent() - }) + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) + ) }) it('does not leave the Firefox UA standing when a preset lands mid-navigation off an auth host', async () => { - const { guest, debuggerSendCommand } = makeGuest(4252, 'https://accounts.google.com/') + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( + 4252, + 'https://accounts.google.com/' + ) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ @@ -347,11 +349,12 @@ describe('browserManager', () => { // Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the // navigation's correct write, stranding the Firefox UA on a non-auth page. - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: '' }) }) it('falls back to the committed URL once a navigation commits or fails', async () => { - const { guest, debuggerSendCommand, commitNavigationTo } = makeGuest( + const { guest, debuggerSendCommand, commitNavigationTo, presentedUserAgent } = makeGuest( 4253, 'https://example.com/' ) @@ -392,8 +395,9 @@ describe('browserManager', () => { didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true) await flushViewportOps() - expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_CLEAN_UA) - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + // The restore goes over CDP: only did-start-navigation wrote the WebContents UA. + expect(guest.setUserAgent).toHaveBeenCalledOnce() + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) // A later preset must also resolve the committed, non-auth URL. debuggerSendCommand.mockClear() @@ -407,7 +411,6 @@ describe('browserManager', () => { didNavigate(null, 'https://accounts.google.com/signin') await flushViewportOps() - debuggerSendCommand.mockClear() await browserManager.setViewportOverride('tab-pending-cleared', { width: 1024, height: 768, @@ -415,13 +418,14 @@ describe('browserManager', () => { mobile: false }) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ - userAgent: googleAuthUserAgent() - }) + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) }) it('does not let a superseded navigation failure revert a newer target', async () => { - const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/') + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( + 4255, + 'https://example.com/' + ) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ @@ -460,20 +464,19 @@ describe('browserManager', () => { willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true) didStartNavigation(null, 'https://accounts.google.com/same', false, true) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ - userAgent: googleAuthUserAgent() - }) + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) debuggerSendCommand.mockClear() didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true) await flushViewportOps() - // Why: the redirect path never writes the WebContents UA — that write cancels the navigation. - expect(guest.setUserAgent).not.toHaveBeenCalled() + // Only the direct navigation onto the auth host wrote the WebContents UA. + expect(guest.setUserAgent).toHaveBeenCalledOnce() expect(debuggerSendCommand).not.toHaveBeenCalledWith( 'Emulation.setUserAgentOverride', - expect.objectContaining({ userAgent: GUEST_CLEAN_UA }) + expect.anything() ) + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) }) it('switches identity for a server redirect and restores it if the redirect fails', async () => { @@ -529,10 +532,11 @@ describe('browserManager', () => { userAgent: googleAuthUserAgent() }) + // The WebContents UA never left the process identity, so restoring it clears the override. didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true) await flushViewportOps() expect(guest.setUserAgent).not.toHaveBeenCalled() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: '' }) }) it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => { @@ -584,7 +588,11 @@ describe('browserManager', () => { }) await browserManager.setViewportOverride('tab-mobile-popup-owner', MOBILE_VIEWPORT_OVERRIDE) - const { guest: popupGuest, debuggerSendCommand } = makeGuest(4264, 'https://mail.google.com/') + const { + guest: popupGuest, + debuggerSendCommand, + presentedUserAgent + } = makeGuest(4264, 'https://mail.google.com/') const popupOn = vi.fn() popupGuest.on = popupOn browserManager.attachGuestPolicies(popupGuest as never, { @@ -607,11 +615,18 @@ describe('browserManager', () => { didStartNavigation(null, 'https://example.com/', false, true) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) + ) }) it('reapplies a preset when navigation starts during its final UA write', async () => { - const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/') + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( + 4257, + 'https://example.com/' + ) let releaseFirstUa = (): void => {} const firstUaGate = new Promise((resolve) => { releaseFirstUa = resolve @@ -634,25 +649,26 @@ describe('browserManager', () => { ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - const presetDone = browserManager.setViewportOverride('tab-final-apply-race', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) + const presetDone = browserManager.setViewportOverride( + 'tab-final-apply-race', + MOBILE_VIEWPORT_OVERRIDE + ) await flushViewportOps() didStartNavigation(null, 'https://accounts.google.com/', false, true) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ - userAgent: googleAuthUserAgent() - }) + // The navigation's write is issued after the held mobile write, so it wins however they settle. releaseFirstUa() await presetDone + await flushViewportOps() + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) }) it('does not reinstall a preset while its final UA clear is in flight', async () => { - const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/') + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( + 4258, + 'https://example.com/' + ) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ @@ -664,12 +680,7 @@ describe('browserManager', () => { ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - await browserManager.setViewportOverride('tab-final-clear-race', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) + await browserManager.setViewportOverride('tab-final-clear-race', MOBILE_VIEWPORT_OVERRIDE) let releaseClearUa = (): void => {} const clearUaGate = new Promise((resolve) => { releaseClearUa = resolve @@ -686,19 +697,25 @@ describe('browserManager', () => { debuggerSendCommand.mockClear() didStartNavigation(null, 'https://accounts.google.com/', false, true) await flushViewportOps() - expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: googleAuthUserAgent() - }) + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) + ) releaseClearUa() await clearDone + await flushViewportOps() + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) }) // Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry // first makes it untracked, so the navigation path can never correct it again and the tab carries // a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents. it('keeps tracking the standing override when the CDP clear fails', async () => { - const { guest, debuggerSendCommand } = makeGuest(4254, 'https://example.com/') + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest( + 4254, + 'https://example.com/' + ) webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ @@ -710,15 +727,10 @@ describe('browserManager', () => { ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - await browserManager.setViewportOverride('tab-failed-clear', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) + await browserManager.setViewportOverride('tab-failed-clear', MOBILE_VIEWPORT_OVERRIDE) await flushViewportOps() - // The final UA clear fails after tracking was optimistically removed. + // The final UA clear fails, leaving the mobile override standing. debuggerSendCommand.mockImplementation( (method: string, params: { userAgent?: string } | undefined) => method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' @@ -736,8 +748,9 @@ describe('browserManager', () => { didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) await flushViewportOps() expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: googleAuthUserAgent() + userAgent: '' }) + expect(presentedUserAgent()).toBe(googleAuthUserAgent()) }) it('does not touch the UA override on navigation when no preset is standing', async () => { @@ -774,12 +787,7 @@ describe('browserManager', () => { ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - await browserManager.setViewportOverride('tab-cleared-preset', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) + await browserManager.setViewportOverride('tab-cleared-preset', MOBILE_VIEWPORT_OVERRIDE) await browserManager.setViewportOverride('tab-cleared-preset', null) debuggerSendCommand.mockClear() @@ -791,7 +799,7 @@ describe('browserManager', () => { ) }) - it('reapplies the native process identity instead of the Google exception', async () => { + it('keeps a native-mode mobile preset off the Google exception on navigation', async () => { browserMocks.processUserAgentMode = 'native' browserMocks.processUserAgent = GUEST_ELECTRON_UA const { guest, debuggerSendCommand } = makeGuest(4250) @@ -807,17 +815,13 @@ describe('browserManager', () => { ([event]) => event === 'did-start-navigation' )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void - await browserManager.setViewportOverride('tab-native-nav', { - width: 1024, - height: 768, - deviceScaleFactor: 1, - mobile: false - }) + await browserManager.setViewportOverride('tab-native-nav', MOBILE_VIEWPORT_OVERRIDE) debuggerSendCommand.mockClear() didStartNavigation(null, 'https://accounts.google.com/', false, true) await flushViewportOps() - expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: GUEST_ELECTRON_UA + expect(guest.setUserAgent).not.toHaveBeenCalled() + expect(lastUserAgentOverride(debuggerSendCommand)).toMatchObject({ + userAgent: expect.stringContaining('iPhone') }) }) @@ -839,9 +843,26 @@ describe('browserManager', () => { enabled: false, maxTouchPoints: 0 }) - expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { - userAgent: '' + // Nothing overrode the identity, so there is nothing to clear. + expect(lastUserAgentOverride(debuggerSendCommand)).toBeUndefined() + }) + + it('clears the mobile identity for override=null', async () => { + const { guest, debuggerSendCommand, presentedUserAgent } = makeGuest(4344) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-clear-mobile', + webContentsId: guest.id as number, + rendererWebContentsId }) + + await browserManager.setViewportOverride('tab-clear-mobile', MOBILE_VIEWPORT_OVERRIDE) + await expect(browserManager.setViewportOverride('tab-clear-mobile', null)).resolves.toBe(true) + await flushViewportOps() + + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: '' }) + expect(presentedUserAgent()).toBe(GUEST_CLEAN_UA) }) it('attaches the debugger if not already attached and does not detach after', async () => { diff --git a/src/main/browser/browser-manager-viewport-ownership.test.ts b/src/main/browser/browser-manager-viewport-ownership.test.ts index 8b4492f36fd..db2d0b9a38f 100644 --- a/src/main/browser/browser-manager-viewport-ownership.test.ts +++ b/src/main/browser/browser-manager-viewport-ownership.test.ts @@ -50,16 +50,16 @@ const mobile = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } const desktop = { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false } const guests = new Map>() const registeredGuests = readViewportStateMap('webContentsIdByTabId') -const uaIntents = readViewportStateMap('viewportUaOverrideMobileByTabId') -const presetIntents = readViewportStateMap('viewportPresetActiveByTabId') +const presetIntents = readViewportStateMap('viewportPresetByTabId') const pendingOperations = readViewportStateMap('viewportOpsByTabId') +const cdpUserAgentOverrides = readViewportStateMap('cdpUserAgentOverrideStateByGuestId') function readViewportStateMap( name: | 'webContentsIdByTabId' - | 'viewportUaOverrideMobileByTabId' - | 'viewportPresetActiveByTabId' + | 'viewportPresetByTabId' | 'viewportOpsByTabId' + | 'cdpUserAgentOverrideStateByGuestId' ): Map { const value: unknown = browserManager[name] if (!(value instanceof Map)) { @@ -116,7 +116,7 @@ describe('browser viewport operation ownership', () => { const result = browserManager.setViewportOverride('closed', mobile) await gate.entered browserManager.unregisterGuest('closed') - expect(uaIntents.size).toBe(0) + expect(presetIntents.size).toBe(0) const isDestroyed = handle.guest.isDestroyed expect(vi.isMockFunction(isDestroyed)).toBe(true) if (vi.isMockFunction(isDestroyed)) { @@ -126,7 +126,7 @@ describe('browser viewport operation ownership', () => { await expect(result).resolves.toBe(false) expect(registeredGuests.size).toBe(0) expect(presetIntents.size).toBe(0) - expect(uaIntents.get('closed')).toBeUndefined() + expect(cdpUserAgentOverrides.size).toBe(0) }) it('does not restore closed-tab UA intent after a failed clear', async () => { @@ -143,7 +143,8 @@ describe('browser viewport operation ownership', () => { } gate.reject(new Error('Target closed')) await expect(result).resolves.toBe(false) - expect(uaIntents.get('clear-close')).toBeUndefined() + expect(presetIntents.get('clear-close')).toBeUndefined() + expect(cdpUserAgentOverrides.size).toBe(0) }) it('preserves replacement desktop intent after an old clear fails', async () => { @@ -155,14 +156,12 @@ describe('browser viewport operation ownership', () => { browserManager.unregisterGuest('replacement') register('replacement', 103) await expect(browserManager.setViewportOverride('replacement', desktop)).resolves.toBe(true) - expect(uaIntents.get('replacement')).toBe(false) gate.reject(new Error('Old target closed')) await expect(result).resolves.toBe(false) expect(registeredGuests.get('replacement')).toBe(103) - expect(uaIntents.get('replacement')).toBe(false) expect(presetIntents.get('replacement')).toEqual({ guestWebContentsId: 103, - active: true + override: desktop }) }) @@ -177,11 +176,13 @@ describe('browser viewport operation ownership', () => { await browserManager.setViewportOverride('late-delete', mobile) gate.resolve() await expect(result).resolves.toBe(false) - expect(uaIntents.has('late-delete')).toBe(true) + expect(presetIntents.get('late-delete')).toEqual({ guestWebContentsId: 105, override: mobile }) expect(registeredGuests.get('late-delete')).toBe(105) }) - it('same-owner clear failure still restores the legitimate earlier intent', async () => { + // The request is still "no preset"; what survives the failure is the record that the mobile + // override stands, which is what lets the next navigation clear it. + it('same-owner clear failure keeps the standing mobile override tracked', async () => { const handle = register('same-owner', 106) await browserManager.setViewportOverride('same-owner', mobile) const gate = pause(handle, 'Emulation.setUserAgentOverride') @@ -189,7 +190,9 @@ describe('browser viewport operation ownership', () => { await gate.entered gate.reject(new Error('Protocol error')) await expect(result).resolves.toBe(false) - expect(uaIntents.get('same-owner')).toBe(true) + expect(presetIntents.get('same-owner')).toEqual({ guestWebContentsId: 106, override: null }) + expect(handle.presentedUserAgent()).toContain('iPhone') + expect(cdpUserAgentOverrides.has(106)).toBe(true) }) it('old apply cannot overwrite a replacement guest desktop intent', async () => { @@ -202,7 +205,11 @@ describe('browser viewport operation ownership', () => { await browserManager.setViewportOverride('late-apply', desktop) gate.resolve() await expect(pending).resolves.toBe(false) - expect(uaIntents.get('late-apply')).toBe(false) + expect(presetIntents.get('late-apply')).toEqual({ guestWebContentsId: 111, override: desktop }) + expect(old.debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) }) it('an old guest cannot write UA intent after replacement in native process mode', async () => { @@ -216,7 +223,11 @@ describe('browser viewport operation ownership', () => { register('native-replacement', 113) gate.resolve() await expect(pending).resolves.toBe(false) - expect(uaIntents.get('native-replacement')).toBeUndefined() + expect(presetIntents.get('native-replacement')).toBeUndefined() + expect(old.debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) }) it('old queued operations cannot remove or join a replacement promise tail', async () => { @@ -240,7 +251,11 @@ describe('browser viewport operation ownership', () => { await expect(replacementFirst).resolves.toBe(true) await expect(replacementSecond).resolves.toBe(true) expect(pendingOperations.size).toBe(0) - expect(uaIntents.get('queued-replacement')).toBe(true) + expect(presetIntents.get('queued-replacement')).toEqual({ + guestWebContentsId: 115, + override: mobile + }) + expect(replacement.presentedUserAgent()).toContain('iPhone') }) it('normal same-owner toggles preserve last-requested order and remove the promise tail', async () => { @@ -252,19 +267,18 @@ describe('browser viewport operation ownership', () => { const third = browserManager.setViewportOverride('serialized', null) gate.resolve() expect(await Promise.all([first, second, third])).toEqual([true, true, true]) + // Identity follows the last request, which was "no preset" before any identity write ran, so the + // mobile UA is never installed at all. expect(handle.debuggerSendCommand.mock.calls.map(([method]) => method)).toEqual([ 'Emulation.setDeviceMetricsOverride', 'Emulation.setTouchEmulationEnabled', - 'Emulation.setUserAgentOverride', 'Emulation.setDeviceMetricsOverride', 'Emulation.setTouchEmulationEnabled', - 'Emulation.setUserAgentOverride', 'Emulation.clearDeviceMetricsOverride', - 'Emulation.setTouchEmulationEnabled', - 'Emulation.setUserAgentOverride' + 'Emulation.setTouchEmulationEnabled' ]) expect(pendingOperations.size).toBe(0) - expect(uaIntents.size).toBe(0) + expect(presetIntents.get('serialized')).toEqual({ guestWebContentsId: 116, override: null }) }) it.each([false, true])( @@ -276,13 +290,20 @@ describe('browser viewport operation ownership', () => { await expect( browserManager.setViewportOverride('native', mobileMode ? mobile : desktop) ).resolves.toBe(true) - expect(handle.debuggerSendCommand).toHaveBeenCalledWith( - 'Emulation.setUserAgentOverride', - mobileMode - ? expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) - : { userAgent: GUEST_ELECTRON_UA } + if (mobileMode) { + expect(handle.debuggerSendCommand).toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: expect.stringContaining('iPhone') }) + ) + } else { + expect(handle.debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + } + expect(handle.presentedUserAgent()).toEqual( + mobileMode ? expect.stringContaining('iPhone') : GUEST_ELECTRON_UA ) - expect(uaIntents.get('native')).toBe(mobileMode) } ) @@ -304,7 +325,7 @@ describe('browser viewport operation ownership', () => { expect(await Promise.all(operations.map(({ pending }) => pending))).toEqual( Array(16).fill(false) ) - expect(uaIntents.size).toBe(0) + expect(cdpUserAgentOverrides.size).toBe(0) expect(registeredGuests.size).toBe(0) expect(pendingOperations.size).toBe(0) expect(presetIntents.size).toBe(0) diff --git a/src/main/browser/browser-manager-viewport-scroll-state.ts b/src/main/browser/browser-manager-viewport-scroll-state.ts index 1f83534c856..04063fb3c30 100644 --- a/src/main/browser/browser-manager-viewport-scroll-state.ts +++ b/src/main/browser/browser-manager-viewport-scroll-state.ts @@ -1,5 +1,8 @@ import { webContents } from 'electron' -import type { BrowserViewportScrollState } from '../../shared/browser-workspace-types' +import type { + BrowserViewportOverride, + BrowserViewportScrollState +} from '../../shared/browser-workspace-types' /** * Renderer routing plus the host-side viewport-preset geometry the wheel path needs to decide @@ -7,11 +10,11 @@ import type { BrowserViewportScrollState } from '../../shared/browser-workspace- */ export abstract class BrowserManagerViewportScrollState { protected readonly rendererWebContentsIdByTabId = new Map() - // Why: host-side wheel panning follows the requested local viewport on the owning guest; - // replacement guests must not inherit a retired guest's state. - protected readonly viewportPresetActiveByTabId = new Map< + // Why: the requested preset, not the applied one — host-side wheel panning and the tab's identity + // both follow what was asked for; replacement guests must not inherit a retired guest's preset. + protected readonly viewportPresetByTabId = new Map< string, - { guestWebContentsId: number; active: boolean } + { guestWebContentsId: number; override: BrowserViewportOverride | null } >() protected readonly viewportScrollStateByTabId = new Map() diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index b440a768b71..4d6437cf5d9 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -21,19 +21,47 @@ export type ViewportGuestHandle = { debuggerAttach: ReturnType setGuestUserAgent: (ua: string) => void commitNavigationTo: (nextUrl: string) => void + webContentsUserAgent: () => string + /** What navigator.userAgent reports: the latest applied CDP override, else the WebContents UA. */ + presentedUserAgent: () => string + /** The CDP UA override Chromium holds, or null when none stands. */ + standingUserAgentOverride: () => Record | null } // Why: the guest wires the file's own hoisted mocks, which cannot be imported here. +// Why the process identity: the session UA and app.userAgentFallback both carry it, so that is what a +// real guest's getUserAgent() reports until something writes the WebContents UA. export function createViewportGuestFactory( - mocks: BrowserManagerMocks + mocks: BrowserManagerMocks & { processUserAgent?: string } ): (id: number, url?: string) => ViewportGuestHandle { return function makeGuest(id: number, url = 'https://example.com/'): ViewportGuestHandle { const debuggerSendCommand = vi.fn().mockResolvedValue(undefined) const debuggerIsAttached = vi.fn(() => true) const debuggerAttach = vi.fn() - let currentUa = GUEST_ELECTRON_UA + let currentUa = mocks.processUserAgent ?? GUEST_ELECTRON_UA // Why: getURL() reports the last COMMITTED url — it does not move at did-start-navigation. let committedUrl = url + // Chromium applies CDP commands in issue order, so a later-issued write wins however they settle, + // and a rejected write changes nothing. An empty userAgent clears the override. + let issuedUserAgentWrites = 0 + let appliedUserAgentWrite = 0 + let cdpOverride: Record | null = null + const sendCommand = (method: string, params?: Record): unknown => { + const result: unknown = debuggerSendCommand(method, params) + if (method === 'Emulation.setUserAgentOverride') { + const ordinal = ++issuedUserAgentWrites + void Promise.resolve(result).then( + () => { + if (ordinal > appliedUserAgentWrite) { + appliedUserAgentWrite = ordinal + cdpOverride = params?.userAgent ? params : null + } + }, + () => {} + ) + } + return result + } const guest = { id, isDestroyed: vi.fn(() => false), @@ -53,7 +81,7 @@ export function createViewportGuestFactory( debugger: { isAttached: debuggerIsAttached, attach: debuggerAttach, - sendCommand: debuggerSendCommand, + sendCommand, on: vi.fn(), off: vi.fn() } @@ -68,7 +96,11 @@ export function createViewportGuestFactory( }, commitNavigationTo: (nextUrl: string) => { committedUrl = nextUrl - } + }, + webContentsUserAgent: () => currentUa, + presentedUserAgent: () => + typeof cdpOverride?.userAgent === 'string' ? cdpOverride.userAgent : currentUa, + standingUserAgentOverride: () => cdpOverride } } } diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts index a4e263dcced..b85e6d0424f 100644 --- a/src/main/browser/browser-manager-viewport.ts +++ b/src/main/browser/browser-manager-viewport.ts @@ -5,9 +5,7 @@ import { type BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' -import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. @@ -39,11 +37,11 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (expectedWebContentsId !== undefined) { - // Keep host panning available while CDP applies the requested dimensions. The guest id fence - // prevents this intent from leaking to a replacement guest; clearing the preset removes it. - this.viewportPresetActiveByTabId.set(browserTabId, { + // Record the request before CDP runs: host panning and the tab's identity both follow it, so a + // navigation mid-apply already sees it. The guest id fence keeps it off a replacement guest. + this.viewportPresetByTabId.set(browserTabId, { guestWebContentsId: expectedWebContentsId, - active: override !== null + override }) } // The renderer resizes the host before CDP completes; discard the old geometry until it @@ -146,84 +144,54 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec } const dbg = guest.debugger + const metricsApplied = await this.runViewportEmulationStep( + browserTabId, + 'device metrics', + () => + override + ? dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: override.width, + height: override.height, + deviceScaleFactor: override.deviceScaleFactor, + mobile: override.mobile + }) + : dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) + ) + const touchApplied = await this.runViewportEmulationStep(browserTabId, 'touch emulation', () => + dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: override?.mobile ?? false, + maxTouchPoints: override?.mobile ? 5 : 0 + }) + ) + if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { + return false + } + // Why: identity is not an emulation step. It follows the requested preset whatever the steps + // above did, so a failed metrics or touch write can never strand a mobile identity on the tab. + const identityApplied = await this.runViewportEmulationStep(browserTabId, 'identity', () => + this.retargetTabIdentity(guest, this.resolveTabNavigationUrl(guest)) + ) + return ( + metricsApplied && + touchApplied && + identityApplied && + this.webContentsIdByTabId.get(browserTabId) === webContentsId + ) + } + + private async runViewportEmulationStep( + browserTabId: string, + step: string, + send: () => Promise + ): Promise { try { - if (override) { - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width: override.width, - height: override.height, - deviceScaleFactor: override.deviceScaleFactor, - mobile: override.mobile - }) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: true - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: override.mobile, - maxTouchPoints: override.mobile ? 5 : 0 - }) - if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { - return false - } - // Navigation must see the preset while the final CDP write is in flight. - this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) - await this.sendViewportUserAgentOverride(guest, override.mobile) - } else { - await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: false - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: false, - maxTouchPoints: 0 - }) - if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { - return false - } - const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - // A navigation after this point must not re-install the override behind the clear. - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - try { - if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { - const url = this.resolveTabNavigationUrl(guest) - const identity = getBrowserProcessUserAgentIdentity() - // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve - // one coherent identity per mode instead of pairing a Firefox document with native workers. - const restored = await this.applyAuthUserAgentOverrideOverCdp( - guest, - false, - url, - identity.mode === 'clean' && isGoogleAuthUrl(url) - ? googleAuthUserAgent() - : identity.userAgent - ) - if (!restored) { - throw new Error('Failed to preserve auth user agent') - } - } else { - // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) - } - } catch (error) { - if ( - trackedMobile !== undefined && - this.webContentsIdByTabId.get(browserTabId) === webContentsId - ) { - this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) - } - throw error - } - } - if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { - return false - } - return true - } catch { + // A step that reports its own failure resolves false rather than throwing. + return (await send()) !== false + } catch (error) { + console.warn(`[browser-manager] setViewportOverride: ${step} failed`, { + browserTabId, + error: error instanceof Error ? error.message : String(error) + }) return false } } diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 27965a56433..a3e84b290f0 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -556,7 +556,7 @@ describe('BrowserSessionRegistry', () => { { webRequest: { onBeforeSendHeaders } } as never, (request) => request.currentUserAgent === googleAuthUserAgent() - ? { userAgent: googleAuthUserAgent() } + ? { kind: 'google-auth', userAgent: googleAuthUserAgent() } : undefined ) expect(onBeforeSendHeaders).toHaveBeenCalledWith( diff --git a/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts b/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts index 05331ab5c65..c6cba96c6d7 100644 --- a/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts +++ b/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts @@ -252,10 +252,10 @@ async function run() { if (arm === 'branch') { installBrowserSessionUserAgentPolicy(sess, request => { if (request.resourceType !== 'mainFrame' && (request.currentUserAgent === firefoxUserAgent || request.effectiveUserAgent === firefoxUserAgent)) { - return { userAgent: firefoxUserAgent } + return { kind: 'google-auth', userAgent: firefoxUserAgent } } if (request.resourceType === 'mainFrame' && request.currentUserAgent === firefoxUserAgent) { - return { userAgent: cleanUserAgent } + return { kind: 'process', userAgent: cleanUserAgent } } return undefined }) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 5b4d4735dcd..fc05fe357cc 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,5 +1,5 @@ import type { Session } from 'electron' -import type { ViewportUserAgentOverride } from './browser-viewport-user-agent' +import type { BrowserTabIdentity, UserAgentMetadata } from './browser-tab-identity' export { cleanElectronUserAgent } from './browser-process-user-agent' import { getBrowserProcessUserAgentIdentity } from './browser-process-user-agent' @@ -19,7 +19,7 @@ export type BrowserSessionRequestUserAgentResolver = (args: { webContentsId?: number currentUserAgent?: string effectiveUserAgent?: string -}) => ViewportUserAgentOverride | undefined +}) => BrowserTabIdentity | undefined function quoteClientHint(value: string): string { return `"${value.replace(/["\\]/g, '\\$&')}"` @@ -33,7 +33,7 @@ function formatClientHintBrands(brands: { brand: string; version: string }[]): s function applyUserAgentMetadataHeaders( headers: Record, - metadata: NonNullable + metadata: UserAgentMetadata ): void { const values: Record = { 'sec-ch-ua': formatClientHintBrands(metadata.brands), @@ -101,15 +101,10 @@ export function installBrowserSessionUserAgentPolicy( callback({ requestHeaders: headers }) return } - if (identity.userAgent) { - setUserAgentHeader(headers, identity.userAgent) - } - if (identity.userAgent === firefoxUa) { + setUserAgentHeader(headers, identity.userAgent) + if (identity.kind === 'google-auth') { stripClientHints(headers) - callback({ requestHeaders: headers }) - return - } - if (identity.userAgentMetadata) { + } else if (identity.kind === 'mobile') { applyUserAgentMetadataHeaders(headers, identity.userAgentMetadata) } callback({ requestHeaders: headers }) diff --git a/src/main/browser/browser-tab-identity.test.ts b/src/main/browser/browser-tab-identity.test.ts new file mode 100644 index 00000000000..520eae50819 --- /dev/null +++ b/src/main/browser/browser-tab-identity.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { resolveBrowserTabIdentity } from './browser-tab-identity' + +const CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' +const NATIVE_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.0.0 Chrome/134.0.0.0 Electron/43.0.0 Safari/537.36' +const clean = { mode: 'clean', userAgent: CHROME_UA } as const +const native = { mode: 'native', userAgent: NATIVE_UA } as const +const AUTH_URL = 'https://accounts.google.com/v3/signin/identifier' + +describe('resolveBrowserTabIdentity', () => { + it('presents Firefox on Google auth hosts in clean mode regardless of the preset', () => { + for (const mobile of [false, true]) { + expect(resolveBrowserTabIdentity({ url: AUTH_URL, mobile, processIdentity: clean })).toEqual({ + kind: 'google-auth', + userAgent: googleAuthUserAgent() + }) + } + }) + + it('leaves Google auth hosts on the native identity in native mode', () => { + expect( + resolveBrowserTabIdentity({ url: AUTH_URL, mobile: false, processIdentity: native }) + ).toEqual({ kind: 'process', userAgent: NATIVE_UA }) + expect( + resolveBrowserTabIdentity({ url: AUTH_URL, mobile: true, processIdentity: native }).kind + ).toBe('mobile') + }) + + it('presents the process identity itself off the auth hosts without a mobile preset', () => { + for (const processIdentity of [clean, native]) { + expect( + resolveBrowserTabIdentity({ + url: 'https://myaccount.google.com/', + mobile: false, + processIdentity + }) + ).toEqual({ kind: 'process', userAgent: processIdentity.userAgent }) + } + }) + + it('splices the real Chrome major into the mobile UA and its client hints', () => { + const identity = resolveBrowserTabIdentity({ + url: 'https://example.com/', + mobile: true, + processIdentity: clean + }) + expect(identity.kind).toBe('mobile') + expect(identity.userAgent).toContain('iPhone') + expect(identity.userAgent).toContain('CriOS/134.0.0.0') + if (identity.kind === 'mobile') { + expect(identity.userAgentMetadata.mobile).toBe(true) + expect(identity.userAgentMetadata.platform).toBe('iOS') + expect(identity.userAgentMetadata.brands).toContainEqual({ + brand: 'Google Chrome', + version: '134' + }) + } + }) + + it('falls back to a known Chrome major when the process UA carries none', () => { + const identity = resolveBrowserTabIdentity({ + url: 'https://example.com/', + mobile: true, + processIdentity: { mode: 'clean', userAgent: googleAuthUserAgent() } + }) + expect(identity.userAgent).toContain('CriOS/134.0.0.0') + }) + + it('treats an unparseable URL as a non-auth host', () => { + expect( + resolveBrowserTabIdentity({ url: 'not a url', mobile: false, processIdentity: clean }) + ).toEqual({ kind: 'process', userAgent: CHROME_UA }) + }) +}) diff --git a/src/main/browser/browser-tab-identity.ts b/src/main/browser/browser-tab-identity.ts new file mode 100644 index 00000000000..713b0310a4a --- /dev/null +++ b/src/main/browser/browser-tab-identity.ts @@ -0,0 +1,90 @@ +// Why one owner: a tab's identity is read by the session request hook and written to the guest over +// two layers (the WebContents UA and a CDP override that outranks it). When each layer derived it on +// its own, a desktop viewport preset installed a CDP override with no userAgentMetadata, and Chromium +// then drops navigator.userAgentData and every sec-ch-ua header for that tab — a Chrome UA with no +// client hints, which bot checks read as a spoof. Every layer now asks this module instead. + +import type { BrowserProcessUserAgentIdentity } from './browser-process-user-agent' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' + +type UserAgentBrand = { brand: string; version: string } + +export type UserAgentMetadata = { + brands: UserAgentBrand[] + fullVersionList: UserAgentBrand[] + fullVersion: string + platform: string + platformVersion: string + architecture: string + model: string + mobile: boolean +} + +export type BrowserTabIdentity = + /** The process identity, with its client hints left to Chromium. */ + | { kind: 'process'; userAgent: string } + /** Firefox on Google's auth hosts; real Firefox sends no client hints. */ + | { kind: 'google-auth'; userAgent: string } + | { kind: 'mobile'; userAgent: string; userAgentMetadata: UserAgentMetadata } + +export function googleAuthTabIdentity(): BrowserTabIdentity { + return { kind: 'google-auth', userAgent: googleAuthUserAgent() } +} + +/** + * The identity a tab presents at `url`. Desktop presets and "no preset" are deliberately the same + * input: only a mobile preset changes who the tab claims to be. + */ +export function resolveBrowserTabIdentity(args: { + url: string + mobile: boolean + processIdentity: BrowserProcessUserAgentIdentity +}): BrowserTabIdentity { + // Firefox is delivered per-target and cannot reach workers; keep it clean-only to preserve one + // coherent identity per mode instead of pairing a Firefox document with native workers. + if (args.processIdentity.mode === 'clean' && isGoogleAuthUrl(args.url)) { + return googleAuthTabIdentity() + } + if (args.mobile) { + return buildMobileTabIdentity(args.processIdentity.userAgent) + } + return { kind: 'process', userAgent: args.processIdentity.userAgent } +} + +// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real +// Chrome major spliced in so the userAgentMetadata brands below agree with it. +function buildMobileUserAgent(chromeMajor: string): string { + return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` +} + +function extractChromeMajor(ua: string): string { + const match = ua.match(/Chrome\/(\d+)/) + return match ? match[1] : '134' +} + +function buildMobileTabIdentity(processUserAgent: string): BrowserTabIdentity { + const chromeMajor = extractChromeMajor(processUserAgent) + // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. + return { + kind: 'mobile', + userAgent: buildMobileUserAgent(chromeMajor), + userAgentMetadata: { + brands: [ + { brand: 'Google Chrome', version: chromeMajor }, + { brand: 'Chromium', version: chromeMajor }, + { brand: 'Not/A)Brand', version: '24' } + ], + fullVersionList: [ + { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, + { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, + { brand: 'Not/A)Brand', version: '24.0.0.0' } + ], + fullVersion: `${chromeMajor}.0.0.0`, + platform: 'iOS', + platformVersion: '17.0', + architecture: '', + model: 'iPhone', + mobile: true + } + } +} diff --git a/src/main/browser/browser-viewport-user-agent.test.ts b/src/main/browser/browser-viewport-user-agent.test.ts deleted file mode 100644 index 1d069a209ad..00000000000 --- a/src/main/browser/browser-viewport-user-agent.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { googleAuthUserAgent } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' - -const CHROME_UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' - -describe('buildViewportUserAgentOverride', () => { - it('presents the Firefox UA on Google auth hosts regardless of the preset', () => { - for (const mobile of [false, true]) { - const override = buildViewportUserAgentOverride({ - url: 'https://accounts.google.com/v3/signin/identifier', - mobile, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toBe(googleAuthUserAgent()) - // Real Firefox emits no client hints, so Chrome brands would contradict the stripped headers. - expect(override.userAgentMetadata).toBeUndefined() - } - }) - - it('keeps the clean desktop UA off the auth hosts', () => { - const override = buildViewportUserAgentOverride({ - url: 'https://myaccount.google.com/', - mobile: false, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toBe(CHROME_UA) - expect(override.userAgentMetadata).toBeUndefined() - }) - - it('splices the real Chrome major into the mobile UA and its client hints', () => { - const override = buildViewportUserAgentOverride({ - url: 'https://example.com/', - mobile: true, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toContain('iPhone') - expect(override.userAgent).toContain('CriOS/134.0.0.0') - expect(override.userAgentMetadata?.mobile).toBe(true) - expect(override.userAgentMetadata?.platform).toBe('iOS') - expect(override.userAgentMetadata?.brands).toContainEqual({ - brand: 'Google Chrome', - version: '134' - }) - }) - - it('falls back to a known Chrome major when the base UA carries none', () => { - const override = buildViewportUserAgentOverride({ - url: 'https://example.com/', - mobile: true, - baseUserAgent: googleAuthUserAgent() - }) - expect(override.userAgent).toContain('CriOS/134.0.0.0') - }) - - it('treats an unparseable URL as a non-auth host', () => { - const override = buildViewportUserAgentOverride({ - url: 'not a url', - mobile: false, - baseUserAgent: CHROME_UA - }) - expect(override.userAgent).toBe(CHROME_UA) - }) -}) diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts deleted file mode 100644 index 31c06307b46..00000000000 --- a/src/main/browser/browser-viewport-user-agent.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Why: a CDP Emulation.setUserAgentOverride outranks WebContents.setUserAgent for both -// navigator.userAgent and the outgoing request header, and it stands across every later -// navigation until explicitly cleared. So the viewport preset's UA is a third identity layer -// that must agree with the auth-host Firefox switch, or applying a preset silently reintroduces -// the exact UA mismatch this scope exists to remove. - -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' - -type UserAgentBrand = { brand: string; version: string } - -export type ViewportUserAgentOverride = { - userAgent: string - userAgentMetadata?: { - brands: UserAgentBrand[] - fullVersionList: UserAgentBrand[] - fullVersion: string - platform: string - platformVersion: string - architecture: string - model: string - mobile: boolean - } -} - -// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real -// Chrome major spliced in so the userAgentMetadata brands below agree with it. -function buildMobileUserAgent(chromeMajor: string): string { - return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` -} - -function extractChromeMajor(ua: string): string { - const match = ua.match(/Chrome\/(\d+)/) - return match ? match[1] : '134' -} - -export function buildViewportUserAgentOverride(args: { - url: string - mobile: boolean - baseUserAgent: string - googleAuthEnabled?: boolean -}): ViewportUserAgentOverride { - if (args.googleAuthEnabled !== false && isGoogleAuthUrl(args.url)) { - // Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real - // Firefox emits no client hints, so Chrome brands here would contradict the stripped headers. - return { userAgent: googleAuthUserAgent() } - } - if (!args.mobile) { - // Why: desktop presets republish the session's clean identity, or a preset would put the - // Electron/app tokens back on the wire and a transplanted session gets revoked (STA-7147). - return { userAgent: args.baseUserAgent } - } - const chromeMajor = extractChromeMajor(args.baseUserAgent) - // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. - return { - userAgent: buildMobileUserAgent(chromeMajor), - userAgentMetadata: { - brands: [ - { brand: 'Google Chrome', version: chromeMajor }, - { brand: 'Chromium', version: chromeMajor }, - { brand: 'Not/A)Brand', version: '24' } - ], - fullVersionList: [ - { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, - { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, - { brand: 'Not/A)Brand', version: '24.0.0.0' } - ], - fullVersion: `${chromeMajor}.0.0.0`, - platform: 'iOS', - platformVersion: '17.0', - architecture: '', - model: 'iPhone', - mobile: true - } - } -} diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 47d8b419d7a..2b3e9638720 100644 --- a/tests/tools/google-signin-ua-probe.cjs +++ b/tests/tools/google-signin-ua-probe.cjs @@ -241,7 +241,7 @@ app.whenReady().then(async () => { return } if (isAppMode) { - // Mirror applyGoogleAuthUserAgent: Firefox UA on auth navs, restore the + // Mirror presentTabIdentityAtNavigationStart: Firefox UA on auth navs, restore the // cleaned session UA otherwise. This WebContents UA is what leaks onto // cross-host subresource requests while the auth document is on screen. const current = window.webContents.getUserAgent() From 3976ad4c5983d83a5124a2d97762fb0a609fb6c9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:55:05 -0700 Subject: [PATCH 332/387] perf(test): remove obsolete structural snapshots (#23777) --- config/scripts/ci-shard-timings.json | 1 - src/main/git/repo-api-parity.test.ts | 37 ---- .../claude-trust-dialog-transcript.test.ts | 16 +- .../refactor-hook-contract.test-support.ts | 163 ------------------ .../task-page-source-family.test-support.ts | 43 ----- .../terminal-pane-hook-order-parity.test.ts | 125 -------------- ...-pane-quick-command-initialization.test.ts | 23 +++ 7 files changed, 36 insertions(+), 372 deletions(-) delete mode 100644 src/main/git/repo-api-parity.test.ts delete mode 100644 src/renderer/src/components/refactor-hook-contract.test-support.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-quick-command-initialization.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 1a187718dd2..664e8268662 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1721,7 +1721,6 @@ "src/main/git/remove-worktree-branch-cleanup.test.ts": 163, "src/main/git/remove-worktree-clean-preflight.test.ts": 125, "src/main/git/remove-worktree.test.ts": 159, - "src/main/git/repo-api-parity.test.ts": 194, "src/main/git/repo-branch-conflict-batched-probe.test.ts": 73, "src/main/git/repo-branch-conflict-real-git.test.ts": 351, "src/main/git/repo-branch-conflict.test.ts": 214, diff --git a/src/main/git/repo-api-parity.test.ts b/src/main/git/repo-api-parity.test.ts deleted file mode 100644 index 9143b7bf65f..00000000000 --- a/src/main/git/repo-api-parity.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as repo from './repo' - -describe('repo public API parity', () => { - it('keeps the historical runtime export surface', () => { - expect(Object.keys(repo).sort()).toEqual( - [ - 'DEFAULT_BASE_REF_PROBES', - 'buildSearchBaseRefsArgv', - 'getBaseRefDefault', - 'getBranchConflictKind', - 'getDefaultBaseRef', - 'getDefaultRemote', - 'getGitRepoRoot', - 'getLinkedWorktreeMainRepoRoot', - 'getRecentDriftSubjects', - 'getRemoteCommitUrl', - 'getRemoteCount', - 'getRemoteDrift', - 'getRemoteFileUrl', - 'getRemoteUrl', - 'getRepoName', - 'isForEachRefExcludeUnsupportedError', - 'isGitRepo', - 'mergeBaseRefSearchResultGroups', - 'normalizeGitRepoRootForInputPath', - 'normalizeRefSearchQuery', - 'parseAndFilterSearchRefDetails', - 'parseRemoteCount', - 'resolveDefaultBaseRefViaExec', - 'resolveDefaultBaseRefWithLocalGit', - 'searchBaseRefDetails', - 'searchBaseRefs' - ].sort() - ) - }) -}) diff --git a/src/main/runtime/claude-trust-dialog-transcript.test.ts b/src/main/runtime/claude-trust-dialog-transcript.test.ts index d459fc51502..5913c54b019 100644 --- a/src/main/runtime/claude-trust-dialog-transcript.test.ts +++ b/src/main/runtime/claude-trust-dialog-transcript.test.ts @@ -31,6 +31,8 @@ async function waitOnReplay( size, data: '' }) + // Pane creation awaits real timers; replay and polling use the virtual clock. + vi.useFakeTimers() const bytes = Buffer.from(data, 'utf8') const step = readSize ?? bytes.length // Why a streaming decoder: a PTY read can end inside a multi-byte character, as a real one does. @@ -42,14 +44,22 @@ async function waitOnReplay( if (options.after) { runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.after, Date.now()) } - // Why a wide budget: a blocked verdict lands on the first ~2 s poll tick; the slack absorbs load. - return runtime.waitForTerminal(handle, { + const timeoutMs = options.timeoutMs ?? 15_000 + const promise = runtime.waitForTerminal(handle, { condition: 'tui-idle', - timeoutMs: options.timeoutMs ?? 15_000 + timeoutMs }) + // Attach before advancing so the expected timeout is never an unhandled rejection. + promise.catch(() => {}) + await vi.advanceTimersByTimeAsync(timeoutMs) + return promise } describe("Claude's workspace trust dialog, from captured transcripts", () => { + afterEach(() => { + vi.useRealTimers() + }) + it('reports the dialog as a blocking prompt instead of waiting out the whole budget', async () => { await expect(waitOnReplay('claude-dialog-trust-workspace', null)).resolves.toMatchObject({ satisfied: false, diff --git a/src/renderer/src/components/refactor-hook-contract.test-support.ts b/src/renderer/src/components/refactor-hook-contract.test-support.ts deleted file mode 100644 index cb191c7c86f..00000000000 --- a/src/renderer/src/components/refactor-hook-contract.test-support.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { createHash } from 'node:crypto' -// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. -import ts from 'typescript-api' - -export type HookContractFact = { - dependencies: string[] | null - name: string -} - -type HookDefinition = { - body: ts.Node - sourceFile: ts.SourceFile -} - -export type HookSource = { - relativePath: string - source: string -} - -export type HookDefinitions = ReadonlyMap - -function readFunctionName(node: ts.Node): string | null { - if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) && node.name) { - return node.name.text - } - if ( - ts.isArrowFunction(node) && - ts.isVariableDeclaration(node.parent) && - ts.isIdentifier(node.parent.name) - ) { - return node.parent.name.text - } - return null -} - -function readHookName(node: ts.CallExpression): string | null { - if (ts.isIdentifier(node.expression) && /^use[A-Z]/.test(node.expression.text)) { - return node.expression.text - } - if ( - ts.isPropertyAccessExpression(node.expression) && - /^use[A-Z]/.test(node.expression.name.text) - ) { - return node.expression.name.text - } - return null -} - -function normalize(node: ts.Node, sourceFile: ts.SourceFile): string { - return node - .getText(sourceFile) - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\/\/.*$/gm, '') - .replace(/\s+/g, '') -} - -export function readHookDefinitions( - sources: readonly HookSource[], - includesDefinition: (name: string) => boolean -): HookDefinitions { - const definitions = new Map() - for (const { relativePath, source } of sources) { - const sourceFile = ts.createSourceFile( - relativePath, - source, - ts.ScriptTarget.Latest, - true, - relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - const visit = (node: ts.Node): void => { - if ( - (ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node)) && - node.body - ) { - const name = readFunctionName(node) - if (name && includesDefinition(name)) { - definitions.set(name, { body: node.body, sourceFile }) - } - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - } - return definitions -} - -export function readFlattenedHookFacts( - definitions: HookDefinitions, - rootName: string -): HookContractFact[] { - const flatten = (name: string, active: ReadonlySet): HookContractFact[] => { - const definition = definitions.get(name) - if (!definition || active.has(name)) { - throw new Error(`Invalid hook stage: ${name}`) - } - const facts: HookContractFact[] = [] - const nextActive = new Set([...active, name]) - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - const hookName = readHookName(node) - if (hookName) { - if (definitions.has(hookName)) { - facts.push(...flatten(hookName, nextActive)) - } else { - const dependencyArray = [...node.arguments] - .toReversed() - .find((argument) => ts.isArrayLiteralExpression(argument)) - facts.push({ - dependencies: dependencyArray - ? [...dependencyArray.elements].map((element) => - normalize(element, definition.sourceFile) - ) - : null, - name: hookName - }) - } - } - } - ts.forEachChild(node, visit) - } - visit(definition.body) - return facts - } - - return flatten(rootName, new Set()) -} - -export function hashContract(values: readonly string[]): string { - return createHash('sha256').update(values.join('\n')).digest('hex') -} - -export function readDependencyContract(facts: readonly HookContractFact[]): string[] { - return facts.flatMap(({ dependencies, name }) => - dependencies === null ? [] : [`${name}|[${dependencies.join(',')}]`] - ) -} - -export function projectDependencyContract( - facts: readonly HookContractFact[], - allowedAdditions: Readonly> -): string[] { - const dependencyFacts = facts.filter( - (fact): fact is HookContractFact & { dependencies: string[] } => fact.dependencies !== null - ) - for (const ordinal of Object.keys(allowedAdditions).map(Number)) { - if (!Number.isInteger(ordinal) || ordinal < 0 || ordinal >= dependencyFacts.length) { - throw new Error(`Invalid dependency-array ordinal: ${ordinal}`) - } - } - return dependencyFacts.map(({ dependencies, name }, ordinal) => { - const projected = [...dependencies] - for (const addition of allowedAdditions[ordinal] ?? []) { - const index = projected.indexOf(addition) - if (index === -1) { - throw new Error(`Missing allowed dependency addition at ${ordinal}: ${addition}`) - } - projected.splice(index, 1) - } - return `${name}|[${projected.join(',')}]` - }) -} diff --git a/src/renderer/src/components/task-page-source-family.test-support.ts b/src/renderer/src/components/task-page-source-family.test-support.ts index 2253f11295e..14dd9a76d2e 100644 --- a/src/renderer/src/components/task-page-source-family.test-support.ts +++ b/src/renderer/src/components/task-page-source-family.test-support.ts @@ -1,25 +1,8 @@ import { readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { - type HookContractFact, - readFlattenedHookFacts, - readHookDefinitions -} from './refactor-hook-contract.test-support' - const TASK_PAGE_FLAT_SOURCE_PATTERN = /^(?:use-task-page-.*\.ts|task-page-.*\.tsx?)$/ const TASK_PAGE_DIRECTORY = 'task-page' -const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([ - 'task-page-draft-storage.tsx', - 'task-page-github-landing-refresh-run.tsx', - 'task-page-github-quiet-refresh-run.tsx', - 'task-page-github-review-model.tsx', - 'task-page-github-reviewer-actions.ts', - 'task-page-linear-issue-model.tsx', - 'task-page-linear-jira-list-model.tsx', - 'task-page-source-context.tsx' -]) - function isSourceFile(name: string): boolean { return !name.includes('.test.') && !name.includes('.test-support.') } @@ -45,13 +28,6 @@ export const TASK_PAGE_SOURCE_FILES = [ ...readTaskPageDirectory(TASK_PAGE_DIRECTORY) ].sort() -export const TASK_PAGE_REFACTOR_SOURCE_FILES = TASK_PAGE_SOURCE_FILES.filter( - (name) => - (name.startsWith(`${TASK_PAGE_DIRECTORY}/`) && name.endsWith('.tsx')) || - /^use-task-page-.*\.ts$/.test(name) || - TASK_PAGE_EXTRACTED_LOWERCASE_FILES.has(name) -) - export function readTaskPageSource(relativePath: string): string { return readFileSync(join(__dirname, relativePath), 'utf8') } @@ -61,22 +37,3 @@ export function readTaskPageSourceFamily(): string { (relativePath) => `// TaskPage source: ${relativePath}\n${readTaskPageSource(relativePath)}` ).join('\n') } - -export function readTaskPageRefactorSourceFamily(): string { - return TASK_PAGE_REFACTOR_SOURCE_FILES.map(readTaskPageSource).join('\n') -} - -export function readFlattenedTaskPageHookFacts(): HookContractFact[] { - const definitions = readHookDefinitions( - TASK_PAGE_REFACTOR_SOURCE_FILES.map((relativePath) => ({ - relativePath, - source: readTaskPageSource(relativePath) - })), - (name) => name === 'TaskPage' || name.startsWith('useTaskPage') - ) - return readFlattenedHookFacts(definitions, 'TaskPage') -} - -export function readFlattenedTaskPageHookOrder(): string[] { - return readFlattenedTaskPageHookFacts().map(({ name }) => name) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts deleted file mode 100644 index ded0bbf5c2a..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -const TERMINAL_PANE_HOOK_SOURCE_PATTERN = - /^(?:TerminalPane\.tsx|use-terminal-pane-(?:chat-state|close-actions|context-actions|controller|foundation|global-listeners|layout-bindings|layout-persistence|lifecycle-stage|mobile-actions|paste-listeners|process-exit-actions|projection|reconciliation|startup-actions|store-actions|store-bindings|title-effects|title-state)\.ts)$/ -// Rebased onto main after the workbench surface-per-workspace and deferred -// split-cwd changes; the pane session-ID projection added one render hook (230 hooks). -// Then 27 stable-action `useAppStore` subscriptions folded into four -// `useTerminalPaneStoreActions()` calls, each one `useMemo` (204 hooks, 8 useMemo). -// Restoring the terminal/chat switcher added four `useCallback`s -- three in -// chat-state (can-toggle, toggle-for-leaf, toggle-active) and the context-menu -// toggle in projection (208 hooks, still 8 useMemo). -// Then chat-state's orchestration dispatch-status subscription went with the -// paused notice that read it (207 hooks, still 8 useMemo). -// Then host-authoritative layout removal added two `useRef`s in reconciliation -// (last host layout leaf set, retired leaf set) (209 hooks, still 8 useMemo). -// Then search match count + Cmd+F focus parity (#9035) added a `useRef` and a `useCallback` in -// foundation (search input ref, focus-search-input) (211 hooks, still 8 useMemo). -// Then the pending split-close admission added one `useRef` in close-actions -// (the confirmed-close continuation) (212 hooks, still 8 useMemo). -// Then the dead adopted-structured-session portal went with its local target `useMemo` -// in projection (211 hooks, 7 useMemo). -// Then chat ownership through toggles and restore (#23049) added a `useRef`, a `useLayoutEffect` -// and a `useEffect` across chat-state, layout-persistence and title-effects (214 hooks, still 7 useMemo). -const PRE_REFACTOR_HOOK_ORDER_SHA256 = - '3736b71c612bb28b9b5298c7704ee1b32c7afc0254e75d0072ff4df870ec3be9' - -const sourceFiles = readdirSync(__dirname) - .filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name)) - .sort() - -function readFunctionDefinitions(): Map { - const definitions = new Map() - for (const relativePath of sourceFiles) { - const filePath = join(__dirname, relativePath) - const sourceFile = ts.createSourceFile( - filePath, - readFileSync(filePath, 'utf8'), - ts.ScriptTarget.Latest, - true, - relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - const visit = (node: ts.Node): void => { - if ( - ts.isFunctionDeclaration(node) && - node.name && - (node.name.text === 'TerminalPane' || node.name.text.startsWith('useTerminalPane')) - ) { - definitions.set(node.name.text, node) - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - } - return definitions -} - -function readFlattenedHookOrder(): string[] { - const definitions = readFunctionDefinitions() - const flatten = (name: string, active: ReadonlySet): string[] => { - const definition = definitions.get(name) - if (!definition?.body) { - throw new Error(`Missing terminal pane hook stage: ${name}`) - } - if (active.has(name)) { - throw new Error(`Recursive terminal pane hook stage: ${name}`) - } - const nextActive = new Set([...active, name]) - const hooks: string[] = [] - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - /^use[A-Z]/.test(node.expression.text) - ) { - const hookName = node.expression.text - hooks.push(...(definitions.has(hookName) ? flatten(hookName, nextActive) : [hookName])) - } - ts.forEachChild(node, visit) - } - visit(definition.body) - return hooks - } - - return flatten('TerminalPane', new Set()) -} - -describe('TerminalPane refactor hook parity', () => { - it('preserves the recursively flattened render hook order', () => { - const hooks = readFlattenedHookOrder() - expect(hooks).toHaveLength(214) - expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(7) - expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe( - PRE_REFACTOR_HOOK_ORDER_SHA256 - ) - }) - - it('aggregates every extracted hook stage', () => { - expect(sourceFiles).toHaveLength(20) - expect(sourceFiles).toContain('TerminalPane.tsx') - expect(sourceFiles).toContain('use-terminal-pane-controller.ts') - }) - - it('defers quick-command host work until the context menu opens', () => { - const startupSource = readFileSync( - join(__dirname, 'use-terminal-pane-startup-actions.ts'), - 'utf8' - ) - const contextSource = readFileSync( - join(__dirname, 'use-terminal-pane-context-actions.ts'), - 'utf8' - ) - - expect(startupSource).toContain('const quickCommandRepoId =') - expect(startupSource).not.toContain('useTerminalQuickCommandHosts') - expect(contextSource).toContain('useTerminalQuickCommandHosts(worktreeId, contextMenu.open)') - expect(contextSource).toContain('terminalQuickCommandMatchesWorkspaceProject') - expect(contextSource).toContain('projectHostSetupProjection.setups') - expect(contextSource).toContain('quickCommandExecutionHostId') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-quick-command-initialization.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-quick-command-initialization.test.ts new file mode 100644 index 00000000000..b64f7583160 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-quick-command-initialization.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('TerminalPane quick-command initialization', () => { + it('defers quick-command host work until the context menu opens', () => { + const startupSource = readFileSync( + join(__dirname, 'use-terminal-pane-startup-actions.ts'), + 'utf8' + ) + const contextSource = readFileSync( + join(__dirname, 'use-terminal-pane-context-actions.ts'), + 'utf8' + ) + + expect(startupSource).toContain('const quickCommandRepoId =') + expect(startupSource).not.toContain('useTerminalQuickCommandHosts') + expect(contextSource).toContain('useTerminalQuickCommandHosts(worktreeId, contextMenu.open)') + expect(contextSource).toContain('terminalQuickCommandMatchesWorkspaceProject') + expect(contextSource).toContain('projectHostSetupProjection.setups') + expect(contextSource).toContain('quickCommandExecutionHostId') + }) +}) From 2ae7c00e847e2cfdfa44bdfffa1d343ee1ca6686 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:09:13 -0700 Subject: [PATCH 333/387] fix(opencode): keep OpenCode 2 panes Working across plugin reloads (#23700) * fix(opencode): keep OpenCode 2 panes Working across plugin reloads OpenCode 2 disposes and re-sets-up every plugin whenever its plugins dir changes, while sessions keep running. The status plugin published a final Idle on dispose, so a pane read Done mid-turn. Orca also rewrote the plugin file on every PTY spawn, so opening any terminal triggered that reload. Dispose now releases the factory's bookkeeping without publishing a verdict; the next lifecycle event settles the pane, and Orca's ended-process reconciliation still retires panes whose agent exited. The plugin file is written only when its bytes differ. * fix(opencode): skip rewriting an unchanged plugin in the SSH relay install too The relay's canonical-config install still unlinked and rewrote the status plugin on every OpenCode launch over SSH, which restarts every plugin in a remote OpenCode 2 server. Share one install-currency check (lstat + the existing byte comparison) between the local and relay writers, and pin write-if-changed with mtime so the tests also fail on filesystems that reuse a freed inode. * fix(opencode): keep the final Idle when OpenCode 1 tears its instance down OpenCode 1 disposes a plugin only when it tears the instance down, and that teardown cancels every running session, so the Idle published on dispose is true there; the cancelled run's own idle may never reach the plugin. Only OpenCode 2 disposes on a hot reload while turns keep running. The generated module serves both hosts, so the OpenCode 2 setup() entry point now tells the shared factory that sessions outlive disposal; the server() path keeps the previous disposal behaviour, including the hand-off to a surviving factory. * fix(opencode): compare a symlinked plugin by its target before rewriting OpenCode 2 loads plugins through file-level symlinks and reads the revision from the target's mtime, so a user whose Orca plugin file is a symlink (per-file dotfile managers) failed the regular-file check and got a write through the link, and a reload, on every spawn. The config-dir and relay installs now skip the write when the resolved target already has Orca's bytes; when stale they behave as before. Only the per-source overlay keeps the regular-file check, since a link there mirrors a user entry. Installers also skip the write inside a guarded block rather than returning early, so later install steps still run. * test(opencode): skip the plugin symlink tests on Windows like their neighbours Creating a file symlink on Windows needs Developer Mode or admin rights. * test(opencode): stub fetch without a type assertion in the dispose host test --- .../opencode/hook-plugin-dispose-host.test.ts | 165 ++++++++++++++++++ src/main/opencode/hook-service.test.ts | 62 ++++++- src/main/opencode/hook-service.ts | 23 ++- .../opencode/status-plugin-factory-source.ts | 16 +- .../opencode2/status-plugin-setup-source.ts | 3 +- src/relay/opencode-canonical-config.ts | 13 +- src/relay/plugin-overlay.test.ts | 47 +++++ src/shared/opencode-installed-plugin.test.ts | 47 +++++ src/shared/opencode-installed-plugin.ts | 22 +++ 9 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 src/main/opencode/hook-plugin-dispose-host.test.ts create mode 100644 src/shared/opencode-installed-plugin.test.ts create mode 100644 src/shared/opencode-installed-plugin.ts diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts new file mode 100644 index 00000000000..179714d8c83 --- /dev/null +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -0,0 +1,165 @@ +/** + * Executes the generated plugin through each OpenCode host entry point, because + * what disposal means differs by host: OpenCode 1 disposes only on instance + * teardown (which cancels every run), OpenCode 2 also on a plugin hot reload. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type PluginHooks = { + event: (input: { event: unknown }) => Promise + dispose?: () => Promise +} +type HostEvent = { type: string; data: Record } +type PluginModule = { + default?: { + server?: (ctx: unknown) => Promise + setup?: (ctx: unknown) => Promise<() => Promise> + } +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' +] as const + +// A live OpenCode 2 event bus: stays open until the subscriber aborts. +function createEventBus(): { + push: (event: HostEvent) => void + subscribe: (input: { signal: AbortSignal }) => AsyncGenerator +} { + const queue: HostEvent[] = [] + let wake: (() => void) | null = null + return { + push(event) { + queue.push(event) + wake?.() + }, + async *subscribe({ signal }) { + while (!signal.aborted) { + const next = queue.shift() + if (next) { + yield next + continue + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + wake = null + } + } + } +} + +describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', (agent) => { + let tempDir: string + let savedEnv: Record + let names: string[] + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-dispose-host-')) + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_OPENCODE_AGENT = agent + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + names = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + names.push(String(JSON.parse(String(init?.body)).payload?.hook_event_name)) + return new Response('{}', { status: 200 }) + }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPluginModule(): Promise { + const pluginPath = join(tempDir, `orca-${agent}-status.mjs`) + writeFileSync( + pluginPath, + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this shape. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + function setupContext(bus: ReturnType): unknown { + return { + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ id: sessionID }), + hook: async () => ({ dispose: async () => {} }) + }, + event: { subscribe: bus.subscribe } + } + } + + it('publishes a final Idle when the OpenCode 1 instance tears down mid-turn', async () => { + const module = await loadPluginModule() + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await hooks?.dispose?.() + + expect(names).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('keeps the pane Working when OpenCode 2 reloads the plugin mid-turn', async () => { + const module = await loadPluginModule() + const firstBus = createEventBus() + const firstCleanup = await module.default?.setup?.(setupContext(firstBus)) + firstBus.push({ type: 'session.execution.started', data: { sessionID: 'ses_root' } }) + await vi.waitFor(() => expect(names).toEqual(['SessionBusy'])) + + await firstCleanup?.() + expect(names).toEqual(['SessionBusy']) + + // The turn kept running; its end reaches the reloaded plugin. + const secondBus = createEventBus() + const secondCleanup = await module.default?.setup?.(setupContext(secondBus)) + secondBus.push({ type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } }) + await vi.waitFor(() => expect(names).toEqual(['SessionBusy', 'SessionIdle'])) + await secondCleanup?.() + expect(names).toEqual(['SessionBusy', 'SessionIdle']) + }) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 66fb3c86a47..e99550c59ea 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -97,11 +97,11 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - 'c81893814f08bfb2f9b5c133c5355b53d485982402d89e92ff15fc4e7d543079' + '655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('2267e2ab6e854e71c9bae12afed97e464f93b2b14f3e25dca133ca6666c98752') + ).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e') }) it('filters child sessions via parentID lookup before forwarding events', () => { @@ -294,6 +294,51 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(pluginSource).toContain('messageID: part.messageID') }) + // Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn. + it('leaves a current installed plugin untouched and replaces a stale one', () => { + const service = new OpenCodeHookService() + service.buildPtyEnv(daemonSessionId) + const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js') + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + service.buildPtyEnv(daemonSessionId) + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + + writeFileSync(pluginPath, 'stale plugin') + service.buildPtyEnv(daemonSessionId) + expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + }) + + // Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target. + it.skipIf(process.platform === 'win32')( + 'compares a symlinked plugin by its target and writes through only when stale', + () => { + const service = new OpenCodeHookService() + const pluginPath = join( + resolveOpenCodeConfigDirectory(), + 'plugins', + 'orca-opencode-status.js' + ) + const targetPath = join(userDataDir, 'dotfiles-orca-opencode-status.js') + writeFileSync(targetPath, _internals.getOpenCodePluginSource()) + rmSync(pluginPath, { force: true }) + symlinkSync(targetPath, pluginPath) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(targetPath, past, past) + + service.buildPtyEnv(daemonSessionId) + expect(statSync(targetPath).mtimeMs).toBe(past.getTime()) + + writeFileSync(targetPath, 'stale plugin') + service.buildPtyEnv(daemonSessionId) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) + expect(readFileSync(targetPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + rmSync(pluginPath, { force: true }) + rmSync(targetPath, { force: true }) + } + ) + // Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader // rejects a default export that only has server(). Asserting the emitted *source* is // not enough; the installed file is what the v2 server validates, so load it. @@ -700,6 +745,19 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) + it('leaves a current overlay plugin file in place across spawns', () => { + const service = new OpenCodeHookService() + const overlayDir = service.buildPtyEnv(ptyId, userConfigDir).OPENCODE_CONFIG_DIR! + const pluginPath = join(overlayDir, 'plugins', 'orca-opencode-status.js') + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + service.buildPtyEnv(ptyId, userConfigDir) + + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + }) + it('reconciles stale mirrored entries while preserving OpenCode runtime files', () => { const service = new OpenCodeHookService() const firstEnv = service.buildPtyEnv(ptyId, userConfigDir) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index e7ec0cc8b2f..74f24577c6e 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -28,6 +28,10 @@ import { OPENCODE2_LEGACY_HOOKS_DIR, OPENCODE_LEGACY_HOOKS_DIR } from './legacy-shared-config-dir' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' @@ -278,18 +282,25 @@ export class OpenCodeHookService { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) - try { - unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + const source = this.pluginSource() + if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { + try { + unlinkSync(pluginPath) + } catch { + // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + } + writeFileSync(pluginPath, source) } - writeFileSync(pluginPath, this.pluginSource()) } private writePluginToConfigDir(configDir: string): void { const pluginsDir = join(configDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) - writeFileSync(join(pluginsDir, this.pluginFileName), this.pluginSource()) + const pluginPath = join(pluginsDir, this.pluginFileName) + const source = this.pluginSource() + if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { + writeFileSync(pluginPath, source) + } } } diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 8348cc968eb..218d15c3e41 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -23,6 +23,7 @@ export function getStatusPluginFactorySource(options: { 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', ` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`, ' const client = _ctx?.client;', + ' const sessionsOutliveDispose = _ctx?.sessionsOutliveDispose === true;', ' const factoryID = ++nextFactoryID;', ' activeFactoryIDs.add(factoryID);', ' let disposed = false;', @@ -242,7 +243,14 @@ export function getStatusPluginFactorySource(options: { ' pendingAssistantPart = null;', ' }', ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', - ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', + ' // Why: OpenCode 1 disposes only on instance teardown, which cancels every run, so a final', + ' // Idle is true. OpenCode 2 also disposes on a hot reload mid-turn, so it publishes nothing.', + ' if (sessionsOutliveDispose) {', + ' if (desiredFactoryID === factoryID) {', + ' clearStatusRetry();', + ' statusRevision += 1;', + ' }', + ' } else if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', ' clearStatusRetry();', ' statusRevision += 1;', ' // A MessagePart may have changed the listener to Working after the', @@ -250,17 +258,13 @@ export function getStatusPluginFactorySource(options: { ' statusDeliveryDirty = ownsDeliveredMessagePart;', ' busyRecoveryUsed = false;', ' busyRecoveryEndpointKey = "";', - ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', - ' (id) => id !== factoryID', - ' );', + ' const fallbackFactoryID = Array.from(activeFactoryIDs).find((id) => id !== factoryID);', ' if (fallbackFactoryID !== undefined) {', ' await publishAggregateStatus(', ' fallbackFactoryID,', ' desiredStatusProperties?.sessionID', ' );', ' } else {', - ' // Why: Instance disposal can happen while the PTY stays alive;', - ' // publish a final idle so Orca does not retain a dead owner.', ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', ' await setStatus(', ' "idle",', diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index c68c15cbda2..7b825056ecd 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -24,7 +24,8 @@ async function setupOpenCode2Status(ctx) { // Without it, resolveRootSessionID returns null for every session and a // subagent's work publishes as if it were the root's. const client = { session: { get: async (input, options) => { const result = await ctx.session.get(input, options); return result && typeof result.id === "string" ? { data: result } : result; } } }; - hooks = await OrcaOpenCodeStatusPlugin({ client }); + // Why: this host disposes plugins on a hot reload while turns keep running. + hooks = await OrcaOpenCodeStatusPlugin({ client, sessionsOutliveDispose: true }); if (!hooks || typeof hooks.event !== "function") return noop; const promptRegistration = await ctx.session.hook("prompt", async (properties) => { await hooks.event({ event: { type: "session.next.prompt.admitted", properties } }); diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 4bcb9f3e151..38682d3f274 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,6 +1,7 @@ import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' +import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' const RELAY_HOOKS_DIR = '.orca-relay' @@ -18,12 +19,14 @@ export function installOpenCodePluginInCanonicalConfig( agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js' const pluginPath = join(configDir, 'plugins', pluginFileName) mkdirSync(join(configDir, 'plugins'), { recursive: true }) - try { - unlinkSync(pluginPath) - } catch { - // The file may not exist on the first install. + if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { + try { + unlinkSync(pluginPath) + } catch { + // The file may not exist on the first install. + } + writeFileSync(pluginPath, source) } - writeFileSync(pluginPath, source) return true } catch (err) { process.stderr.write( diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 14feb631876..d4d38a31fba 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -1,12 +1,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -74,6 +77,50 @@ describe('PluginOverlayManager', () => { ).toBe('v2 plugin') }) + // Why: OpenCode 2 reloads a plugin whose file mtime changed, even with unchanged bytes. + it('leaves a current canonical plugin untouched and replaces a stale one', () => { + const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') } + const pluginPath = join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode2-status.js') + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + manager.installOpenCodePlugin('opencode2', env) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + + manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') + }) + + // Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target. + it.skipIf(process.platform === 'win32')( + 'leaves a symlinked canonical plugin with current bytes untouched', + () => { + const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') } + const pluginsDir = join(homeDir, 'xdg', 'opencode', 'plugins') + const pluginPath = join(pluginsDir, 'orca-opencode2-status.js') + const targetPath = join(homeDir, 'dotfiles-orca-opencode2-status.js') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(targetPath, 'v2 plugin') + symlinkSync(targetPath, pluginPath) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(targetPath, past, past) + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) + expect(statSync(targetPath).mtimeMs).toBe(past.getTime()) + + manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(lstatSync(pluginPath).isFile()).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') + expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin') + } + ) + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { const userConfigDir = join(homeDir, 'company-opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) diff --git a/src/shared/opencode-installed-plugin.test.ts b/src/shared/opencode-installed-plugin.test.ts new file mode 100644 index 00000000000..d7814c1229b --- /dev/null +++ b/src/shared/opencode-installed-plugin.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from './opencode-installed-plugin' + +describe('installed OpenCode plugin currency', () => { + let dir: string + let pluginPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-installed-plugin-')) + pluginPath = join(dir, 'orca-opencode-status.js') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('treats a missing or different file as stale', () => { + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + writeFileSync(pluginPath, 'older plugin') + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + }) + + it('treats a regular file with the same bytes as current', () => { + writeFileSync(pluginPath, 'plugin') + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true) + }) + + it.skipIf(process.platform === 'win32')( + 'follows a symlink except in an overlay, where a link is a mirrored user entry', + () => { + const targetPath = join(dir, 'target.js') + writeFileSync(targetPath, 'plugin') + symlinkSync(targetPath, pluginPath) + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + } + ) +}) diff --git a/src/shared/opencode-installed-plugin.ts b/src/shared/opencode-installed-plugin.ts new file mode 100644 index 00000000000..33a80f4d168 --- /dev/null +++ b/src/shared/opencode-installed-plugin.ts @@ -0,0 +1,22 @@ +import { lstatSync } from 'node:fs' +import { nodeFileContentsEqualSync } from './node-file-content-equality' + +// Why: OpenCode 2 reloads a plugin (and every plugin loaded after it) when its file's mtime changes, +// even with identical bytes, so installers must skip the write when Orca's plugin is already current. +// Follows symlinks: OpenCode 2 loads through them, so a linked file with Orca's bytes is current. +export function isInstalledOpenCodePluginCurrent(pluginPath: string, source: string): boolean { + try { + return nodeFileContentsEqualSync(pluginPath, source) + } catch { + return false + } +} + +// Overlay variant: a symlink there mirrors a user entry, never Orca's file, even when the bytes match. +export function isOverlayOpenCodePluginCurrent(pluginPath: string, source: string): boolean { + try { + return lstatSync(pluginPath).isFile() && nodeFileContentsEqualSync(pluginPath, source) + } catch { + return false + } +} From e899809ff88a29af6b00a9dff38d54f1df1f120f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:15:39 -0700 Subject: [PATCH 334/387] fix(mobile): unsubscribe session tabs by request on the direct connection (#22943) * fix(mobile): unsubscribe session tabs by request on the direct connection * fix(mobile): hold a direct session tabs unsubscribe until the first snapshot The desktop registers a tab-list stream only as it emits the first snapshot. A direct unsubscribe sent before that found nothing, and with per-request addressing no later worktree-wide sweep collects the late stream, so it kept its desktop listener until the socket closed. Hold the unsubscribe until the snapshot arrives, as the relay connection does. * test(mobile): cover a held session tabs unsubscribe whose subscribe fails * fix(mobile): keep the session tabs hold within the registry line budget after merging main Move the pre-snapshot hold into the session tabs stream module, note that only older hosts need it, and give the unsubscribe test the real registration version now that a worktree-wide unsubscribe spares later streams. --- .../rpc-client-session-tabs-stream.ts | 34 +++++++ .../rpc-client-stream-registry.test.ts | 98 +++++++++++++++++++ .../transport/rpc-client-stream-registry.ts | 9 ++ mobile/src/transport/rpc-client.test.ts | 17 +++- .../rpc-subscription-result-shapes.ts | 4 + .../methods/session-tabs-unsubscribe.test.ts | 55 ++++++++++- 6 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 mobile/src/transport/rpc-client-session-tabs-stream.ts diff --git a/mobile/src/transport/rpc-client-session-tabs-stream.ts b/mobile/src/transport/rpc-client-session-tabs-stream.ts new file mode 100644 index 00000000000..f98a7586921 --- /dev/null +++ b/mobile/src/transport/rpc-client-session-tabs-stream.ts @@ -0,0 +1,34 @@ +import { isSnapshotResult } from './rpc-subscription-result-shapes' + +type SessionTabsStreamState = { + method: string + sent?: boolean + receivedSnapshot?: boolean + cancelled?: boolean +} + +/** Older hosts register a tabs stream only as they emit the first snapshot, so an earlier unsubscribe + * finds nothing there. Newer hosts register on arrival and still send that snapshot, so the hold only delays. */ +function awaitsRegistration(stream: SessionTabsStreamState): boolean { + return ( + stream.method === 'session.tabs.subscribe' && stream.sent === true && !stream.receivedSnapshot + ) +} + +/** Cancels a stream still awaiting its first snapshot; that snapshot then triggers the unsubscribe. */ +export function holdUnsubscribe(stream: SessionTabsStreamState): boolean { + if (!awaitsRegistration(stream)) { + return false + } + stream.cancelled = true + return true +} + +/** Returns true when `result` is the tabs stream's snapshot, i.e. the host has registered it. */ +export function recordSnapshot(stream: SessionTabsStreamState, result: unknown): boolean { + if (stream.method !== 'session.tabs.subscribe' || !isSnapshotResult(result)) { + return false + } + stream.receivedSnapshot = true + return true +} diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts index 6afcd8a7ef3..ce210333618 100644 --- a/mobile/src/transport/rpc-client-stream-registry.test.ts +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -79,6 +79,104 @@ describe('RpcClientStreamRegistry', () => { ]) }) + it('unsubscribes a session tabs stream by its own request id', () => { + const { registry, sent } = createRegistry() + const disposeOlder = registry.subscribe( + 'session.tabs.subscribe', + { worktree: 'wt-1' }, + () => {} + ) + registry.subscribe('session.tabs.subscribe', { worktree: 'wt-1' }, () => {}) + const [older, newer] = sent + registry.handleResponse(streamingResponse(older!.id, { type: 'snapshot', tabs: [] })) + + disposeOlder() + + // Without the request id the host sweeps every stream for the worktree, including the newer one. + expect(sent[2]).toMatchObject({ + method: 'session.tabs.unsubscribe', + params: { worktree: 'wt-1', subscriptionId: older!.id } + }) + expect(newer!.id).not.toBe(older!.id) + expect(sent).toHaveLength(3) + }) + + it('holds a session tabs unsubscribe until the host registers the stream', () => { + const { registry, sent } = createRegistry() + const events: unknown[] = [] + const dispose = registry.subscribe('session.tabs.subscribe', { worktree: 'wt-1' }, (event) => + events.push(event) + ) + const subscribe = sent[0]! + + dispose() + // Older hosts register only as they emit the first snapshot, so an earlier unsubscribe finds nothing. + expect(sent).toHaveLength(1) + + registry.handleResponse(streamingResponse(subscribe.id, { type: 'snapshot', tabs: [] })) + + expect(sent[1]).toMatchObject({ + method: 'session.tabs.unsubscribe', + params: { worktree: 'wt-1', subscriptionId: subscribe.id } + }) + // A host that registered on arrival ends the stream; that end must not unsubscribe again. + registry.handleResponse(streamingResponse(subscribe.id, { type: 'end' })) + expect(sent).toHaveLength(2) + expect(events).toEqual([]) + expect(registry.size()).toBe(0) + }) + + it('holds a session tabs unsubscribe again after a reconnect replays the stream', () => { + const { registry, sent } = createRegistry() + const dispose = registry.subscribe('session.tabs.subscribe', { worktree: 'wt-1' }, () => {}) + const subscribe = sent[0]! + registry.handleResponse(streamingResponse(subscribe.id, { type: 'snapshot', tabs: [] })) + + registry.markForReplay() + registry.replayAfterAuthentication() + dispose() + + expect(sent.map((request) => request.method)).toEqual([ + 'session.tabs.subscribe', + 'session.tabs.subscribe' + ]) + registry.handleResponse(streamingResponse(subscribe.id, { type: 'snapshot', tabs: [] })) + expect(sent[2]).toMatchObject({ + method: 'session.tabs.unsubscribe', + params: { worktree: 'wt-1', subscriptionId: subscribe.id } + }) + }) + + it('drops a held session tabs unsubscribe when the subscribe fails or reconnects first', () => { + const { registry, sent } = createRegistry() + const events: unknown[] = [] + const disposeFailed = registry.subscribe( + 'session.tabs.subscribe', + { worktree: 'wt-1' }, + (event) => events.push(event) + ) + const disposeReplayed = registry.subscribe( + 'session.tabs.subscribe', + { worktree: 'wt-2' }, + () => {} + ) + const failed = sent[0]! + disposeFailed() + disposeReplayed() + + registry.handleResponse({ + id: failed.id, + ok: false, + error: { code: 'worktree_not_found', message: 'Worktree not found' } + }) + registry.markForReplay() + registry.replayAfterAuthentication() + + expect(sent).toHaveLength(2) + expect(events).toEqual([]) + expect(registry.size()).toBe(0) + }) + it('ends one transcript stream on dispose and leaves a sibling on the same socket (U-03)', () => { const { registry, sent } = createRegistry() const disposeFirst = registry.subscribe('agentSession.subscribe', { sessionId: 's1' }, () => {}) diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts index ebfdb741997..4062e80b49b 100644 --- a/mobile/src/transport/rpc-client-stream-registry.ts +++ b/mobile/src/transport/rpc-client-stream-registry.ts @@ -14,6 +14,7 @@ import { isTerminalSubscribedResult } from './rpc-subscription-result-shapes' import { RpcClientTerminalStreamRouter } from './rpc-client-terminal-stream-router' +import * as sessionTabsStream from './rpc-client-session-tabs-stream' import type { ConnectionState, RpcResponse, RpcSuccess } from './types' export type RpcStreamingListener = (result: unknown) => void @@ -30,6 +31,7 @@ type StreamRequest = { subscriptionId?: string cancelled?: boolean sent?: boolean + receivedSnapshot?: boolean } type StreamRegistryOptions = { @@ -108,6 +110,7 @@ export class RpcClientStreamRegistry { this.pendingBrowserRequestId = null for (const [id, stream] of this.streams) { stream.sent = false + stream.receivedSnapshot = false // The id named a registration on the closed socket; the replay's ready brings the new one. stream.subscriptionId = undefined this.resetTerminalRouting(id) @@ -169,6 +172,10 @@ export class RpcClientStreamRegistry { return } const result = response.result + if (sessionTabsStream.recordSnapshot(stream, result) && stream.cancelled) { + this.dispose(response.id) + return + } if (isStreamingSubscriptionReadyResult(result)) { stream.subscriptionId = result.subscriptionId if (stream.cancelled) { @@ -215,6 +222,8 @@ export class RpcClientStreamRegistry { // Why: `requestId` names this exact request; hosts that predate it strip it and use the slot. this.sendRpc('terminal.unsubscribe', { ...params, requestId: id }) } + } else if (stream && sessionTabsStream.holdUnsubscribe(stream)) { + return } else { const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params, id) if (unsubscribe) { diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index e88e6c220e6..ff290f72b84 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -196,12 +196,21 @@ describe('mobile rpc-client connection timeout', () => { { worktree: 'id:wt-1' }, () => {} ) + const request = sentRequest(socket, 'session.tabs.subscribe') + socket.receive( + `encrypted:${JSON.stringify({ + id: request.id, + ok: true, + streaming: true, + result: { type: 'snapshot', worktree: 'id:wt-1', tabs: [] }, + _meta: { runtimeId: 'r1' } + })}` + ) unsubscribe() - expect( - socket.sent.some((payload) => payload.includes('"method":"session.tabs.unsubscribe"')) - ).toBe(true) - expect(socket.sent.some((payload) => payload.includes('"worktree":"id:wt-1"'))).toBe(true) + expect(sentRequests(socket, 'session.tabs.unsubscribe')).toEqual([ + expect.objectContaining({ params: { worktree: 'id:wt-1', subscriptionId: request.id } }) + ]) client.close() }) diff --git a/mobile/src/transport/rpc-subscription-result-shapes.ts b/mobile/src/transport/rpc-subscription-result-shapes.ts index cf09acdb6ca..4c624658126 100644 --- a/mobile/src/transport/rpc-subscription-result-shapes.ts +++ b/mobile/src/transport/rpc-subscription-result-shapes.ts @@ -19,3 +19,7 @@ export function isStreamingSubscriptionReadyResult( typeof (value as { subscriptionId?: unknown }).subscriptionId === 'string' ) } + +export function isSnapshotResult(value: unknown): value is { type: 'snapshot' } { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'snapshot' +} diff --git a/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts b/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts index 8490181d0ea..021224bd649 100644 --- a/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' +import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry' import { RpcDispatcher } from '../dispatcher' import { SESSION_TAB_METHODS } from './session-tabs' @@ -61,11 +62,61 @@ describe('session tab unsubscribe RPC methods', () => { expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:*:sub-all-1') expect(cleanupSubscriptionsByPrefix).not.toHaveBeenCalled() }) + + it('ends only the named stream when a newer one watches the same worktree', async () => { + const { dispatcher, ends } = await subscribeTwiceToOneWorktree() + + await dispatcher.dispatchStreaming( + request('session.tabs.unsubscribe', { worktree: 'id:wt-1', subscriptionId: 'sub-old' }), + vi.fn(), + { connectionId: 'conn-1' } + ) + + await vi.waitFor(() => expect(ends['sub-old']).toBe(1)) + expect(ends['sub-new']).toBe(0) + }) + + it('ends every stream for the worktree when the unsubscribe names no request', async () => { + const { dispatcher, ends } = await subscribeTwiceToOneWorktree() + + await dispatcher.dispatchStreaming( + request('session.tabs.unsubscribe', { worktree: 'id:wt-1' }), + vi.fn(), + { connectionId: 'conn-1' } + ) + + await vi.waitFor(() => expect(ends).toEqual({ 'sub-old': 1, 'sub-new': 1 })) + }) }) +async function subscribeTwiceToOneWorktree() { + const registry = new RuntimeSubscriptionRegistry() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Partial runtime backed by the real subscription registry; it supplies every member session.tabs subscribe/unsubscribe call. + const runtime = { + ...runtimeWithCleanup(registry.cleanup.bind(registry), registry.cleanupByPrefix.bind(registry)), + registerSubscriptionCleanup: registry.register.bind(registry), + getSubscriptionRegistrationVersion: registry.getRegistrationVersion.bind(registry), + onMobileSessionTabsChanged: () => () => {} + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const ends: Record = { 'sub-old': 0, 'sub-new': 0 } + for (const id of ['sub-old', 'sub-new']) { + await dispatcher.dispatchStreaming( + { ...request('session.tabs.subscribe', { worktree: 'id:wt-1' }), id }, + (message) => { + if (JSON.parse(message).result?.type === 'end') { + ends[id]! += 1 + } + }, + { connectionId: 'conn-1' } + ) + } + return { dispatcher, ends } +} + function runtimeWithCleanup( - cleanupSubscription: ReturnType, - cleanupSubscriptionsByPrefix = vi.fn() + cleanupSubscription: (id: string) => void, + cleanupSubscriptionsByPrefix: (prefix: string, throughVersion?: number) => void = vi.fn() ): OrcaRuntimeService { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the unsubscribe methods reach only these members; a missing one throws and fails the test. return { From c5fc0c6f269096a5cc205119b30fa8e345ada027 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:17:07 -0700 Subject: [PATCH 335/387] fix(ci): keep a squash-merged RPC recording pin reachable through its pull request (#23720) * fix(ci): keep a squash-merged RPC recording pin reachable through its pull request Main's "RPC recording pin" check has been red since #22762: that branch pinned the recording corpus to its own commit 03995ae, and the squash-merge left that commit out of main's history. Every behaviour-change squash did the same, and each needed a hand-made repin PR to clear it (#23565, #23535, #23046 and more). The guard now accepts a pin that is either in this history or in the head of the pull request whose squash wrote it into the manifest. It finds that pull request from the `(#n)` subject of the commit that added the pin and fetches `refs/pull//head`, which GitHub keeps after the branch is deleted. The reproduce step uses the same lookup, so it can still check the pinned tree out. * fix(ci): give the recording pin lookup room to walk a blobless clone In CI's blobless clone, `git log -S` fetches the manifest's blobs one commit at a time, a few seconds each. Under the 30 s process default the walk was killed after a handful of manifest commits, which main's history already exceeds (up to 7 manifest commits between a pin landing and the next pin change), and the guard then failed with an empty "Could not find the commit that pinned" error. The lookup and the pull request fetch now carry explicit budgets and say when they timed out. The not-an-ancestor instruction now names the pull request whose head was checked, or says the commit that pinned it names none. Adds the two merge-preview shapes the guard runs on: a branch opened after a squash resolves main's pin through the squash's pull request, and a branch whose rebase dropped its own pinned commit fails on its pull request instead of on main. * fix(mobile): tell a missing recording pin apart from product drift After a squash the pinned commit can live only in its pull request's head, so a clone that never fetched it makes `git diff --quiet ` exit 128. The recorder reported that as "Product sources or lockfile differ from the pinned main baseline", which sends the developer to repin a tree that may match. It now prints git's error and the command that fetches the pin. * fix(ci): ask GitHub which pull request holds a squash-dropped recording pin The recording pin guard found the pull request that keeps a squash-dropped pin by walking main's first-parent history for the commit that wrote the pin into the manifest and reading "(#n)" off its subject. A merger who edits the squash title loses the number, and the push to main turns red anyway. That already happened on main: of the 22 squashes that left a pin outside main's history, #21674's title had no "(#n)". The guard now asks GitHub for the pull requests associated with the pinned commit (GET /repos/{owner}/{repo}/commits/{sha}/pulls) and, for each in turn, fetches refs/pull//head and accepts only when git proves the pin is an ancestor of that head. GitHub only nominates candidates, so a wrong answer can fail the guard but never pass it. The endpoint named the right pull request for all 22 historical cases, #21674 included, and names none for commits a force-push orphaned. This removes the pickaxe walk, its 600 s budget and its lazy blob fetches in a blobless clone, the first-parent subtlety, and the subject regex. A revert that restores an older pull-request-only pin now resolves too, because the lookup is by the pin itself rather than by the commit that last wrote it. CI passes the job token to both guard steps and grants the job pull-requests: read. Local runs work without a token on this public repo and send GITHUB_TOKEN or GH_TOKEN when set. A failed lookup throws with the HTTP status, and names the rate limit when an unauthenticated call is refused. --- .github/workflows/mobile.yml | 22 +- .../mobile-recording-pin-checkout.test.mjs | 9 +- mobile/scripts/rpc-recording-pin-guard.mts | 111 ++------- .../scripts/rpc-recording-pin-guard.test.ts | 233 ++++++++++++++++-- .../rpc-recording-pin-reachability.mts | 204 +++++++++++++++ mobile/scripts/rpc-recording.mts | 10 +- .../src/test-support/rpc-recording/README.md | 12 +- 7 files changed, 483 insertions(+), 118 deletions(-) create mode 100644 mobile/scripts/rpc-recording-pin-reachability.mts diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 7ec322b0203..bc9f8241f94 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -41,9 +41,8 @@ on: - 'config/scripts/mobile-test-change-scope*' - 'config/scripts/pr-code-change-scope.mjs' - 'config/scripts/mobile-recording-pin-checkout.test.mjs' - # Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that - # commit only stops being reachable when the branch squash-merges. The pull_request run cannot - # see that; this one is where the pin guard finds it. + # Why main too: a squash is where a spliced corpus lands, and where a behaviour-change branch's + # own pinned commit leaves main's history for its pull request's head ref, which the guard follows. push: branches: - main @@ -182,6 +181,10 @@ jobs: name: RPC recording pin # Why ARM: pure Node plus git; the golden comparison masks `platform`. runs-on: ubuntu-24.04-arm + # Why pull-requests: the guard asks GitHub which pull requests hold a pin main's history lacks. + permissions: + contents: read + pull-requests: read defaults: run: @@ -191,7 +194,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # The ancestry verdict is read straight off history. On a shallow checkout + # The reachability verdict is read straight off history. On a shallow checkout # `git merge-base --is-ancestor` answers from grafted parents, so the guard refuses to # answer at all rather than reporting a pass it has no evidence for -- and the pinned tree # below has to be checkable out. @@ -211,12 +214,14 @@ jobs: # Seconds. No `--ref`, so the pin is judged against the same tree it was read out of. On a # pull request that is the merge preview, which already carries main's repins; judging the # branch head instead fails every branch cut before the day's repin, and its instruction would - # tell the author to pin their own head -- creating the break this guard exists to catch. A - # branch that pins its own commit passes here and fails on the push after the squash, which is - # where the pin actually leaves the history. + # tell the author to pin their own head. A branch that pins its own commit still passes on the + # push after the squash: the guard asks GitHub which pull requests hold the pin and fetches + # their `refs/pull//head`, which GitHub keeps for good. The token lifts the API rate limit. - name: Check the recording pin is reachable shell: bash - run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts ancestry + env: + GITHUB_TOKEN: ${{ github.token }} + run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable # ~2 min locally for the record itself, so it is gated rather than run twice over. A pull # request that moves none of the corpus, the manifest or the recorder cannot move this @@ -226,6 +231,7 @@ jobs: - name: Reproduce the corpus from the pinned tree shell: bash env: + GITHUB_TOKEN: ${{ github.token }} PIN_GUARD_BASE: ${{ github.event.pull_request.base.sha }} run: | if [ -n "$PIN_GUARD_BASE" ]; then diff --git a/config/scripts/mobile-recording-pin-checkout.test.mjs b/config/scripts/mobile-recording-pin-checkout.test.mjs index f45db12bdbe..7ff6251afd6 100644 --- a/config/scripts/mobile-recording-pin-checkout.test.mjs +++ b/config/scripts/mobile-recording-pin-checkout.test.mjs @@ -19,12 +19,15 @@ it('keeps full ancestry and credentials for lazy pinned-tree reads', () => { expect(job.if).toBeUndefined() expect(workflow.on.push.branches).toEqual(['main']) expect(workflow.concurrency.group).toContain('github.sha') - expect(job.steps.find((step) => step.name === 'Check the recording pin is reachable').run).toBe( - 'pnpm exec tsx scripts/rpc-recording-pin-guard.mts ancestry' - ) + const reachable = job.steps.find((step) => step.name === 'Check the recording pin is reachable') + expect(reachable.run).toBe('pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable') const reproduce = job.steps.find( (step) => step.name === 'Reproduce the corpus from the pinned tree' ) + // Both steps ask GitHub which pull requests hold a pin main's history lacks. + expect(job.permissions).toEqual({ contents: 'read', 'pull-requests': 'read' }) + expect(reachable.env.GITHUB_TOKEN).toContain('github.token') + expect(reproduce.env.GITHUB_TOKEN).toContain('github.token') expect(reproduce.run).toContain('reproduce --if-changed-since "$PIN_GUARD_BASE"') expect(reproduce.run).toContain( 'else\n pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce\nfi' diff --git a/mobile/scripts/rpc-recording-pin-guard.mts b/mobile/scripts/rpc-recording-pin-guard.mts index c7b11a48495..807893dcb28 100644 --- a/mobile/scripts/rpc-recording-pin-guard.mts +++ b/mobile/scripts/rpc-recording-pin-guard.mts @@ -1,10 +1,12 @@ /** * Guards the two claims `mobile/rpc-foundation/goldens` makes about its pin. * - * `ancestry` — `baseline` names a commit in this history. A behaviour-change branch pins its own - * last fenced commit; that commit stops being reachable the moment the branch - * squash-merges, and nobody can run `--record` on main again until a hand-made repin - * lands. Ordinary product drift past a reachable pin is normal and is not a failure. + * `reachable` — `baseline` names a commit this repository keeps: one in this history, or one in + * the head of a pull request GitHub associates with that commit. A behaviour-change + * branch pins its own last fenced commit, which the squash leaves out of main's + * history; `refs/pull//head` outlives the branch, so the pin stays checkable out. + * GitHub only names the candidates; git's ancestry check decides. Ordinary product + * drift past a kept pin is normal and is not a failure. * `reproduce` — the goldens on disk are what the recorder produces from the PINNED tree. The * recording suites replay the corpus against the CURRENT tree on every run, which is * the same claim only while the fenced tree still matches the pin. @@ -17,6 +19,7 @@ import { join, resolve } from 'node:path' import { runProcess } from '../../src/shared/child-process/run-process.ts' import { RECORDING_DRIVERS } from '../src/test-support/rpc-recording/recording-drivers.ts' import { readScenarios } from '../src/test-support/rpc-recording/scenario-input.ts' +import { checkPinReachable, PIN_MANIFEST } from './rpc-recording-pin-reachability.mts' /** The recorder is exempt from the fence, so a reproduction lays the candidate copy over the pin. */ const RECORDER_OVERLAY = 'mobile/src/test-support/rpc-recording' @@ -67,90 +70,11 @@ export function assertReproductionSuitesExist(root: string): void { } } -export type PinAncestryFailure = 'shallow' | 'unreachable' | 'not-an-ancestor' -export type PinAncestryVerdict = - | { ok: true; baseline: string; ref: string } - | { ok: false; baseline: string; ref: string; failure: PinAncestryFailure; message: string } - async function git(cwd: string, args: readonly string[]) { return await runProcess({ program: 'git', args: [...args], cwd }) } function readPinnedBaseline(root: string): string { - return readScenarios(resolve(root, 'mobile/rpc-foundation/pilot-scenarios.json')).baseline -} -export function repinInstruction(baseline: string, ref: string, cause: string): string { - return [ - `The RPC recording corpus is pinned to a commit that ${cause}.`, - '', - ` baseline ${baseline} (mobile/rpc-foundation/pilot-scenarios.json)`, - ` head ${ref}`, - '', - 'Every golden under mobile/rpc-foundation/goldens claims it was recorded from that tree, and', - '`--record` refuses on any other tree, so the corpus cannot be refreshed until the pin names a', - 'commit that is reachable from here. Repin and re-record, both in one commit:', - '', - ` git switch -c repin-rpc-recording ${ref}`, - ` # set "baseline" in mobile/rpc-foundation/pilot-scenarios.json to ${ref}`, - ' ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 \\', - ' pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record', - '', - 'Re-record everything: the repin rewrites the `baseline` header of every golden, so a partial', - 'refresh leaves the corpus pinned to two different trees. See', - 'mobile/src/test-support/rpc-recording/README.md, "Recording a behaviour change".' - ].join('\n') -} -const SHALLOW_MESSAGE = [ - 'Cannot judge the recording pin: this is a shallow clone.', - '', - '`git merge-base --is-ancestor` answers from grafted history, so it would report a verdict this', - 'guard has no evidence for. Check out with `fetch-depth: 0`.' -].join('\n') - -export async function checkPinAncestry( - root: string, - baseline: string, - ref: string -): Promise { - const shallow = await git(root, ['rev-parse', '--is-shallow-repository']) - if (shallow.code !== 0) { - throw new Error(`Could not ask git whether the clone is shallow: ${shallow.stderr.trim()}`) - } - if (shallow.stdout.trim() !== 'false') { - return { ok: false, baseline, ref, failure: 'shallow', message: SHALLOW_MESSAGE } - } - const head = await git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) - if (head.code !== 0) { - throw new Error(`Cannot resolve ${ref} to a commit in this repository`) - } - // Resolved, because the instruction below is a command to paste: `HEAD` in it moves with whatever - // the reader has checked out by the time they read the log. - const resolved = head.stdout.trim() - const pinned = await git(root, ['rev-parse', '--verify', '--quiet', `${baseline}^{commit}`]) - if (pinned.code !== 0) { - return { - ok: false, - baseline, - ref, - failure: 'unreachable', - message: repinInstruction(baseline, resolved, 'is not a commit in this repository at all') - } - } - const ancestor = await git(root, ['merge-base', '--is-ancestor', baseline, ref]) - if (ancestor.code === 0) { - return { ok: true, baseline, ref } - } - // Why only 1: git reserves higher codes for real errors, and treating one as "not an ancestor" - // would turn a broken repository into a repin instruction nobody can act on. - if (ancestor.code !== 1) { - throw new Error(`git merge-base --is-ancestor failed: ${ancestor.stderr.trim()}`) - } - return { - ok: false, - baseline, - ref, - failure: 'not-an-ancestor', - message: repinInstruction(baseline, resolved, 'is not an ancestor of this commit') - } + return readScenarios(resolve(root, PIN_MANIFEST)).baseline } /** Whether anything a reproduction reads from the candidate tree moved since `since`. */ @@ -284,18 +208,22 @@ async function main(argv: readonly string[]): Promise { const check = argv[0] const root = resolve(import.meta.dirname, '../..') const baseline = readPinnedBaseline(root) - if (check === 'ancestry') { + if (check === 'reachable') { const ref = argv.includes('--ref') ? argv[argv.indexOf('--ref') + 1] : 'HEAD' if (!ref) { throw new Error('--ref needs a commit') } - const verdict = await checkPinAncestry(root, baseline, ref) + const verdict = await checkPinReachable(root, baseline, ref) if (!verdict.ok) { process.stderr.write(`${verdict.message}\n`) process.exitCode = 1 return } - process.stdout.write(`Recording pin ${baseline} is an ancestor of ${ref}.\n`) + process.stdout.write( + verdict.pullRequest === null + ? `Recording pin ${baseline} is an ancestor of ${ref}.\n` + : `Recording pin ${baseline} is kept by refs/pull/${verdict.pullRequest}/head.\n` + ) return } if (check === 'reproduce') { @@ -313,6 +241,13 @@ async function main(argv: readonly string[]): Promise { return } } + // Also fetches a pin that only its pull request's head still holds, so the checkout can find it. + const verdict = await checkPinReachable(root, baseline, 'HEAD') + if (!verdict.ok) { + process.stderr.write(`${verdict.message}\n`) + process.exitCode = 1 + return + } if (!(await reproduceFromPin(root, baseline))) { process.stderr.write(`\n${REPRODUCTION_FAILURE}\n`) process.exitCode = 1 @@ -322,7 +257,7 @@ async function main(argv: readonly string[]): Promise { return } throw new Error( - 'Usage: rpc-recording-pin-guard.mts ancestry [--ref ]\n | reproduce [--if-changed-since ]' + 'Usage: rpc-recording-pin-guard.mts reachable [--ref ]\n | reproduce [--if-changed-since ]' ) } diff --git a/mobile/scripts/rpc-recording-pin-guard.test.ts b/mobile/scripts/rpc-recording-pin-guard.test.ts index e6154d51c2c..616034d72b1 100644 --- a/mobile/scripts/rpc-recording-pin-guard.test.ts +++ b/mobile/scripts/rpc-recording-pin-guard.test.ts @@ -1,16 +1,21 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '../../src/shared/child-process/run-process' import { assertReproductionSuitesExist, - checkPinAncestry, corpusProvenanceChanged, removeScratchWorktree, - repinInstruction, REPRODUCTION_SUITES } from './rpc-recording-pin-guard.mts' +import { + checkPinReachable, + gitHubRepositoryFromRemote, + PIN_MANIFEST, + pullRequestsWithCommit, + repinInstruction +} from './rpc-recording-pin-reachability.mts' const scratch: string[] = [] async function git(cwd: string, ...args: string[]): Promise { @@ -54,6 +59,50 @@ async function seedProvenance(repository: string): Promise { return await commitAt(repository, RECORDER_FILE, 'recorder') } const MISSING_SHA = '0123456789abcdef0123456789abcdef01234567' +function pinManifest(baseline: string): string { + return JSON.stringify({ baseline, scenarios: [] }) +} +/** + * A behaviour-change branch that pinned its own commit, squash-merged onto main, with the host + * keeping the branch head at `refs/pull/7/head` the way GitHub does after the branch is gone. + */ +async function squashMergedPin(change: string, subject = `${change} (#7)`) { + const work = await throwawayRepository() + await commit(work, 'one') + await git(work, 'switch', '--quiet', '--create', 'behaviour-change') + const branchPin = await commit(work, change) + await commitAt(work, PIN_MANIFEST, pinManifest(branchPin)) + await git(work, 'switch', '--quiet', 'main') + await git(work, 'merge', '--quiet', '--squash', 'behaviour-change') + await git(work, 'commit', '--quiet', '--no-verify', '--message', subject) + const remote = join(work, '..', 'remote.git') + await git(work, 'init', '--quiet', '--bare', remote) + await git(work, 'push', '--quiet', remote, 'main', 'behaviour-change:refs/pull/7/head') + await git(work, 'branch', '--quiet', '-D', 'behaviour-change') + return { work, remote, branchPin } +} +async function cloneMain(remote: string): Promise { + const clone = join(remote, '..', `clone-${scratch.length}-${Date.now()}`) + await git( + join(remote, '..'), + 'clone', + '--quiet', + '--single-branch', + '--branch', + 'main', + remote, + clone + ) + await git(clone, 'config', 'user.email', 'pin-guard@example.invalid') + await git(clone, 'config', 'user.name', 'Pin Guard') + return clone +} +// Stand-ins for GitHub's "pull requests associated with this commit" answer. +const heldByPullRequest7 = async () => [7] +const heldByNoPullRequest = async () => [] +const unasked = async (): Promise => { + throw new Error('a pin in this history must not send the guard to GitHub') +} // File scope, not per-suite: a suite-local hook fires before the later suites have made theirs. afterAll(async () => { @@ -62,18 +111,18 @@ afterAll(async () => { } }) -describe('recording pin ancestry', () => { +describe('recording pin reachability', () => { it('passes when the pin is the commit itself', async () => { const repository = await throwawayRepository() const first = await commit(repository, 'one') - expect(await checkPinAncestry(repository, first, first)).toMatchObject({ ok: true }) + expect(await checkPinReachable(repository, first, first, unasked)).toMatchObject({ ok: true }) }) it('passes on ordinary drift: the tree has moved on, the pin is still reachable', async () => { const repository = await throwawayRepository() const pin = await commit(repository, 'one') const head = await commit(repository, 'two') - expect(await checkPinAncestry(repository, pin, head)).toMatchObject({ ok: true }) + expect(await checkPinReachable(repository, pin, head, unasked)).toMatchObject({ ok: true }) }) it('passes on a branch that pinned its own commit, judged against that branch', async () => { @@ -82,7 +131,9 @@ describe('recording pin ancestry', () => { await git(repository, 'switch', '--quiet', '--create', 'behaviour-change') const branchPin = await commit(repository, 'two') const branchHead = await commit(repository, 'three') - expect(await checkPinAncestry(repository, branchPin, branchHead)).toMatchObject({ ok: true }) + expect(await checkPinReachable(repository, branchPin, branchHead, unasked)).toMatchObject({ + ok: true + }) }) it('passes a branch cut before main repinned, judged against the merge preview', async () => { @@ -98,29 +149,115 @@ describe('recording pin ancestry', () => { await git(repository, 'merge', '--quiet', '--no-edit', 'main') const preview = await git(repository, 'rev-parse', 'HEAD') // The preview is the tree CI checks out and reads the pin from, so it is the tree to judge. - expect(await checkPinAncestry(repository, mainPin, preview)).toMatchObject({ ok: true }) + expect(await checkPinReachable(repository, mainPin, preview, unasked)).toMatchObject({ + ok: true + }) // The head sha would have failed this ordinary branch, and told the author to repin to it. - expect(await checkPinAncestry(repository, mainPin, branchHead)).toMatchObject({ + expect( + await checkPinReachable(repository, mainPin, branchHead, heldByNoPullRequest) + ).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) }) - it('fails once that branch squash-merges and the pin leaves the history', async () => { + it('passes once that branch squash-merges, through the head its pull request keeps', async () => { + const { remote, branchPin } = await squashMergedPin('two') + // Only main: the branch is deleted, so the pin is nowhere in this clone until it is fetched. + const clone = await cloneMain(remote) + expect(await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7)).toMatchObject({ + ok: true, + pullRequest: 7 + }) + const drifted = await commit(clone, 'later product change on main') + expect(await checkPinReachable(clone, branchPin, drifted, heldByPullRequest7)).toMatchObject({ + ok: true, + pullRequest: 7 + }) + // The reproduction checks the pinned tree out next, which needs the fetched commit. + await git(clone, 'cat-file', '-e', `${branchPin}^{commit}`) + }) + + it('passes a branch opened after that squash, judged against its merge preview', async () => { + const { remote, branchPin } = await squashMergedPin('two') + const clone = await cloneMain(remote) + await git(clone, 'switch', '--quiet', '--create', 'later-branch') + await commitAt(clone, 'mobile/src/later.ts', 'later branch change') + await git(clone, 'switch', '--quiet', '--detach', 'main') + await commit(clone, 'main moved on') + await git(clone, 'merge', '--quiet', '--no-ff', '--no-edit', 'later-branch') + expect(await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7)).toMatchObject({ + ok: true, + pullRequest: 7 + }) + }) + + it('passes a squash whose edited title lost its (#n), since GitHub names the pull request', async () => { + const { remote, branchPin } = await squashMergedPin('two', 'two, title edited at merge') + const clone = await cloneMain(remote) + expect(await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7)).toMatchObject({ + ok: true, + pullRequest: 7 + }) + }) + + it('passes through the first associated pull request whose head holds the pin', async () => { + const { remote, branchPin, work } = await squashMergedPin('two') + await git(work, 'push', '--quiet', remote, 'main:refs/pull/9/head') + const clone = await cloneMain(remote) + expect(await checkPinReachable(clone, branchPin, 'HEAD', async () => [9, 7])).toMatchObject({ + ok: true, + pullRequest: 7 + }) + }) + + it('fails the merge preview of a branch whose rebase dropped the commit it pinned', async () => { + const { remote } = await squashMergedPin('two') + const clone = await cloneMain(remote) + await git(clone, 'switch', '--quiet', '--create', 'rebased') + const droppedPin = await commit(clone, 'three') + await commitAt(clone, PIN_MANIFEST, pinManifest(droppedPin)) + await git(clone, 'switch', '--quiet', 'main') + await commitAt(clone, 'mobile/src/moved.ts', 'main moved on') + // The rebase rewrites both commits; the manifest still names the one that is gone. + await git(clone, 'rebase', '--quiet', 'main', 'rebased') + await git(clone, 'switch', '--quiet', '--detach', 'main') + await git(clone, 'merge', '--quiet', '--no-ff', '--no-edit', 'rebased') + // Caught on the pull request: after the squash no head would hold the pin either. + const verdict = await checkPinReachable(clone, droppedPin, 'HEAD', heldByNoPullRequest) + expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) + expect(verdict.ok ? '' : verdict.message).toContain('GitHub associates no pull request') + }) + + it("fails when the pull request's head no longer holds the pin", async () => { + const { remote, branchPin, work } = await squashMergedPin('two') + // A force-push after pinning: the pin is left in no ref the host keeps. + await git(work, 'push', '--quiet', '--force', remote, 'main:refs/pull/7/head') + await git(work, 'push', '--quiet', remote, `${branchPin}:refs/heads/stray`) + const clone = await cloneMain(remote) + await git(clone, 'fetch', '--quiet', 'origin', 'stray') + const verdict = await checkPinReachable(clone, branchPin, 'HEAD', heldByPullRequest7) + expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) + expect(verdict.ok ? '' : verdict.message).toContain('refs/pull/7/head') + }) + + it('fails once that branch squash-merges if GitHub associates no pull request with it', async () => { const repository = await throwawayRepository() const base = await commit(repository, 'one') await git(repository, 'switch', '--quiet', '--create', 'behaviour-change') const branchPin = await commit(repository, 'two') await git(repository, 'switch', '--quiet', 'main') await git(repository, 'reset', '--quiet', '--hard', base) + await commitAt(repository, PIN_MANIFEST, pinManifest(branchPin)) const squashed = await commit(repository, 'two, squashed') - const verdict = await checkPinAncestry(repository, branchPin, squashed) + const verdict = await checkPinReachable(repository, branchPin, squashed, heldByNoPullRequest) expect(verdict).toMatchObject({ ok: false, failure: 'not-an-ancestor' }) expect(verdict.ok).toBe(false) if (verdict.ok) { return } expect(verdict.message).toContain(branchPin) + expect(verdict.message).toContain('GitHub associates no pull request') expect(verdict.message).toContain('scripts/rpc-recording.mts --record') expect(verdict.message).toContain('mobile/rpc-foundation/pilot-scenarios.json') }) @@ -128,11 +265,22 @@ describe('recording pin ancestry', () => { it('fails with the same instruction when the pin is no commit at all', async () => { const repository = await throwawayRepository() const head = await commit(repository, 'one') - const verdict = await checkPinAncestry(repository, MISSING_SHA, head) + const verdict = await checkPinReachable(repository, MISSING_SHA, head, heldByNoPullRequest) expect(verdict).toMatchObject({ ok: false, failure: 'unreachable' }) expect(verdict.ok ? '' : verdict.message).toContain('scripts/rpc-recording.mts --record') }) + it('lets a failed GitHub lookup propagate, never a pass or a repin instruction', async () => { + const { remote, branchPin } = await squashMergedPin('two') + const clone = await cloneMain(remote) + const broken = async (): Promise => { + throw new Error('GitHub answered 502') + } + await expect(checkPinReachable(clone, branchPin, 'HEAD', broken)).rejects.toThrow( + 'GitHub answered 502' + ) + }) + it('refuses to answer on a shallow clone instead of trusting grafted history', async () => { const repository = await throwawayRepository() const pin = await commit(repository, 'one') @@ -141,19 +289,74 @@ describe('recording pin ancestry', () => { const clone = join(repository, '..', 'shallow') await git(repository, 'clone', '--quiet', '--depth', '1', `file://${repository}`, clone) // The pin is real and reachable in the full repository; only the missing history hides it. - expect(await checkPinAncestry(repository, pin, head)).toMatchObject({ ok: true }) - const verdict = await checkPinAncestry(clone, pin, 'HEAD') + expect(await checkPinReachable(repository, pin, head, unasked)).toMatchObject({ ok: true }) + const verdict = await checkPinReachable(clone, pin, 'HEAD', unasked) expect(verdict).toMatchObject({ ok: false, failure: 'shallow' }) expect(verdict.ok ? '' : verdict.message).toContain('fetch-depth: 0') }) it('names the head and the pin in the instruction', () => { expect( - repinInstruction('a'.repeat(40), 'b'.repeat(40), 'is not an ancestor of this commit') + repinInstruction('a'.repeat(40), 'b'.repeat(40), 'is not a commit in this repository at all') ).toContain(`git switch -c repin-rpc-recording ${'b'.repeat(40)}`) }) }) +describe("GitHub's pull requests for a pinned commit", () => { + const pin = 'a'.repeat(40) + const answer = (status: number, body: unknown) => + vi.fn(async () => new Response(JSON.stringify(body), { status })) + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('asks with the token when one is set and reads the pull request numbers', async () => { + vi.stubEnv('GITHUB_REPOSITORY', 'stablyai/orca') + vi.stubEnv('GITHUB_TOKEN', 'test-token') + const fetch = answer(200, [{ number: 22762 }, { number: 5 }]) + vi.stubGlobal('fetch', fetch) + expect(await pullRequestsWithCommit('/unused', pin)).toEqual([22762, 5]) + expect(fetch).toHaveBeenCalledWith( + `https://api.github.com/repos/stablyai/orca/commits/${pin}/pulls`, + expect.objectContaining({ + headers: { + Accept: 'application/vnd.github+json', + Authorization: 'Bearer test-token' + } + }) + ) + }) + + it('reads no pull request for a commit GitHub has never seen', async () => { + vi.stubEnv('GITHUB_REPOSITORY', 'stablyai/orca') + vi.stubGlobal('fetch', answer(422, { message: `No commit found for SHA: ${pin}` })) + expect(await pullRequestsWithCommit('/unused', pin)).toEqual([]) + }) + + it('names the rate limit when GitHub refuses a call made without a token', async () => { + vi.stubEnv('GITHUB_REPOSITORY', 'stablyai/orca') + vi.stubEnv('GITHUB_TOKEN', '') + vi.stubEnv('GH_TOKEN', '') + vi.stubGlobal('fetch', answer(403, { message: 'API rate limit exceeded' })) + await expect(pullRequestsWithCommit('/unused', pin)).rejects.toThrow( + /answered 403[\s\S]*API rate limit exceeded[\s\S]*set GITHUB_TOKEN or GH_TOKEN/ + ) + }) + + it('reads owner and repo from https and ssh remotes, and nothing from another host', () => { + expect(gitHubRepositoryFromRemote('https://github.com/stablyai/orca.git\n')).toBe( + 'stablyai/orca' + ) + expect(gitHubRepositoryFromRemote('https://github.com/stablyai/orca')).toBe('stablyai/orca') + expect(gitHubRepositoryFromRemote('git@github.com:stablyai/orca.git')).toBe('stablyai/orca') + expect(gitHubRepositoryFromRemote('ssh://git@github.com/stablyai/orca.git')).toBe( + 'stablyai/orca' + ) + expect(gitHubRepositoryFromRemote('/tmp/remote.git')).toBeNull() + }) +}) + describe('what a reproduction reads from the candidate tree', () => { it('skips the run when only product sources moved', async () => { const repository = await throwawayRepository() diff --git a/mobile/scripts/rpc-recording-pin-reachability.mts b/mobile/scripts/rpc-recording-pin-reachability.mts new file mode 100644 index 00000000000..c7ec31ccfb1 --- /dev/null +++ b/mobile/scripts/rpc-recording-pin-reachability.mts @@ -0,0 +1,204 @@ +/** + * Whether the RPC recording pin names a commit this repository keeps: one in this history, or one in + * the head of a pull request that GitHub associates with the pinned commit. + */ +import { runProcess } from '../../src/shared/child-process/run-process.ts' + +export const PIN_MANIFEST = 'mobile/rpc-foundation/pilot-scenarios.json' +const PIN_REMOTE = 'origin' +const PIN_FETCH_TIMEOUT_MS = 180_000 +const PULL_REQUEST_LOOKUP_TIMEOUT_MS = 30_000 + +export type PinReachabilityFailure = 'shallow' | 'unreachable' | 'not-an-ancestor' +export type PinReachabilityVerdict = + | { ok: true; baseline: string; ref: string; pullRequest: number | null } + | { ok: false; baseline: string; ref: string; failure: PinReachabilityFailure; message: string } +/** Only nominates pull requests; git's ancestry check decides, so a wrong answer can only fail. */ +export type PullRequestLookup = (root: string, commit: string) => Promise + +async function git(cwd: string, args: readonly string[], timeoutMs?: number) { + return await runProcess({ program: 'git', args: [...args], cwd, timeoutMs }) +} +function failureDetail(result: { stderr: string; timedOut: boolean }, timeoutMs: number): string { + return result.timedOut ? `timed out after ${timeoutMs / 1000}s` : result.stderr.trim() +} +export function repinInstruction(baseline: string, ref: string, cause: string): string { + return [ + `The RPC recording corpus is pinned to a commit that ${cause}.`, + '', + ` baseline ${baseline} (mobile/rpc-foundation/pilot-scenarios.json)`, + ` head ${ref}`, + '', + 'Every golden under mobile/rpc-foundation/goldens claims it was recorded from that tree, and', + '`--record` refuses on any other tree, so the corpus cannot be refreshed until the pin names a', + 'commit that is reachable from here. Repin and re-record, both in one commit:', + '', + ` git switch -c repin-rpc-recording ${ref}`, + ` # set "baseline" in mobile/rpc-foundation/pilot-scenarios.json to ${ref}`, + ' ORCA_BACKGROUND_LAUNCH=1 RPC_FOUNDATION_RECORD=1 \\', + ' pnpm --dir mobile exec tsx scripts/rpc-recording.mts --record', + '', + 'Re-record everything: the repin rewrites the `baseline` header of every golden, so a partial', + 'refresh leaves the corpus pinned to two different trees. See', + 'mobile/src/test-support/rpc-recording/README.md, "Recording a behaviour change".' + ].join('\n') +} +const SHALLOW_MESSAGE = [ + 'Cannot judge the recording pin: this is a shallow clone.', + '', + '`git merge-base --is-ancestor` answers from grafted history, so it would report a verdict this', + 'guard has no evidence for. Check out with `fetch-depth: 0`.' +].join('\n') + +async function isAncestor(root: string, commit: string, of: string): Promise { + const ancestor = await git(root, ['merge-base', '--is-ancestor', commit, of]) + // Why only 0 and 1: git reserves higher codes for real errors, and treating one as "not an + // ancestor" would turn a broken repository into a repin instruction nobody can act on. + if (ancestor.code !== 0 && ancestor.code !== 1) { + throw new Error(`git merge-base --is-ancestor failed: ${ancestor.stderr.trim()}`) + } + return ancestor.code === 0 +} + +/** `owner/repo` from a GitHub remote URL, https or ssh. */ +export function gitHubRepositoryFromRemote(url: string): string | null { + const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim()) + return match ? `${match[1]}/${match[2]}` : null +} +async function gitHubRepository(root: string): Promise { + if (process.env.GITHUB_REPOSITORY) { + return process.env.GITHUB_REPOSITORY + } + const remote = await git(root, ['remote', 'get-url', PIN_REMOTE]) + const repository = remote.code === 0 ? gitHubRepositoryFromRemote(remote.stdout) : null + if (!repository) { + throw new Error( + `Cannot tell which GitHub repository ${PIN_REMOTE} is ` + + `(${remote.stdout.trim() || remote.stderr.trim()}); set GITHUB_REPOSITORY=/` + ) + } + return repository +} + +/** The pull requests GitHub associates with `commit`, whatever the squash title says. */ +export async function pullRequestsWithCommit(root: string, commit: string): Promise { + const url = `https://api.github.com/repos/${await gitHubRepository(root)}/commits/${commit}/pulls` + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + signal: AbortSignal.timeout(PULL_REQUEST_LOOKUP_TIMEOUT_MS) + }).catch((error: unknown) => { + // Node's fetch hides the network reason (DNS, TLS, reset) in `cause`. + const cause = error instanceof Error && error.cause ? ` (${String(error.cause)})` : '' + throw new Error( + `Could not ask GitHub which pull requests hold ${commit} (${url}): ${String(error)}${cause}` + ) + }) + const body = await response.text() + // GitHub's answer for a commit it has never seen, which no pull request can hold. + if (response.status === 422 && body.includes('No commit found')) { + return [] + } + if (!response.ok) { + const rateLimited = !token && (response.status === 403 || response.status === 429) + throw new Error( + `GitHub answered ${response.status} to ${url}: ${body.slice(0, 300)}` + + (rateLimited + ? '\nUnauthenticated GitHub API calls are rate-limited; set GITHUB_TOKEN or GH_TOKEN.' + : '') + ) + } + const pulls: unknown = JSON.parse(body) + if (!Array.isArray(pulls)) { + throw new Error( + `GitHub answered ${url} with something other than a list: ${body.slice(0, 300)}` + ) + } + return pulls.flatMap((pull) => (typeof pull?.number === 'number' ? [pull.number] : [])) +} + +/** Fetches a pull request's head into a ref of our own, so no other fetch can move it under us. */ +async function fetchPullRequestHead(root: string, pullRequest: number): Promise { + const local = `refs/rpc-recording-pin/pull/${pullRequest}` + const fetched = await git( + root, + ['fetch', '--quiet', '--no-tags', PIN_REMOTE, `+refs/pull/${pullRequest}/head:${local}`], + PIN_FETCH_TIMEOUT_MS + ) + if (fetched.code !== 0) { + const detail = failureDetail(fetched, PIN_FETCH_TIMEOUT_MS) + throw new Error( + `Could not fetch refs/pull/${pullRequest}/head from ${PIN_REMOTE}, the head of a pull ` + + `request GitHub associates with the recording pin: ${detail}` + ) + } + return local +} + +export async function checkPinReachable( + root: string, + baseline: string, + ref: string, + pullRequestsHolding: PullRequestLookup = pullRequestsWithCommit +): Promise { + const shallow = await git(root, ['rev-parse', '--is-shallow-repository']) + if (shallow.code !== 0) { + throw new Error(`Could not ask git whether the clone is shallow: ${shallow.stderr.trim()}`) + } + if (shallow.stdout.trim() !== 'false') { + return { ok: false, baseline, ref, failure: 'shallow', message: SHALLOW_MESSAGE } + } + const head = await git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) + if (head.code !== 0) { + throw new Error(`Cannot resolve ${ref} to a commit in this repository`) + } + // Resolved, because the instruction below is a command to paste: `HEAD` in it moves with whatever + // the reader has checked out by the time they read the log. + const resolved = head.stdout.trim() + const present = async () => + (await git(root, ['rev-parse', '--verify', '--quiet', `${baseline}^{commit}`])).code === 0 + if ((await present()) && (await isAncestor(root, baseline, ref))) { + return { ok: true, baseline, ref, pullRequest: null } + } + // A squash drops the branch commit the pin names, and the branch may already be deleted, but + // GitHub keeps the pull request's head ref for good. + const pullRequests = await pullRequestsHolding(root, baseline) + for (const pullRequest of pullRequests) { + const pullRequestHead = await fetchPullRequestHead(root, pullRequest) + if ((await present()) && (await isAncestor(root, baseline, pullRequestHead))) { + return { ok: true, baseline, ref, pullRequest } + } + } + const pullRequestCause = + pullRequests.length === 0 + ? 'and GitHub associates no pull request with it' + : 'and no head of a pull request GitHub associates with it holds it ' + + `(${pullRequests.map((pullRequest) => `refs/pull/${pullRequest}/head`).join(', ')})` + if (!(await present())) { + return { + ok: false, + baseline, + ref, + failure: 'unreachable', + message: repinInstruction( + baseline, + resolved, + `is not a commit in this clone, ${pullRequestCause}` + ) + } + } + return { + ok: false, + baseline, + ref, + failure: 'not-an-ancestor', + message: repinInstruction( + baseline, + resolved, + `is not an ancestor of this commit, ${pullRequestCause}` + ) + } +} diff --git a/mobile/scripts/rpc-recording.mts b/mobile/scripts/rpc-recording.mts index 7ada953da09..c6034a9246e 100644 --- a/mobile/scripts/rpc-recording.mts +++ b/mobile/scripts/rpc-recording.mts @@ -24,7 +24,15 @@ const baseline = await runProcess({ ], cwd: root }) -if (baseline.code !== 0) { +// A pin a squash left only in its pull request's head is a missing object, not product drift. +if (baseline.code !== 0 && baseline.code !== 1) { + throw new Error( + `Could not diff against the pinned baseline ${input.baseline}: ${baseline.stderr.trim()}\n` + + 'If your clone lacks that commit, fetch it with\n' + + ' pnpm --dir mobile exec tsx scripts/rpc-recording-pin-guard.mts reachable' + ) +} +if (baseline.code === 1) { throw new Error('Product sources or lockfile differ from the pinned main baseline') } // Why a second check: `git diff` only sees tracked paths, so an untracked module under the diff --git a/mobile/src/test-support/rpc-recording/README.md b/mobile/src/test-support/rpc-recording/README.md index 8f8bbc89fc6..afe33f98bda 100644 --- a/mobile/src/test-support/rpc-recording/README.md +++ b/mobile/src/test-support/rpc-recording/README.md @@ -529,9 +529,15 @@ a tree that does not produce them, which is the one claim this header exists to derived scenarios rather than the manifest, so a repin alone never moves it. 5. Commit the repin and the refresh together, and state the cause. -After a squash-merge the pinned sha is unreachable from main, so the next recording on main repins -to main's tip in a follow-up — the same two-step #20563 and #20895 used. A reviewer checking an -in-flight branch resolves the pin against the branch, where it is a real commit. +A squash-merge leaves the pinned sha out of main's history, and no follow-up repin is needed: the +pin guard asks GitHub which pull requests contain the pinned commit +(`GET /repos/{owner}/{repo}/commits/{sha}/pulls`), fetches each one's `refs/pull//head`, which +GitHub keeps after the branch is deleted, and passes only when git finds the pin in one of those +heads. The squash title plays no part, so editing it at merge time is safe. Run `pnpm --dir mobile +exec tsx scripts/rpc-recording-pin-guard.mts reachable` to fetch a pin your clone does not have +before checking it out. It works without a token, and sends `GITHUB_TOKEN` or `GH_TOKEN` when one +is set, which lifts GitHub's unauthenticated rate limit. A reviewer checking an in-flight branch +resolves the pin against the branch, where it is a real commit. Editing the recorder engine on a migration branch is the awkward case: `recorderSha256` moves, so every golden needs rewriting, but the product tree no longer matches `baseline`, and bumping From 50a8ef18e48a22076a3343ca65f4b40971ec84bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:22:22 -0700 Subject: [PATCH 336/387] fix(native-chat): keep a turn's bar on the prompt that opened it (#23573) A message sent while a structured turn runs appears in the transcript at once, so "the newest user message" is not the running turn's owner. The live "Working for" bar moved to the mid-turn message and counted from the earlier prompt's start, and a send queued behind the running turn counted its wait twice: once in the previous turn and again from its own send. Derive both from the host's turn records in one ordered pass: - The running turn's bar belongs to the user message its lifecycle row names (resolved exactly as settled timing resolves it). A message sent mid-turn gets no bar until its own turn opens; a send folded into the running turn never gets one. Surfaces fall back to the latest user message only when the host names no opener. - A turn counts from its send, but never before the previous turn in the journal ended (its recorded end, else its row's last host revision), capped at the turn's own start. The same origin feeds the live counter and the settled duration. Desktop and mobile share the derivation; no wire, host, or storage change. --- .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 4 + .../mobile-native-chat-controller-contract.ts | 2 + .../use-mobile-native-chat-controller.ts | 3 + ...obile-native-chat-turn-disclosure.test.tsx | 62 +++ .../use-mobile-native-chat-turn-disclosure.ts | 24 +- .../use-mobile-native-chat-turn-status.ts | 14 +- ...use-mobile-structured-agent-turn-timing.ts | 28 +- .../native-chat/NativeChatMessageList.tsx | 19 +- ...iveChatMessageList.turn-indicator.test.tsx | 87 ++++ .../NativeChatStructuredSession.tsx | 1 + .../native-chat-message-rail-items.test.ts | 3 +- .../native-chat-rail-outline-parity.test.ts | 3 +- .../native-chat-transcript-slots.test.ts | 7 +- .../native-chat-transcript-slots.ts | 7 +- .../use-native-chat-message-rail.test.ts | 3 +- .../use-native-chat-turn-status.ts | 7 +- .../use-structured-agent-session.ts | 1 + .../use-structured-agent-turn-timing.ts | 28 +- src/shared/native-chat-turn-status.ts | 19 + ...structured-agent-session-turn-bars.test.ts | 401 ++++++++++++++++++ .../structured-agent-session-turn-timing.ts | 119 ++++-- 22 files changed, 754 insertions(+), 89 deletions(-) create mode 100644 src/shared/structured-agent-session-turn-bars.test.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 65f25e4ea24..31ce1e22550 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -76,6 +76,7 @@ export function MobileNativeChatOverlay({ turnIndicator={controller.nativeChatTurnIndicator} workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} + activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy} streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 31ba0302065..1184b126c55 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -66,6 +66,8 @@ type Props = { /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null + /** Structured lane: the user message the host says opened the running turn. */ + activeTurnOpenedBy?: string | null /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ /** Interrupt a provider turn. */ onStop?: () => void @@ -151,6 +153,7 @@ export function MobileNativeChatView({ turnIndicator = null, workingStartedAt, settledTurns, + activeTurnOpenedBy = null, onStop, streaming, hasMore, @@ -270,6 +273,7 @@ export function MobileNativeChatView({ isWorking: agentWorking === true, workingStartedAt, settledTurns, + activeTurnOpenedBy, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 78c5ebeb1cf..686dac407e7 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -37,6 +37,8 @@ export type MobileNativeChatController = { /** Structured lane: host-recorded turn timing for the per-turn status rows. */ nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null + /** Structured lane: the user message the host says opened the running turn. */ + nativeChatActiveTurnOpenedBy: string | null nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index dfefcf47627..7a1fa7c468e 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -288,6 +288,9 @@ export function useMobileNativeChatController(args: { nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, + nativeChatActiveTurnOpenedBy: activeChatStructured + ? structuredNativeChat.activeTurnOpenedBy + : null, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index 3546a568e73..1757a8ce65b 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -20,12 +20,16 @@ function Harness({ enabled, isWorking = true, settledTurns, + workingStartedAt, + activeTurnOpenedBy, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] enabled: boolean isWorking?: boolean settledTurns?: NativeChatSettledTurns + workingStartedAt?: number | null + activeTurnOpenedBy?: string | null scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -33,6 +37,8 @@ function Harness({ enabled, isWorking, settledTurns, + workingStartedAt, + activeTurnOpenedBy, scopeKey }) return createElement('result', { disclosure }) @@ -202,6 +208,62 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) + it('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const tool: NativeChatMessage = { + id: 'tool-a', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('A'), tool, userMessage('B')] + const rows = () => { + const disclosure = renderer!.root.findByType('result').props.disclosure + return messages.map((message, index) => disclosure.resolveRow(index, message)) + } + // B was sent while A's turn runs; the host still names A as the running turn's opener. + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + activeTurnOpenedBy: 'A' + }) + ) + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + // Row liveness still follows the transcript's grouping under the newest prompt. + expect(rowTool.activeTurnIsWorking).toBe(false) + expect(rowB.activeTurnIsWorking).toBe(true) + + // B's own turn opens: A takes the host's settled duration, B counts from A's end. + act(() => { + renderer?.update( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + activeTurnOpenedBy: 'B' + }) + ) + }) + ;[rowA, , rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + } finally { + vi.useRealTimers() + } + }) + it('keeps at most the latest 128 turns expanded', () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 4e236d648be..6cbb31c723a 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { - MOBILE_UNANCHORED_TURN_KEY, useMobileNativeChatTurnStatus, type NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' @@ -28,6 +27,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, + activeTurnOpenedBy = null, thinking = false, activityText = null, scopeKey @@ -38,6 +38,8 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null + /** The user message the host says opened the running turn; absent, the latest one. */ + activeTurnOpenedBy?: string | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ @@ -57,6 +59,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, + activeTurnOpenedBy, thinking, scopeKey }) @@ -100,6 +103,8 @@ export function useMobileNativeChatTurnDisclosure({ }, [enabled, messages]) const { active, activeTurnKey, completedByTurn } = turnStatuses + // Liveness follows the transcript's own grouping (the latest prompt's rows), not the bar's owner. + const latestTurnKey = turnKeys.at(-1) const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { @@ -120,14 +125,19 @@ export function useMobileNativeChatTurnDisclosure({ // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, // With no user boundary at all, the session's working state stays authoritative. - activeTurnIsWorking: - enabled && - isWorking && - (turnKey === activeTurnKey || - (turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY)) + activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey } }, - [turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking] + [ + turnKeys, + enabled, + activeTurnKey, + latestTurnKey, + active, + completedByTurn, + expandedTurnIds, + isWorking + ] ) return { diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index f7cbb2dd887..3dac27e1b25 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,7 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { + NATIVE_CHAT_UNANCHORED_TURN_KEY, reduceNativeChatTurnTiming, + selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatSettledTurns, type NativeChatTurnStatus, @@ -10,7 +12,6 @@ import { export type { NativeChatTurnStatus } -export const MOBILE_UNANCHORED_TURN_KEY = '__unanchored__' const EMPTY_TURN_TIMING_BY_TURN: NativeChatTurnTimingByTurn = Object.freeze({}) type ScopedTurnTiming = { @@ -26,6 +27,7 @@ export function useMobileNativeChatTurnStatus({ isWorking, workingStartedAt, settledTurns, + activeTurnOpenedBy = null, thinking = false, scopeKey }: { @@ -35,6 +37,8 @@ export function useMobileNativeChatTurnStatus({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null + /** The user message the host says opened the running turn; absent, the latest one. */ + activeTurnOpenedBy?: string | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ @@ -44,11 +48,9 @@ export function useMobileNativeChatTurnStatus({ completedByTurn: Readonly> activeTurnKey: string } { - const latestUserIndex = enabled - ? messages.findLastIndex((message) => message.role === 'user') - : -1 - const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null - const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY + const activeTurnKey = enabled + ? selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) + : NATIVE_CHAT_UNANCHORED_TURN_KEY const [scopedTiming, setScopedTiming] = useState(() => ({ scopeKey, timingByTurn: {} diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 811320510a2..3559f6925ed 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -5,18 +5,16 @@ import type { } from '../../../src/shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import type { StructuredAgentHostClock } from '../../../src/shared/structured-agent-session-reducer' -import { - selectStructuredAgentRunningTurnTiming, - selectStructuredAgentSettledTurns -} from '../../../src/shared/structured-agent-session-turn-timing' +import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' import { stepStructuredAgentTurnClock, type StructuredAgentTurnClockLatch } from '../../../src/shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, and a skew-free start for the live counter whose host-to-local - * conversion is latched once per turn. */ + * off the journal, the user message that owns the running turn's bar, and a + * skew-free start for the live counter whose host-to-local conversion is latched + * once per turn. */ export function useMobileStructuredAgentTurnTiming( { items, @@ -28,16 +26,16 @@ export function useMobileStructuredAgentTurnTiming( hostClock?: StructuredAgentHostClock | null }, turnId: string | null -): { settledTurns: NativeChatSettledTurns; workingStartedAt: number | null } { - const settledTurns = useMemo( - () => selectStructuredAgentSettledTurns(items, submissions), - [items, submissions] +): { + settledTurns: NativeChatSettledTurns + workingStartedAt: number | null + activeTurnOpenedBy: string | null +} { + const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo( + () => selectStructuredAgentTurnBars(items, submissions, turnId), + [items, submissions, turnId] ) const [latch, setLatch] = useState(null) - const runningTiming = useMemo( - () => (turnId === null ? null : selectStructuredAgentRunningTurnTiming(items, turnId)), - [items, turnId] - ) // Stamp during render (React's derive-from-props pattern) so the first paint of // a new turn already counts from the right instant. const step = stepStructuredAgentTurnClock({ @@ -50,5 +48,5 @@ export function useMobileStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { settledTurns, workingStartedAt: step.workingStartedAt } + return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy } } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index d81bfffb52f..d739e0b8d61 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -43,7 +43,10 @@ import { nativeChatReaderScrollInputHandlers } from './native-chat-reader-scroll import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' import { nativeChatSubagentLabels } from '../../../../shared/native-chat-subagent-attribution' -import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' +import { + selectNativeChatActiveTurnKey, + type NativeChatSettledTurns +} from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, type NativeChatDiffReveal, @@ -71,6 +74,7 @@ export function NativeChatMessageList({ allowFileUriLinks = false, workingStartedAt, settledTurns, + activeTurnOpenedBy, deliveryNotices, showTurnStatus = true, showLiveTurnActivity = true, @@ -90,6 +94,8 @@ export function NativeChatMessageList({ workingStartedAt?: number | null /** Host-recorded turn durations keyed by user message id (structured lane). */ settledTurns?: NativeChatSettledTurns + /** The user message the host says opened the running turn (structured lane). */ + activeTurnOpenedBy?: string | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap @@ -154,9 +160,7 @@ export function NativeChatMessageList({ const showTypingIndicator = showTurnStatus ? isWorking : shouldShowNativeChatTypingIndicator({ messages, isWorking }) - const latestUserIndex = messages.findLastIndex((message) => message.role === 'user') - const currentTurnKey = - latestUserIndex === -1 ? undefined : (messages[latestUserIndex]?.id ?? undefined) + const currentTurnKey = messages.findLast((message) => message.role === 'user')?.id ?? undefined // Resolve each row's turn boundary once. Prefix slice/findLast in the render // loop becomes quadratic for long transcripts. const turnKeys = useMemo(() => { @@ -181,9 +185,10 @@ export function NativeChatMessageList({ () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), [journalItems] ) + const activeTurnKey = selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) const turnStatuses = useNativeChatTurnStatus({ messages, - latestUserIndex, + activeTurnKey, isWorking: showTurnStatus && isWorking, workingStartedAt: showTurnStatus ? workingStartedAt : null, settledTurns: showTurnStatus ? settledTurns : null, @@ -195,7 +200,7 @@ export function NativeChatMessageList({ buildNativeChatTranscriptSlots({ messages, turnKeys, - latestUserIndex, + activeTurnKey, currentTurnKey, receipts, turnStatuses, @@ -207,10 +212,10 @@ export function NativeChatMessageList({ subagentLabels }), [ + activeTurnKey, currentTurnKey, expandedTurnIds, isWorking, - latestUserIndex, lifecycleWorking, messages, receipts, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index b58c91fe31f..8400b592e7d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -655,6 +655,93 @@ describe('NativeChatMessageList turn indicator', () => { expect(screen.getByText('Working for 0s')).toBeInTheDocument() }) + it('keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn', () => { + const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' + }) + const messages = [ + prompt('user-a', 'Run four sleeps'), + { + id: 'tool-a', + role: 'assistant' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' as const + } + ], + timestamp: 2, + source: 'transcript' as const + }, + prompt('user-b', 'Also say banana') + ] + const list = (props: Partial>) => ( + + ) + // B was sent while A's turn runs: the host still names A as the running turn's opener. + const { rerender } = render( + list({ + workingStartedAt: Date.now() - 5000, + settledTurns: new Map(), + activeTurnOpenedBy: 'user-a' + }) + ) + const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') + expect(screen.getAllByText(/Working for/)).toHaveLength(1) + expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + + // B's own turn opens: A settles in place and B counts from A's end. + rerender( + list({ + workingStartedAt: Date.now() - 1000, + settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), + activeTurnOpenedBy: 'user-b' + }) + ) + expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) + const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') + expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + + rerender( + list({ + isWorking: false, + session: { ...session, status: 'ready', messages }, + workingStartedAt: null, + settledTurns: new Map([ + ['user-a', { startedAt: 1, workedSeconds: 17 }], + ['user-b', { startedAt: 2, workedSeconds: 51 }] + ]), + activeTurnOpenedBy: null + }) + ) + expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) + expect( + screen + .getByText('Also say banana') + .compareDocumentPosition(screen.getByText('Worked for 51s')) + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.queryByText(/Working for/)).toBeNull() + }) + it("uses the completed caret to expand that turn's tool details", () => { const startedAt = Date.now() - 3000 render( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b66f1770d33..2de5591b779 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -279,6 +279,7 @@ export function NativeChatStructuredSession( fontScale={fontScale.scale} workingStartedAt={controller.workingStartedAt} settledTurns={controller.settledTurns} + activeTurnOpenedBy={controller.activeTurnOpenedBy} showTurnStatus showLiveTurnActivity={prompt === null} turnActivity={controller.turnActivity} diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index 5e6fecefa2f..3e760a787ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -10,6 +10,7 @@ import { NATIVE_CHAT_RAIL_MAX_TICKS, type NativeChatRailItem } from './native-chat-message-rail-items' +import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function text(id: string, body: string, role: NativeChatMessage['role'] = 'assistant') { return { @@ -42,7 +43,7 @@ function slotsOf(messages: NativeChatMessage[]) { return buildNativeChatTranscriptSlots({ messages, turnKeys, - latestUserIndex: messages.findLastIndex((message) => message.role === 'user'), + activeTurnKey: selectNativeChatActiveTurnKey(messages), currentTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 385899ae91f..5ee40345e1b 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -18,6 +18,7 @@ import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } @@ -81,7 +82,7 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour const slots = buildNativeChatTranscriptSlots({ messages, turnKeys, - latestUserIndex: messages.findLastIndex((message) => message.role === 'user'), + activeTurnKey: selectNativeChatActiveTurnKey(messages), currentTurnKey: turn, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 6bc5a2d9b1d..5f1fc09b3a1 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { + selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' @@ -38,7 +39,7 @@ function build( return buildNativeChatTranscriptSlots({ messages, turnKeys, - latestUserIndex: messages.findLastIndex((message) => message.role === 'user'), + activeTurnKey: selectNativeChatActiveTurnKey(messages), currentTurnKey: undefined, receipts: new Map(), turnStatuses: NO_STATUSES, @@ -112,7 +113,7 @@ describe('transcript slots', () => { it('keeps a message whose only content is a turn status under it', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 4 } const slots = build([text('u', '', 'user')], { - latestUserIndex: 0, + activeTurnKey: 'u', turnStatuses: { active: status, completedByTurn: {} } }) expect(slots).toHaveLength(1) @@ -142,7 +143,7 @@ describe('transcript slots', () => { it('puts the running turn bar under the prompt it answers', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } const slots = build([text('u', 'ask', 'user'), text('a', 'answer')], { - latestUserIndex: 0, + activeTurnKey: 'u', turnStatuses: { active: status, completedByTurn: {} }, isWorking: true }) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index cf2acedfc68..6e0dc5bff4b 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -60,7 +60,8 @@ export type NativeChatTranscriptSlot = { export type NativeChatTranscriptSlotsInput = { messages: readonly NativeChatMessage[] turnKeys: readonly (string | undefined)[] - latestUserIndex: number + /** The user message whose bar carries the live turn's status. */ + activeTurnKey: string currentTurnKey: string | undefined receipts: ReadonlyMap turnStatuses: { @@ -84,7 +85,7 @@ export function buildNativeChatTranscriptSlots( const { messages, turnKeys, - latestUserIndex, + activeTurnKey, currentTurnKey, receipts, turnStatuses, @@ -151,7 +152,7 @@ export function buildNativeChatTranscriptSlots( const turnKey = turnKeys[index] const receipt = receipts.get(message.id) const candidateStatus = - index === latestUserIndex + message.role === 'user' && message.id === activeTurnKey ? turnStatuses.active : message.role === 'user' && turnKey ? turnStatuses.completedByTurn[turnKey] diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index cec8d63a9c7..b48025533f4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -8,6 +8,7 @@ import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import { useNativeChatMessageRail } from './use-native-chat-message-rail' +import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function message(id: string, role: NativeChatMessage['role']): NativeChatMessage { return { @@ -31,7 +32,7 @@ function slotsOf(messages: NativeChatMessage[]) { return buildNativeChatTranscriptSlots({ messages, turnKeys, - latestUserIndex: messages.findLastIndex((entry) => entry.role === 'user'), + activeTurnKey: selectNativeChatActiveTurnKey(messages), currentTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts index 82d98c77ec0..95db05141d2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts @@ -12,14 +12,15 @@ export type { NativeChatTurnStatus } export function useNativeChatTurnStatus({ messages, - latestUserIndex, + activeTurnKey, isWorking, workingStartedAt, settledTurns, thinking = false }: { messages: readonly NativeChatMessage[] - latestUserIndex: number + /** The user message whose bar carries the live clock (`selectNativeChatActiveTurnKey`). */ + activeTurnKey: string isWorking: boolean workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ @@ -30,8 +31,6 @@ export function useNativeChatTurnStatus({ active: NativeChatTurnStatus | null completedByTurn: Readonly> } { - const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null - const activeTurnKey = latestUserId ?? '__unanchored__' const [timingByTurn, setTimingByTurn] = useState({}) useLayoutEffect(() => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 659bbafbbd9..d9ca4995b13 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -156,6 +156,7 @@ export function useStructuredAgentSession(args: { isWorking: transportState.isWorking, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, + activeTurnOpenedBy: transportState.turnTiming.activeTurnOpenedBy, turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts index 5603c82b42f..1c26fd9750c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts @@ -5,18 +5,16 @@ import type { } from '../../../../shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import type { StructuredAgentHostClock } from '../../../../shared/structured-agent-session-reducer' -import { - selectStructuredAgentRunningTurnTiming, - selectStructuredAgentSettledTurns -} from '../../../../shared/structured-agent-session-turn-timing' +import { selectStructuredAgentTurnBars } from '../../../../shared/structured-agent-session-turn-timing' import { stepStructuredAgentTurnClock, type StructuredAgentTurnClockLatch } from '../../../../shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, and a skew-free start for the live counter whose host-to-local - * conversion is latched once per turn. */ + * off the journal, the user message that owns the running turn's bar, and a + * skew-free start for the live counter whose host-to-local conversion is latched + * once per turn. */ export function useStructuredAgentTurnTiming( { items, @@ -28,16 +26,16 @@ export function useStructuredAgentTurnTiming( hostClock?: StructuredAgentHostClock | null }, turnId: string | null -): { settledTurns: NativeChatSettledTurns; workingStartedAt: number | null } { - const settledTurns = useMemo( - () => selectStructuredAgentSettledTurns(items, submissions), - [items, submissions] +): { + settledTurns: NativeChatSettledTurns + workingStartedAt: number | null + activeTurnOpenedBy: string | null +} { + const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo( + () => selectStructuredAgentTurnBars(items, submissions, turnId), + [items, submissions, turnId] ) const [latch, setLatch] = useState(null) - const runningTiming = useMemo( - () => (turnId === null ? null : selectStructuredAgentRunningTurnTiming(items, turnId)), - [items, turnId] - ) // Stamp during render (React's derive-from-props pattern) so the first paint of // a new turn already counts from the right instant. const step = stepStructuredAgentTurnClock({ @@ -50,5 +48,5 @@ export function useStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { settledTurns, workingStartedAt: step.workingStartedAt } + return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy } } diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 1b0613bd716..acbab30aabf 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -4,6 +4,8 @@ // (used directly — mobile ships English only) so the two surfaces never drift. // Everything here is pure; each platform owns its own clock. +import type { NativeChatMessage } from './native-chat-types' + export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', @@ -101,6 +103,23 @@ export type NativeChatTurnStatus = { export type NativeChatTurnTimingByTurn = Readonly> +/** The live turn's key when the transcript has no user message to hang it on. */ +export const NATIVE_CHAT_UNANCHORED_TURN_KEY = '__unanchored__' + +/** The user message whose bar carries the live clock: the one the host says opened + * the running turn — a message sent while it runs is not that one — else, when the + * host names none, the latest user message. */ +export function selectNativeChatActiveTurnKey( + messages: readonly NativeChatMessage[], + activeTurnOpenedBy?: string | null +): string { + return ( + activeTurnOpenedBy ?? + messages.findLast((message) => message.role === 'user')?.id ?? + NATIVE_CHAT_UNANCHORED_TURN_KEY + ) +} + /** The turn-timing state machine, lifted out of the React hook so desktop and * mobile stamp start/stop identically. Returns the same reference when nothing * changed so callers can bail out of a state update. */ diff --git a/src/shared/structured-agent-session-turn-bars.test.ts b/src/shared/structured-agent-session-turn-bars.test.ts new file mode 100644 index 00000000000..0f4732a3aac --- /dev/null +++ b/src/shared/structured-agent-session-turn-bars.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import type { NativeChatMessage } from './native-chat-types' +import { + reduceNativeChatTurnTiming, + selectNativeChatActiveTurnKey, + selectNativeChatTurnStatuses, + type NativeChatTurnTimingByTurn +} from './native-chat-turn-status' +import { + selectStructuredAgentSettledTurns, + selectStructuredAgentTurnBars, + structuredAgentTurnLocalStartedAt, + structuredAgentTurnOrigin +} from './structured-agent-session-turn-timing' + +// Host clock of a live Claude repro (journal rows 44-79): prompt A runs a ~60 s tool +// chain; 4 s later the user sends B, which Claude picks up by interrupting A's turn. +const T = 1_790_580_000_000 +const PRIOR = { requestedAt: 1_790_579_265_055, startedAt: 1_790_579_270_679 } +const PRIOR_END = 1_790_579_272_584 +const A = { sent: T + 74_595, started: T + 76_146, ended: T + 92_372 } +const B = { sent: T + 79_006, started: T + 92_372, ended: T + 144_349 } +const SESSION = 'claude:55368cfb' + +function user( + sequence: number, + clientMessageId: string, + observedAt: number +): AgentJournalRenderItem { + return { + itemId: `orca:${clientMessageId}`, + revision: 0, + sequence, + observedAt, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + +function turn( + sequence: number, + observedAt: number, + record: AgentJournalTurnLifecycle +): AgentJournalRenderItem { + return { + itemId: `legacy:claude:55368cfb:turn-lifecycle%3A${record.turnId}`, + revision: 1, + sequence, + observedAt, + body: { kind: 'turn', ...record } + } +} + +function tool(sequence: number, observedAt: number): AgentJournalRenderItem { + return { + itemId: `orca:claude-tool%3A${sequence}`, + revision: 1, + sequence, + observedAt, + body: { kind: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + } +} + +function accepted(clientMessageId: string, providerItemId: string | null): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: providerItemId ? 'accepted' : 'pending', + providerItemId, + reason: null, + submittedAt: 1, + resolvedAt: providerItemId ? 2 : null + } +} + +const priorTurn = turn(47, PRIOR_END + 153, { + turnId: '5901cefd', + state: 'completed', + ...PRIOR, + completedAt: PRIOR_END, + durationMs: 6_218, + userItemId: `${SESSION}:5901cefd` +}) +const turn1 = { turnId: '1020ab78', userItemId: `${SESSION}:1020ab78` } +const turn2 = { turnId: '6891a0bc', userItemId: `${SESSION}:6891a0bc` } +const history = [user(44, 'prior', PRIOR.requestedAt), priorTurn] +const priorSubmission = accepted('prior', `${SESSION}:5901cefd`) + +/** B sent while turn 1 still runs (rows 54-61). */ +const whileQueued = { + turnId: turn1.turnId, + items: [ + ...history, + user(54, 'A', A.sent), + turn(57, T + 77_224, { + ...turn1, + state: 'running', + startedAt: A.started, + requestedAt: A.sent + }), + tool(58, T + 77_223), + user(60, 'B', B.sent) + ], + submissions: [priorSubmission, accepted('A', turn1.userItemId), accepted('B', null)] +} + +/** Claude ended turn 1 to pick up B (rows 62-65). */ +const turn1Ended = turn(57, A.ended + 1, { + ...turn1, + state: 'interrupted', + startedAt: A.started, + requestedAt: A.sent, + completedAt: A.ended +}) +const whileB = { + turnId: turn2.turnId, + items: [ + ...history, + user(54, 'A', A.sent), + turn1Ended, + tool(58, T + 92_329), + user(60, 'B', B.sent), + turn(65, B.started, { ...turn2, state: 'running', startedAt: B.started, requestedAt: B.sent }) + ], + submissions: [priorSubmission, accepted('A', turn1.userItemId), accepted('B', turn2.userItemId)] +} + +/** Both settled (row 78). Claude's own duration for B counts from turn 1's start. */ +const settled = { + items: [ + ...whileB.items.slice(0, -1), + turn(65, B.ended + 1, { + ...turn2, + state: 'completed', + startedAt: B.started, + requestedAt: B.sent, + completedAt: B.ended, + durationMs: 69_595 + }) + ], + submissions: whileB.submissions +} + +function message(id: string, role: NativeChatMessage['role'] = 'user'): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } +} + +describe('a send queued behind a running Claude turn', () => { + it('leaves the live bar with the prompt that opened the running turn', () => { + const bars = selectStructuredAgentTurnBars( + whileQueued.items, + whileQueued.submissions, + whileQueued.turnId + ) + expect(bars.activeTurnOpenedBy).toBe('orca:A') + const transcript = [message('orca:prior'), message('orca:A'), message('orca:B')] + expect(selectNativeChatActiveTurnKey(transcript, bars.activeTurnOpenedBy)).toBe('orca:A') + // A was sent into an idle session: it counts from its own send, as before. + expect(bars.runningTiming).toEqual({ + state: 'running', + startedAt: A.started, + requestedAt: A.sent, + observedAt: T + 77_224 + }) + expect(bars.settledTurns.has('orca:B')).toBe(false) + }) + + it("moves the bar to B when its own turn opens, counting from A's end", () => { + const bars = selectStructuredAgentTurnBars(whileB.items, whileB.submissions, whileB.turnId) + expect(bars.activeTurnOpenedBy).toBe('orca:B') + expect(bars.settledTurns.get('orca:A')).toEqual({ startedAt: A.started, workedSeconds: 17 }) + expect(bars.runningTiming && structuredAgentTurnOrigin(bars.runningTiming)).toBe(A.ended) + // A client attaching 3 s into turn 2 counts 3 s, not the 13 s B waited behind turn 1. + expect(structuredAgentTurnLocalStartedAt(bars.runningTiming!, 50_000, B.started + 3_000)).toBe( + 47_000 + ) + }) + + it('settles from the same origin the live counter used, so the bars sum to wall time', () => { + const settledTurns = selectStructuredAgentSettledTurns(settled.items, settled.submissions) + expect(settledTurns.get('orca:A')).toEqual({ startedAt: A.started, workedSeconds: 17 }) + // Not 65 s from B's send, and not Claude's own 69.6 s, which counts from turn 1's start. + expect(settledTurns.get('orca:B')).toEqual({ startedAt: B.started, workedSeconds: 51 }) + // The turn before A never overlapped a send; its duration is unchanged. + expect(settledTurns.get('orca:prior')).toEqual({ startedAt: PRIOR.startedAt, workedSeconds: 7 }) + + const liveOriginB = structuredAgentTurnOrigin( + selectStructuredAgentTurnBars(whileB.items, whileB.submissions, whileB.turnId).runningTiming! + ) + expect(Math.floor((B.ended - liveOriginB) / 1000)).toBe(51) + expect(A.ended - A.sent + (B.ended - liveOriginB)).toBe(B.ended - A.sent) + expect(selectStructuredAgentTurnBars(settled.items, settled.submissions, null)).toMatchObject({ + activeTurnOpenedBy: null, + runningTiming: null + }) + }) + + it('shows one live bar under A while B waits, then A settled and B live', () => { + const transcript = [message('orca:A'), message('orca:tool', 'assistant'), message('orca:B')] + const validTurnKeys = new Set(['orca:A', 'orca:B']) + let timing: NativeChatTurnTimingByTurn = {} + const pass = ( + snapshot: { items: AgentJournalRenderItem[]; submissions: AgentJournalSubmission[] }, + turnId: string, + now: number + ) => { + const bars = selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, turnId) + const activeTurnKey = selectNativeChatActiveTurnKey(transcript, bars.activeTurnOpenedBy) + const workingStartedAt = structuredAgentTurnLocalStartedAt(bars.runningTiming!, now, now) + timing = reduceNativeChatTurnTiming(timing, { + activeTurnKey, + validTurnKeys, + isWorking: true, + workingStartedAt, + now + }) + return selectNativeChatTurnStatuses(timing, { + activeTurnKey, + isWorking: true, + workingStartedAt, + thinking: false, + settledByTurn: bars.settledTurns + }) + } + + const queued = pass(whileQueued, whileQueued.turnId, B.sent + 1_000) + expect(timing['orca:A']?.startedAt).toBe(A.sent) + expect(timing['orca:B']).toBeUndefined() + expect(queued.completedByTurn['orca:B']).toBeUndefined() + + const running = pass(whileB, whileB.turnId, B.started + 1_000) + expect(running.completedByTurn['orca:A']?.workedSeconds).toBe(17) + expect(running.active?.startedAt).toBe(A.ended) + }) +}) + +describe('a send Codex folds into the running turn', () => { + const key = 'codex:thread:t1:0' + const items: AgentJournalRenderItem[] = [ + { ...user(1, 'first', 1_000) }, + { + itemId: 'legacy:codex:s:turn-lifecycle%3At1', + revision: 1, + sequence: 2, + observedAt: 1_200, + body: { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: key, + requestedAt: 1_000, + startedAt: 1_200 + } + }, + { ...user(3, 'second', 4_000) } + ] + const submissions = [accepted('first', key), accepted('second', key)] + const ended: AgentJournalRenderItem[] = [ + items[0]!, + { + ...items[1]!, + observedAt: 9_000, + body: { + kind: 'turn', + turnId: 't1', + state: 'completed', + userItemId: key, + requestedAt: 1_000, + startedAt: 1_200, + completedAt: 9_000 + } + }, + items[2]! + ] + const transcript = [message('orca:first'), message('orca:second')] + + it('keeps exactly one bar, under the opening prompt, live and settled', () => { + const live = selectStructuredAgentTurnBars(items, submissions, 't1') + expect(selectNativeChatActiveTurnKey(transcript, live.activeTurnOpenedBy)).toBe('orca:first') + expect(live.runningTiming && structuredAgentTurnOrigin(live.runningTiming)).toBe(1_000) + + let timing: NativeChatTurnTimingByTurn = {} + const validTurnKeys = new Set(['orca:first', 'orca:second']) + timing = reduceNativeChatTurnTiming(timing, { + activeTurnKey: 'orca:first', + validTurnKeys, + isWorking: true, + workingStartedAt: 1_000, + now: 4_500 + }) + const after = selectStructuredAgentTurnBars(ended, submissions, null) + // The turn is over: nothing names an owner, so the key falls back to the newest prompt, + // which never ran a turn of its own and so has nothing to settle. + const activeTurnKey = selectNativeChatActiveTurnKey(transcript, after.activeTurnOpenedBy) + expect(activeTurnKey).toBe('orca:second') + timing = reduceNativeChatTurnTiming(timing, { + activeTurnKey, + validTurnKeys, + isWorking: false, + workingStartedAt: null, + now: 9_500 + }) + const statuses = selectNativeChatTurnStatuses(timing, { + activeTurnKey, + isWorking: false, + thinking: false, + settledByTurn: after.settledTurns + }) + expect(statuses.active).toBeNull() + expect(Object.keys(statuses.completedByTurn)).toEqual(['orca:first']) + expect(statuses.completedByTurn['orca:first']?.workedSeconds).toBe(8) + }) +}) + +describe('turns whose host names no opener', () => { + it('falls back to the newest prompt for an older host row with no user key', () => { + const items = [ + user(1, 'u1', 1_000), + turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), + user(3, 'u2', 2_000) + ] + const bars = selectStructuredAgentTurnBars(items, [], 't1') + expect(bars.activeTurnOpenedBy).toBeNull() + expect(selectNativeChatActiveTurnKey([message('orca:u1'), message('orca:u2')], null)).toBe( + 'orca:u2' + ) + // Settled timing keeps its journal-order attribution. + expect([...selectStructuredAgentSettledTurns(items).keys()]).toEqual(['orca:u1']) + }) + + it('falls back for a turn the provider opened and keyed to itself', () => { + const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed' + const items = [ + user(1, 'u1', 1_000), + turn(2, 5_000, { + turnId: 'resumed', + state: 'running', + startedAt: 5_000, + userItemId: self + }) + ] + expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBeNull() + }) + + it('names nothing for the unanchored transcript', () => { + expect(selectNativeChatActiveTurnKey([message('a', 'assistant')], null)).toBe('__unanchored__') + }) +}) + +describe('the previous turn has no recorded end', () => { + it('leaves an unverifiable turn null and a later send counting from itself', () => { + const items = [ + user(1, 'u1', 1_000), + turn(2, 3_000, { turnId: 't1', state: 'unverifiable', userItemId: 'orca:u1' }), + user(3, 'u2', 10_000), + turn(4, 12_000, { + turnId: 't2', + state: 'completed', + userItemId: 'orca:u2', + requestedAt: 10_000, + startedAt: 11_000, + completedAt: 20_000 + }) + ] + const settledTurns = selectStructuredAgentSettledTurns(items) + expect(settledTurns.get('orca:u1')).toBeNull() + expect(settledTurns.get('orca:u2')).toEqual({ startedAt: 11_000, workedSeconds: 10 }) + }) + + it("waits out the previous row's last host revision, never past its own start", () => { + const queued = (lastSeenAt: number) => + selectStructuredAgentTurnBars( + [ + user(1, 'u1', 1_000), + turn(2, lastSeenAt, { + turnId: 't1', + state: 'unverifiable', + userItemId: 'orca:u1', + startedAt: 1_000 + }), + user(3, 'u2', 2_000), + turn(4, 9_000, { + turnId: 't2', + state: 'running', + userItemId: 'orca:u2', + requestedAt: 2_000, + startedAt: 9_000 + }) + ], + [], + 't2' + ).runningTiming! + expect(structuredAgentTurnOrigin(queued(6_000))).toBe(6_000) + expect(structuredAgentTurnOrigin(queued(12_000))).toBe(9_000) + }) +}) diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 3f480013886..9d93639e181 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -23,12 +23,18 @@ export type StructuredAgentTurnTiming = { completedAt?: number /** The provider's own measurement; used when exact host endpoints are unavailable. */ durationMs?: number + /** Host clock this turn's send stopped waiting behind the journal's previous + * turn; present only when the send was issued before that turn ended. */ + queuedUntil?: number /** Host clock when the lifecycle row was appended; with `startedAt` it gives * the host-side lag a client must subtract to anchor a live counter. */ observedAt: number } -function readTiming(item: AgentJournalRenderItem): StructuredAgentTurnTiming | null { +function readTiming( + item: AgentJournalRenderItem, + precedingTurnEndedAt: number | undefined +): StructuredAgentTurnTiming | null { const turn = readAgentJournalTurn(item.body) if (!turn) { return null @@ -49,26 +55,47 @@ function readTiming(item: AgentJournalRenderItem): StructuredAgentTurnTiming | n durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : undefined + // A send queued behind the previous turn counts from that turn's end (recorded, else its row's + // last host revision), never past this turn's own start: the provider opens it only after. + const queuedUntil = + precedingTurnEndedAt === undefined ? undefined : Math.min(precedingTurnEndedAt, startedAt) return { state, startedAt, ...(requested !== undefined ? { requestedAt: requested } : {}), ...(end !== undefined ? { completedAt: end } : {}), ...(measured !== undefined ? { durationMs: measured } : {}), + ...(requested !== undefined && queuedUntil !== undefined && queuedUntil > requested + ? { queuedUntil } + : {}), observedAt: item.observedAt } } -/** Timing keyed by the user message that opened each turn. A row can name the - * submission directly or by a provider key that resolves through its alias. - * Rows from older hosts carry no key and fall back +/** One turn record, read in journal order. */ +type StructuredAgentJournalTurn = { + timing: StructuredAgentTurnTiming | null + /** The user message the host names as the turn's opener; null when it names + * none (an older host, or a turn the provider opened and keyed to itself). */ + openedBy: string | null +} + +type StructuredAgentJournalTurns = { + /** Timing keyed by the user message that opened each turn. */ + byUserItem: ReadonlyMap + byTurnId: ReadonlyMap +} + +/** Every turn record in one pass over the journal. A row can name its user + * message directly or by a provider key that resolves through its alias. + * Rows from older hosts carry no key and fall back, for timing only, * to the nearest user message before them in journal order — the submission * row is written ahead of dispatch, so it always precedes the provider's * turn-start. Untimed rows are skipped unless explicitly unverifiable (null). */ -export function selectStructuredAgentTurnTimings( +function readStructuredAgentJournalTurns( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] -): ReadonlyMap { +): StructuredAgentJournalTurns { const itemIds = new Set(items.map((item) => item.itemId)) const aliases = new Map() // Codex folds a send issued mid-turn into the running turn under the SAME provider @@ -78,26 +105,43 @@ export function selectStructuredAgentTurnTimings( aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) } } - const timings = new Map() + const byUserItem = new Map() + const byTurnId = new Map() let precedingUserItemId: string | null = null + let precedingTurnEndedAt: number | undefined for (const item of items) { if (item.body.kind === 'message' && item.body.role === 'user') { precedingUserItemId = item.itemId continue } const turn = readAgentJournalTurn(item.body) - const timing = readTiming(item) - if (!timing && turn?.state !== 'unverifiable') { + if (!turn) { continue } - const key = turn?.userItemId - const userItemId = - key === undefined ? precedingUserItemId : itemIds.has(key) ? key : (aliases.get(key) ?? null) + const timing = readTiming(item, precedingTurnEndedAt) + precedingTurnEndedAt = timing?.completedAt ?? item.observedAt + const key = turn.userItemId + const named = key === undefined ? null : itemIds.has(key) ? key : (aliases.get(key) ?? null) + byTurnId.set(turn.turnId, { + timing, + openedBy: named === item.itemId ? null : named + }) + if (!timing && turn.state !== 'unverifiable') { + continue + } + const userItemId = key === undefined ? precedingUserItemId : named if (userItemId !== null) { - timings.set(userItemId, timing) + byUserItem.set(userItemId, timing) } } - return timings + return { byUserItem, byTurnId } +} + +export function selectStructuredAgentTurnTimings( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): ReadonlyMap { + return readStructuredAgentJournalTurns(items, submissions).byUserItem } /** The live turn's lifecycle timing, or null when its row carries no host start @@ -106,20 +150,15 @@ export function selectStructuredAgentRunningTurnTiming( items: readonly AgentJournalRenderItem[], turnId: string ): StructuredAgentTurnTiming | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - if (item && readAgentJournalTurn(item.body)?.turnId === turnId) { - return readTiming(item) - } - } - return null + return readStructuredAgentJournalTurns(items).byTurnId.get(turnId)?.timing ?? null } /** The single instant every reading of a turn's elapsed time counts from: the - * send that opened it when the host named one, the provider turn-open otherwise. + * send that opened it when the host named one — or, for a send queued behind the + * previous turn, that turn's end — and the provider turn-open otherwise. * One origin is what keeps the live counter and the settled duration agreeing. */ export function structuredAgentTurnOrigin(timing: StructuredAgentTurnTiming): number { - return timing.requestedAt ?? timing.startedAt + return timing.queuedUntil ?? timing.requestedAt ?? timing.startedAt } /** Whole seconds a settled turn ran, or null when the host never observed its end. */ @@ -129,9 +168,9 @@ export function completedStructuredAgentTurnSeconds( if (!timing || (timing.state !== 'completed' && timing.state !== 'interrupted')) { return null } - // Provider durations may begin at turn-open, so exact host endpoints preserve the live origin. + // Provider durations may begin before the live origin (turn-open, or a turn this send queued behind). if (timing.requestedAt !== undefined && timing.completedAt !== undefined) { - return Math.max(0, Math.floor((timing.completedAt - timing.requestedAt) / 1000)) + return Math.max(0, Math.floor((timing.completedAt - structuredAgentTurnOrigin(timing)) / 1000)) } if (timing.durationMs !== undefined) { return Math.floor(timing.durationMs / 1000) @@ -170,9 +209,16 @@ export function structuredAgentTurnLocalStartedAt( export function selectStructuredAgentSettledTurns( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] +): NativeChatSettledTurns { + return settledTurnsOf(readStructuredAgentJournalTurns(items, submissions), submissions) +} + +function settledTurnsOf( + turns: StructuredAgentJournalTurns, + submissions: readonly AgentJournalSubmission[] ): NativeChatSettledTurns { const settled = new Map() - for (const [userItemId, timing] of selectStructuredAgentTurnTimings(items, submissions)) { + for (const [userItemId, timing] of turns.byUserItem) { const workedSeconds = completedStructuredAgentTurnSeconds(timing) settled.set( userItemId, @@ -189,3 +235,24 @@ export function selectStructuredAgentSettledTurns( } return settled } + +/** Everything a chat surface reads off the journal for its turn bars, from one + * pass: settled durations, the running turn's timing, and the user message that + * owns its bar — which a message sent while the turn runs is not. */ +export function selectStructuredAgentTurnBars( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[], + turnId: string | null +): { + settledTurns: NativeChatSettledTurns + runningTiming: StructuredAgentTurnTiming | null + activeTurnOpenedBy: string | null +} { + const turns = readStructuredAgentJournalTurns(items, submissions) + const running = turnId === null ? undefined : turns.byTurnId.get(turnId) + return { + settledTurns: settledTurnsOf(turns, submissions), + runningTiming: running?.timing ?? null, + activeTurnOpenedBy: running?.openedBy ?? null + } +} From c30c8f9d7775fba6f897af5ee4b88dfda6f3056e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:22:47 -0700 Subject: [PATCH 337/387] fix(native-chat): a message Claude folds into its running turn no longer splits the turn (#23621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): treat a folded mid-turn Claude replay as a receipt, not a turn boundary A message sent while a Claude turn runs is folded into the running turn by the CLI and replayed mid-turn with the client uuid. The replay-driven opener treated that replay as a new turn: the running turn was marked interrupted and the 'Worked for' bar split. The dispatch waiter now captures the open turn at write time (sentDuringTurnId, volatile); a replay that adopts the client uuid while that exact turn is still open settles delivery and opens no boundary. Plural result user_message_uuids settle each waiter under its own uuid; every other relation (miss, replaced turn, provider-resumed root, idle write, fresh replay uuid) keeps the opener path. Replay/turn resolution split out of the dispatch module to hold the line budget. * fix(native-chat): state the fold receipt's uuid-adoption limit as unmeasured The receipt admits only a replay that adopts the client uuid. The comment and test named a fresh replay uuid as "the CLI starting the queued send's own turn", but the measured miss case adopts the client uuid too, so adoption does not tell a fold from a later turn. Say what the rule actually is: the measured fold shape qualifies, and an unmeasured fresh-uuid replay keeps the opener path it always had. No behavior change. * fix(native-chat): decide Claude fold receipts from the provider's own request cycle Measured (p3 captures, CLI 2.1.280): the CLI folds any send that arrives while its request cycle runs — including one written before the first replay — and it announces every new cycle (sequential turn, queued turn, background wake, /compact) with a root system/init; each result names the sends its cycle ran in user_message_uuids. So the fold decision now reads provider state: an adopted replay while the open turn's cycle is still live (no root init since it opened) is a delivery receipt. The send-time bookkeeping (sentDuringTurnId) is removed; it missed the measured early-steer fold and guessed at what the provider states outright. A lost result is covered by the init staleness mark, and CLIs below the per-turn-init version floor (or with no reported version) keep today's opener path via an explicit gate on the init frame's claude_code_version. * fix(native-chat): read Claude fold membership from the cycle's own work A finished background task revises its row before the wake cycle's init, so the wake turn opened ahead of that init and was marked stale by it: a send folded into the wake still split the bar and interrupted the wake turn (captured p3-background-wake order). A send replayed after the current cycle's first root work (send echo or model output) is the fold; the cycle's first send is its opener. Init and every settle start a cycle with no work. * fix(native-chat): adopt the CLI version from any init frame, not only the startup proof Live sessions prove the session from a SessionStart hook frame that arrives BEFORE system/init, so startup facts read a frame with no claude_code_version and the fold receipt's version gate never passed: the real app still split the bar and marked the running turn interrupted while every fixture test — whose harness proves startup with the init frame itself — stayed green. The version is now adopted from whichever init frame carries it, at the same site that already adopts the per-turn model report, and the startup proof never clobbers a version a real init already reported. Pinned twice: a fixture run whose startup proof is the SessionStart hook frame, and a real-CLI fold test (skipped without a signed-in CLI) that fails on the unfixed branch at the interrupted assertion and passes with the fix. * test(native-chat): run the real-CLI fold test in the config dir it probed The connection strips an inherited CLAUDE_CONFIG_DIR and inherited auth vars, and with no launch env the pin compared against that same inherited value and emitted nothing, so the fold test always ran against ~/.claude whatever CLAUDE_CONFIG_DIR the availability probe checked. It also relied on the user's own SessionStart hooks for the live proof order and on their permission rules for the Bash steps; an isolated home hung at startup. The test now launches with the probed home and env auth, and pins a SessionStart hook and the sleep permission itself. * fix(native-chat): drop the CLI-version gate and prove the fold against full adapter captures The fold rule stands on frame-derived facts alone: an adopted client uuid (the capability check, read off the replay itself) while the open turn's cycle has done root work. The claude_code_version floor guarded only an unobserved triple fault — an adopting CLI without per-turn init AND a lost result — and its cost was a silent latch that already fired once live; all cliVersion plumbing is removed. Mid-turn auto-compaction was measured (forced via CLAUDE_CODE_AUTO_COMPACT_WINDOW): it emits status/compact_boundary and a synthetic continuation but NO root init, so the init cycle reset stays and the capture is pinned. The fake connection now defaults to the live startup shape (SessionStart hook proof; one init when the first command starts a cycle; capabilities on the initialize result), with init-at-startup an explicit unmeasured opt-in. Full frame streams recorded through Orca's own adapter against the real CLI are committed and replayed verbatim, asserting the provider's membership fact (each result's user_message_uuids) rather than design internals. * test(native-chat): drop the removed CLI-version gate from fold test comments Also reattaches the fake harness's initProof doc to initProof (it had landed on contextUsage, replacing that field's own doc) and renames a plural-result test whose title described a retired waiter it never creates. * test(native-chat): name the fold test's settings for their role * chore: take main's lockfile back after the merge * test(claude): route the real-CLI probe through the spawn chokepoint; give the slow-init test a live startup report The shared real-CLI availability probe moved out of a test file, so the child_process and CLI-runtime-pairing ratchets now scan it. It spawns through runProcessSync with the CLI paired to its own node, as the structured launch does. The provider-started test's CLI default now reaches startup the live way: get_settings reports it, since system/init only arrives with the first command. --- .../claude-adapter-capture-early-steer.jsonl | 68 ++ .../claude-adapter-capture-fold.jsonl | 65 ++ .../claude-adapter-capture-miss.jsonl | 85 +++ .../claude-adapter-capture-replay.test.ts | 233 +++++++ .../claude-can-use-tool-test-support.ts | 30 + ...captured-fold-steer-frames.test-fixture.ts | 275 ++++++++ ...-fold-steer-frame-builders.test-fixture.ts | 184 ++++++ .../claude/claude-fold-steer-receipt.test.ts | 622 ++++++++++++++++++ .../claude-journal-translator-contract.ts | 4 + src/main/claude/claude-open-turn.ts | 24 + ...aude-real-cli-availability-test-support.ts | 64 ++ .../claude/claude-replay-turn-resolution.ts | 221 +++++++ .../claude-structured-control-actions.test.ts | 1 + .../claude-structured-control-actions.ts | 6 +- ...aude-structured-dispatch-admission.test.ts | 8 +- .../claude-structured-dispatch-waiters.ts | 10 +- .../claude/claude-structured-dispatch.test.ts | 3 +- src/main/claude/claude-structured-dispatch.ts | 167 +---- ...claude-structured-effort-reporting.test.ts | 11 +- .../claude-structured-journal-translation.ts | 18 + ...aude-structured-model-confirmation.test.ts | 14 +- ...claude-structured-prompt-ownership.test.ts | 4 +- .../claude-structured-prompt-ownership.ts | 2 +- .../claude-structured-real-cli-fold.test.ts | 194 ++++++ .../claude/claude-structured-real-cli.test.ts | 40 +- .../claude-structured-session-acquisition.ts | 2 +- ...e-structured-session-adapter-turns.test.ts | 14 + .../claude-structured-session-adapter.test.ts | 10 +- ...claude-structured-session-recovery.test.ts | 2 +- .../claude/claude-structured-session-state.ts | 4 +- .../claude-structured-session-test-support.ts | 73 +- src/main/claude/claude-turn-ownership.test.ts | 2 + ...red-agent-session-provider-started.test.ts | 12 +- .../structured-mailbox-pointer-host.test.ts | 2 +- .../structured-session-pointer-delivery.ts | 3 +- ...structured-worker-group-addressing.test.ts | 2 +- .../structured-worker-group-addressing.ts | 4 +- 37 files changed, 2220 insertions(+), 263 deletions(-) create mode 100644 src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl create mode 100644 src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl create mode 100644 src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl create mode 100644 src/main/claude/claude-adapter-capture-replay.test.ts create mode 100644 src/main/claude/claude-can-use-tool-test-support.ts create mode 100644 src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts create mode 100644 src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts create mode 100644 src/main/claude/claude-fold-steer-receipt.test.ts create mode 100644 src/main/claude/claude-real-cli-availability-test-support.ts create mode 100644 src/main/claude/claude-replay-turn-resolution.ts create mode 100644 src/main/claude/claude-structured-real-cli-fold.test.ts diff --git a/src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl b/src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl new file mode 100644 index 00000000000..bff60192d74 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl @@ -0,0 +1,68 @@ +{"at": 0, "kind": "meta", "scenario": "early-steer", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded through Orca adapter vs Claude CLI 2.1.280; scrubbed: session id, home/tmp paths."} +{"at": 219, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "e13780eb-2f2b-45e5-a9fd-e7868ef6d9e2", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "308e42c2-3222-452b-bb5f-f8e41cf485a4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 229, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "e13780eb-2f2b-45e5-a9fd-e7868ef6d9e2", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "output": "\n", "stdout": "\n", "stderr": "", "exit_code": 0, "outcome": "success", "uuid": "8c37d862-0ba3-4e14-a746-68a29d137384", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 242, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE", "outcome": {"state": "admitted"}} +{"at": 244, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "052ca984-de90-4289-ac97-587d361f35dc", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 244, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "state": "queued", "uuid": "e32cf6db-6c39-49ad-87a6-ad8f392e00b1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 244, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "state": "started", "uuid": "0c7a74e6-8f5e-43c4-9cf0-af6e3fac6e0f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 259, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "ff6bb8ec-afcb-4bb8-88a8-d75625377ab9", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "9900a74e-78ff-48a6-b005-8b748a8f7a1d"}} +{"at": 492, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "text": "Also say the word banana at the end of your reply.", "outcome": {"state": "admitted"}} +{"at": 493, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "state": "queued", "uuid": "2244a4b7-a832-418a-b6e9-be4657618f60", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "timestamp": "2026-09-28T12:50:50.211Z", "isReplay": true}} +{"at": 1342, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEi9RgiS61CeGuiyaP", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5242, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5242, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "25b0f48f-8de3-4e58-a53f-76cedc10c4e8", "ttft_ms": 1075, "user_message_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "user_message_uuids": ["c64cdf14-1349-4be1-b0a4-5613890d57bd"]}} +{"at": 1342, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "54809bd1-bb5a-4fe7-9a04-1dc60e5a7163"}} +{"at": 1342, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "02a681bb-01c9-4dd3-b890-040e17bf4132"}} +{"at": 2307, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"com"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0a1147d0-c168-405a-a4e4-79ad5266d3c2"}} +{"at": 2307, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "mand\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "1eabfcb6-b0c4-403a-9804-c55b013391ae"}} +{"at": 2307, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "lee"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "20f34e40-6392-4e4a-9991-ad35ad6b1aaa"}} +{"at": 2309, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "p 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ec8ed822-0e8f-46c1-b7a0-c8ab4467baa9"}} +{"at": 2330, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"descri"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "87881476-c659-4e82-945d-be15eaa45501"}} +{"at": 2332, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ption\": \""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e2502f3a-3cca-4741-9df5-dade6f4e4abb"}} +{"at": 2332, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "Sl"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "dc28f0f9-22f5-47fc-a374-1369c6fda079"}} +{"at": 2335, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "eep f"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "85345f83-b80d-4142-aa66-66b8640dca3b"}} +{"at": 2340, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "or 8 "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5ce61d4a-abd9-47bd-9c7b-e01a6d566387"}} +{"at": 2341, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "seconds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "21de6d37-199b-44d7-bfaa-4af1471a7235"}} +{"at": 2343, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEi9RgiS61CeGuiyaP", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5242, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5242, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "3f3c0fff-9ca6-49f9-8b73-36e569af5a38", "timestamp": "2026-09-28T12:50:52.296Z", "request_id": "req_011CfVsEhBPEiAFbbAE18svX", "user_message_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "user_message_uuids": ["c64cdf14-1349-4be1-b0a4-5613890d57bd"]}} +{"at": 2347, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "fa196eb6-011b-4fa7-831d-4ffe2582a3c7"}} +{"at": 2375, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5242, "cache_read_input_tokens": 11514, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7b300bd2-eaa1-4cb7-a557-6908cbb6f2a6"}} +{"at": 2380, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c1573618-e6fb-4f22-8939-e7959440f18a"}} +{"at": 5482, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "bys65yjga", "tool_use_id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "6b741fb3-6edc-4c9e-a69a-81fc87ef1502", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10493, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "bys65yjga", "tool_use_id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "9b0edc57-cd0c-4f60-a72c-e359e1a7fabc", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10496, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01RL5fbYfvXaRva9PBe2C7Qn", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "2f7a468f-55f1-4434-8f68-70a077f74f6e", "timestamp": "2026-09-28T12:51:00.450Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 10499, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Also say the word banana at the end of your reply."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "timestamp": "2026-09-28T12:50:50.448Z", "isReplay": true}} +{"at": 10499, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "state": "started", "uuid": "aca666d1-c3b3-451e-81d7-5aadfa246b42", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10499, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ee6f2da9-2514-4086-8bc4-1a5c01e5207b"}} +{"at": 11452, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsFTNDFHDiwBZc8cKWk", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 217, "cache_read_input_tokens": 16756, "cache_creation": {"ephemeral_5m_input_tokens": 217, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "571285ce-eda5-44b5-818c-db549ee9aef9", "ttft_ms": 949}} +{"at": 11452, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6d6d5ecd-0889-4592-b514-d72c0a964cc9"}} +{"at": 11453, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "58339280-14b3-4f61-bd2b-726418d4b324"}} +{"at": 11853, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "37c5f88b-3ef0-471c-89b1-a8dc6f3f61ad"}} +{"at": 11866, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f31aa8f4-903d-4a7d-aeff-41c16f4b487d"}} +{"at": 11866, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b1b59a9c-cec2-4398-9e8e-e49f16ad712d"}} +{"at": 11882, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "25d130db-a0f9-43d2-b639-6a9064f1e00d"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e0b776a6-7cb3-4512-bacc-07fc84a1737e"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "54ec71a5-84ad-4f45-849f-cdbeae44d781"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep for "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "667d1090-ceca-4c88-9864-447d6c95784e"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "8 secon"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2047e8e3-feb9-484d-8a1a-c19f0afebd59"}} +{"at": 11883, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7d4f9da1-d974-4acf-84a5-d1f22068beec"}} +{"at": 11884, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsFTNDFHDiwBZc8cKWk", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 217, "cache_read_input_tokens": 16756, "cache_creation": {"ephemeral_5m_input_tokens": 217, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c4c5b57-e219-4c0d-a90f-26cbae8a33df", "timestamp": "2026-09-28T12:51:01.838Z", "request_id": "req_011CfVsFSwuBKQ87EU42vew5"}} +{"at": 11885, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "df9289e5-af2a-4b85-8dc8-47df67725383"}} +{"at": 11885, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 217, "cache_read_input_tokens": 16756, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7ac75599-4149-466f-ab0b-4d4ef74a9c86"}} +{"at": 11885, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ea092344-1e27-4b3f-9145-e388a268a4a1"}} +{"at": 14890, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "bu917pf4q", "tool_use_id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "408f8301-dbd6-43cb-8f09-7f7d5f4325da", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 19903, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "bu917pf4q", "tool_use_id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "55ee40ae-c83e-40de-ae7c-fb0659950b39", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 19906, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01HG7iwC2HgvoxndbuytmJCM", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ee783948-1a20-4797-b0ef-b191babd6a68", "timestamp": "2026-09-28T12:51:09.859Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 19908, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "dec2fc2c-f27f-44c2-bbda-01b112f803d6"}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGBuJTPMNVKzDFTw9k", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16973, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "14cb8df8-de6e-43e8-a0be-46c883c88501", "ttft_ms": 1627}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f346ec9e-fe33-4c2e-a611-b2da26f43f53"}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FIRST DONE ban"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6440a3c0-de10-4e9e-a898-7ef0ec79673c"}} +{"at": 21540, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ana"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7096cb35-c640-4698-b755-8aad289d6cef"}} +{"at": 21556, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGBuJTPMNVKzDFTw9k", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE banana"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16973, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "5c9408ee-fcd2-46e8-aca0-1e2e8563420c", "timestamp": "2026-09-28T12:51:11.495Z", "request_id": "req_011CfVsG9F4VXdbez6Yd6wis"}} +{"at": 21556, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "12aa2a40-1da6-45bc-bd9e-a1f999c144d2"}} +{"at": 21556, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16973, "output_tokens": 11, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "fee3cade-9b96-4f0e-8657-b23a6c6f83ca"}} +{"at": 21557, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b39aedff-1723-46d2-8fbf-d62b2fe8f2ac"}} +{"at": 21559, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "3a4cf7ae-5121-45d4-8c99-76625c847c4e", "state": "completed", "uuid": "5b6a0e7a-361d-42ae-983a-5ecc00d6ff8c", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21562, "kind": "frame", "frame": {"duration_api_ms": 6682, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.045744599999999996, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 5580, "cache_read_input_tokens": 45243, "output_tokens": 175, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 5580}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1219, "outputTokens": 196, "cacheReadInputTokens": 45243, "cacheCreationInputTokens": 5580, "webSearchRequests": 0, "costUSD": 0.045744599999999996, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 3, "subtype": "success", "api_error_status": null, "result": "FIRST DONE banana", "ttft_ms": 2096, "type": "result", "duration_ms": 21314, "uuid": "02543cfd-4942-47f9-82d7-0bd79a2ed93d", "ttft_stream_ms": 1096, "time_to_request_ms": 21, "first_content_frame_ms": 1096, "user_message_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "user_message_uuids": ["c64cdf14-1349-4be1-b0a4-5613890d57bd", "3a4cf7ae-5121-45d4-8c99-76625c847c4e"], "request_sent_wall_ms": 1790599850221, "queued_turn_count": 0, "result_index": 0}} +{"at": 21563, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c64cdf14-1349-4be1-b0a4-5613890d57bd", "state": "completed", "uuid": "ef4b1bcb-e9cf-42f0-a0f9-b25a59a56efe", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21563, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "fce05c2c-6c43-4c7d-a347-ec3e378e3afc", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 24574, "kind": "end", "results": 1} diff --git a/src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl b/src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl new file mode 100644 index 00000000000..5bf4b2f863c --- /dev/null +++ b/src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl @@ -0,0 +1,65 @@ +{"at": 1, "kind": "meta", "scenario": "fold", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded through Orca adapter vs Claude CLI 2.1.280; scrubbed: session id, home/tmp paths."} +{"at": 242, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "e8d2b9c8-d0d0-442f-9b86-580619257967", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "fedf8819-07f0-41f5-bc48-24a71fa9940d", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 254, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "e8d2b9c8-d0d0-442f-9b86-580619257967", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "output": "\n", "stdout": "\n", "stderr": "", "exit_code": 0, "outcome": "success", "uuid": "fed65947-fa85-4c2d-9bd6-1c01159922b4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 269, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE", "outcome": {"state": "admitted"}} +{"at": 272, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "c36c2f2e-5c24-426d-819b-ea5f89862dae", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 272, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "state": "queued", "uuid": "65530cf1-9a29-4668-9a9f-91616c315809", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 272, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "state": "started", "uuid": "69d4df37-8379-4fce-bbf7-2a3a6854fbf2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 287, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "facd6d73-8d9e-4886-a6ab-f0873938128e", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 289, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d34f89ba-f2ef-4cbf-99a5-7d4d85c8d02a"}} +{"at": 1527, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "timestamp": "2026-09-28T12:50:24.512Z", "isReplay": true}} +{"at": 1528, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsCqF1QKvUNMHjZNCWW", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16754, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 16754, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ac67ce6c-da49-4c1d-bf6a-70e2474ab35f", "ttft_ms": 1230, "user_message_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "user_message_uuids": ["c1a5941b-1bd4-4729-8289-5a25b22ff5b2"]}} +{"at": 1528, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9e8e16c5-d002-4034-b955-3a8a00523ba4"}} +{"at": 1528, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b21cf048-3dbe-4d71-9bb0-9c139be8fb12"}} +{"at": 2612, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2aa7968c-7244-47f1-8e0c-e660514037ee"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "588330dc-afca-4aa7-b90c-73df15fee7c0"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "75c0ecc9-4a50-4d6f-9731-3fed7e81d265"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a773fa58-791c-414f-94f1-a4a04534bdd7"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9de26ee7-6320-467a-b226-83ec143378d7"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e3871642-e9b2-4e35-890e-d8d3a7a81e1e"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep 8 se"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7c1891c7-d7b2-48df-a26e-a7265389c8c7"}} +{"at": 2613, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "conds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9d9be75c-c078-4bda-8a3f-dea3967d5a06"}} +{"at": 2615, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsCqF1QKvUNMHjZNCWW", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16754, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 16754, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "0788da30-2441-4b32-803e-9ff172725739", "timestamp": "2026-09-28T12:50:26.842Z", "request_id": "req_011CfVsCppDCm7ZivTRsAYEu", "user_message_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "user_message_uuids": ["c1a5941b-1bd4-4729-8289-5a25b22ff5b2"]}} +{"at": 2619, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "40dac8c9-408f-4079-8d63-57e0f3b43ca1"}} +{"at": 2620, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16754, "cache_read_input_tokens": 0, "output_tokens": 81, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6d1c0ce1-f7ad-463a-ab3d-f7745bce63f9"}} +{"at": 2621, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b79333a8-7abc-4a32-a31f-249b5fea5f10"}} +{"at": 3640, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "text": "Also say the word banana at the end of your reply.", "outcome": {"state": "admitted"}} +{"at": 3640, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "state": "queued", "uuid": "6395adb0-b318-4284-a298-7b3d89d6b7d7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 5732, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "beu0xkswp", "tool_use_id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "description": "Sleep 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "a35b1b34-5810-47dd-bc2c-a38d19077a74", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10740, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "beu0xkswp", "tool_use_id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "status": "completed", "output_file": "", "summary": "Sleep 8 seconds", "uuid": "3f757c35-2f6c-4b21-bdf4-f8b523809abb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10743, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01GoyahVsaJSkqYZzb3ci9KQ", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "abbb0dbf-e102-4c66-ba3c-7808d634f675", "timestamp": "2026-09-28T12:50:34.971Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 10744, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Also say the word banana at the end of your reply."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "timestamp": "2026-09-28T12:50:27.869Z", "isReplay": true}} +{"at": 10745, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "state": "started", "uuid": "30f94896-494d-4002-a1ce-680620e03d54", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10745, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "bf42e496-1f7f-4095-8d94-19390bd59e50"}} +{"at": 11584, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsDaaVReUJ6r3tdEQhr", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 216, "cache_read_input_tokens": 16754, "cache_creation": {"ephemeral_5m_input_tokens": 216, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "79159e21-d876-467f-abac-86bfe7bf2457", "ttft_ms": 836}} +{"at": 11584, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "469a075b-ff35-42c8-b20e-fe4fd3ccf250"}} +{"at": 11585, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "1a0aeaf4-6b9e-441e-86ac-eac3c32204c4"}} +{"at": 12071, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6bb65599-1e9a-40d6-a769-e2b119fee09a"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "04387767-2ce4-42ec-b869-11406432effc"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ccc9d7c3-19a3-4691-a104-ae6b9c1b4dfa"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "90f5078e-ff14-45a6-9377-257c4545bb5f"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8f04f554-0875-450a-bc0b-89cb339c864b"}} +{"at": 12072, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6610a44a-019f-4136-a214-e0fc8e06d67f"}} +{"at": 12073, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep 8 se"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5caedf82-9c9e-4e13-ac26-d869ce84e863"}} +{"at": 12073, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "conds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8151a90d-7bae-4805-b709-124001edee1d"}} +{"at": 12073, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsDaaVReUJ6r3tdEQhr", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 216, "cache_read_input_tokens": 16754, "cache_creation": {"ephemeral_5m_input_tokens": 216, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6aaa7086-2892-4c73-a6b6-caef6cf1a2f5", "timestamp": "2026-09-28T12:50:36.299Z", "request_id": "req_011CfVsDa8gykAqKH3jsZE4P"}} +{"at": 12074, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3a4aa6b1-647e-4228-94c5-decf6d567a2f"}} +{"at": 12074, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 216, "cache_read_input_tokens": 16754, "output_tokens": 81, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a09a8560-21b5-410c-9337-1abfb30caff1"}} +{"at": 12074, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "90522211-133a-4a50-8ae9-21bdef5590d1"}} +{"at": 15093, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "btowzyjp1", "tool_use_id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "description": "Sleep 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "c5aa424b-840a-43a2-9586-94feff2f1b97", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20107, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "btowzyjp1", "tool_use_id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "status": "completed", "output_file": "", "summary": "Sleep 8 seconds", "uuid": "9ad2b377-9339-4fe9-a2aa-ee9acdb41d08", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20109, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01MxJ1cYsSb1chARZ735hbRk", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e3b1fa1e-6a3d-4156-a1fe-2146aeff2b1e", "timestamp": "2026-09-28T12:50:44.337Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 20110, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "990bcabe-2776-4b87-9ba9-788d3fff81cf"}} +{"at": 21105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEGxMrQeN7Y4miT6gp", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 120, "cache_read_input_tokens": 16970, "cache_creation": {"ephemeral_5m_input_tokens": 120, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2d867ea9-894a-4dfa-a589-7a689402c6b2", "ttft_ms": 992}} +{"at": 21105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ed2beb08-68a1-4159-953c-7a0b85729f4d"}} +{"at": 21106, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FIRST DONE ban"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "581dc001-4ef8-4f1b-a741-0534f316d810"}} +{"at": 21106, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ana"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "99a38535-8448-4953-b98e-f0740fc8e13a"}} +{"at": 21117, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsEGxMrQeN7Y4miT6gp", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE banana"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 120, "cache_read_input_tokens": 16970, "cache_creation": {"ephemeral_5m_input_tokens": 120, "ephemeral_1h_input_tokens": 0}, "output_tokens": 11, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "64e7945b-f3a9-409b-a58d-923a9c4a1f6a", "timestamp": "2026-09-28T12:50:45.334Z", "request_id": "req_011CfVsEGYJNniXhLXGpV5vr"}} +{"at": 21117, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "68da798f-ddd8-4af5-b012-ce4a10811bda"}} +{"at": 21117, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 120, "cache_read_input_tokens": 16970, "output_tokens": 11, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "29e16ff5-ab3b-4406-843f-049ee48650cd"}} +{"at": 21117, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5b7d6bae-6827-48c9-8fab-e43e55949198"}} +{"at": 21119, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "7ee6ab25-eafb-4869-85fd-97d058045906", "state": "completed", "uuid": "0e958675-6553-46b7-9d98-5b667248666d", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21120, "kind": "frame", "frame": {"duration_api_ms": 6661, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.10095080000000002, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 17090, "cache_read_input_tokens": 33724, "output_tokens": 173, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 17090}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1219, "outputTokens": 194, "cacheReadInputTokens": 33724, "cacheCreationInputTokens": 17090, "webSearchRequests": 0, "costUSD": 0.10095080000000002, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 3, "subtype": "success", "api_error_status": null, "result": "FIRST DONE banana", "ttft_ms": 2340, "type": "result", "duration_ms": 20846, "uuid": "408f97b8-b880-47f8-b778-f94e4e15cd92", "ttft_stream_ms": 1253, "time_to_request_ms": 24, "first_content_frame_ms": 1253, "user_message_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "user_message_uuids": ["c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "7ee6ab25-eafb-4869-85fd-97d058045906"], "request_sent_wall_ms": 1790599824526, "queued_turn_count": 0, "result_index": 0}} +{"at": 21121, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "c1a5941b-1bd4-4729-8289-5a25b22ff5b2", "state": "completed", "uuid": "3b91d2ee-409f-45f5-a7be-aa7a45fb6ac5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21121, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "4de40f8a-4b6c-4333-b1e6-ff528d249af9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 24243, "kind": "end", "results": 1} diff --git a/src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl b/src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl new file mode 100644 index 00000000000..211f6e0eb8d --- /dev/null +++ b/src/main/claude/__fixtures__/claude-adapter-capture-miss.jsonl @@ -0,0 +1,85 @@ +{"at": 1, "kind": "meta", "scenario": "miss", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded through Orca adapter vs Claude CLI 2.1.280; scrubbed: session id, home/tmp paths."} +{"at": 228, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "06af4bab-bef3-426b-96b7-733316a3b251", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "9eb2ab97-dce5-430e-9db3-e7d9aa0e7f8d", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 239, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "06af4bab-bef3-426b-96b7-733316a3b251", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "output": "\n", "stdout": "\n", "stderr": "", "exit_code": 0, "outcome": "success", "uuid": "e2266c8c-8e91-4893-9806-1286769a12fd", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 255, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "712482eb-4c05-4f67-8888-a11843c2729f", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE", "outcome": {"state": "admitted"}} +{"at": 257, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "a6e277fb-d7fc-418a-88f2-2d893feea0d0", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 257, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "state": "queued", "uuid": "57d845c0-8945-4528-82a5-8fbe83381714", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 258, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "state": "started", "uuid": "a2a8821a-7b4a-4332-95da-765eb95c2745", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 273, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "ea0c12f1-7e48-4001-a577-5e4abb2e9c5f", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 275, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "85ed497a-5e78-4ac6-aa9c-2ebe37063403"}} +{"at": 1303, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "timestamp": "2026-09-28T12:51:16.373Z", "isReplay": true}} +{"at": 1303, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGeUUTEuaYMdT2HtPQ", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5244, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5244, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "61853b4b-5722-4b7b-94d8-15304f575d2c", "ttft_ms": 1022, "user_message_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "user_message_uuids": ["712482eb-4c05-4f67-8888-a11843c2729f"]}} +{"at": 1304, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "df0f12a2-35bb-424c-8f28-40e6fcc07960"}} +{"at": 1304, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "5bcd51ce-460b-4891-8978-332c44f66114"}} +{"at": 2559, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "9744edc1-59cc-4c22-959a-8bfba7abc2ef"}} +{"at": 2559, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "4b7645f6-f050-4271-91f0-544df1ff0931"}} +{"at": 2559, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "11acbd0f-fc8e-4642-8897-8815951dd4ee"}} +{"at": 2637, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "4d140c8b-47f3-44e4-aade-212f63e2b40a"}} +{"at": 2637, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a51d0b53-c118-4de5-a848-aa4bdf7d86c7"}} +{"at": 2649, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a128c776-0a6a-4923-952f-3633f4b91239"}} +{"at": 2649, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep for "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ddce72e4-cbcc-45f1-966d-dd873f59d687"}} +{"at": 2650, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "8 secon"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "44392f6b-e3ac-4caa-8abe-ee1b99ba6783"}} +{"at": 2650, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "a5bc170f-84ba-4ac7-b104-b36d95c69049"}} +{"at": 2652, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsGeUUTEuaYMdT2HtPQ", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5244, "cache_read_input_tokens": 11514, "cache_creation": {"ephemeral_5m_input_tokens": 5244, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "4a86f704-4cdc-4561-840d-dee6e913ebf9", "timestamp": "2026-09-28T12:51:18.753Z", "request_id": "req_011CfVsGe1gx1SqtHypyjG4P", "user_message_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "user_message_uuids": ["712482eb-4c05-4f67-8888-a11843c2729f"]}} +{"at": 2655, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f79e14ed-a3e2-4ebf-88bb-a45ed5ee6a78"}} +{"at": 2657, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 5244, "cache_read_input_tokens": 11514, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6b586bec-0544-4cdb-9fc5-fadae3c7d6ed"}} +{"at": 2657, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3c602652-b8a2-4b38-b54d-ecb1f99b1201"}} +{"at": 5768, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "bnnik2zr1", "tool_use_id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "5213fad9-0ff9-4f01-b934-7f7155ba396b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10782, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "bnnik2zr1", "tool_use_id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "f37669e5-c402-43ac-823f-1a7f9005a834", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10785, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_016sNyp5R1LwYkaBUj9UKWYs", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "12161028-12c4-470e-a82d-1f6f4ee64aba", "timestamp": "2026-09-28T12:51:26.887Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 10787, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e0454575-2f1d-47c0-b9f3-4ecadbaaa122"}} +{"at": 11596, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsHQJiVw1uJvCEPhMGa", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16758, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "834585ab-8f03-423d-ae43-825a12b9f45e", "ttft_ms": 805}} +{"at": 11597, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "name": "Bash", "input": {}, "caller": {"type": "direct"}}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "273975d6-06bf-4eff-87aa-dca1a6d39b5c"}} +{"at": 11597, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2c9121cf-a472-476e-9626-05d4577ff349"}} +{"at": 11950, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"comma"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c2cb4769-ee86-4eda-9af7-f75a89493101"}} +{"at": 11950, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "nd\": \"s"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8e70eae8-653c-40ad-8a12-f37068234009"}} +{"at": 11950, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "leep 8\""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0135507b-0b6e-41fe-bd1c-17becb10430f"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ", \"desc"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "94802121-d71b-4781-856c-f40f78498ebe"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ription"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ec03999d-3810-405e-b36a-58d8d0f4e4ac"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "\": \"Sle"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2183a548-e1b7-48c5-a4d8-58ab6ae4acc9"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ep for "}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d8ffd37e-3d9d-46a4-b6e1-d52cb96a334d"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "8 secon"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "2c51be8c-ef09-42eb-a07b-c9083d3e349c"}} +{"at": 12026, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "ds\"}"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e4957303-3d3f-47b4-aaed-0ec6210e1e81"}} +{"at": 12027, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsHQJiVw1uJvCEPhMGa", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "name": "Bash", "input": {"command": "sleep 8", "description": "Sleep for 8 seconds"}, "caller": {"type": "direct"}}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16758, "cache_creation": {"ephemeral_5m_input_tokens": 121, "ephemeral_1h_input_tokens": 0}, "output_tokens": 16, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "03e0a7fa-a83c-4688-9504-8b7fa5c9f6ff", "timestamp": "2026-09-28T12:51:28.129Z", "request_id": "req_011CfVsHPsQjwKL4424e8EfN"}} +{"at": 12028, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "56ad793f-dcd0-4f9d-9dcc-49c2054d18c7"}} +{"at": 12036, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 121, "cache_read_input_tokens": 16758, "output_tokens": 82, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "98e98f29-79a1-4db9-9cd4-c4fe810eca7e"}} +{"at": 12036, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3629fbc7-6420-45ae-88be-cd9b779e721a"}} +{"at": 15034, "kind": "frame", "frame": {"type": "system", "subtype": "task_started", "task_id": "buewgpxis", "tool_use_id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "description": "Sleep for 8 seconds", "is_backgrounded": false, "task_type": "local_bash", "uuid": "fa9ac561-8bf7-4035-9aa8-221e4cd91dfb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20045, "kind": "frame", "frame": {"type": "system", "subtype": "task_notification", "task_id": "buewgpxis", "tool_use_id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "status": "completed", "output_file": "", "summary": "Sleep for 8 seconds", "uuid": "230550ea-a7f7-4171-9080-901cfdd0d571", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 20047, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01BiKvSfSEAwNM1DwshGhsT6", "type": "tool_result", "content": "(Bash completed with no output)", "is_error": false}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "68545b22-4c1b-44df-a0ec-5876d2c19aee", "timestamp": "2026-09-28T12:51:36.149Z", "tool_use_result": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false, "noOutputExpected": false}}} +{"at": 20048, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e14bc8d3-a5bf-4d52-b841-9b5632247863"}} +{"at": 20123, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "text": "Also say the word banana at the end of your reply.", "outcome": {"state": "admitted"}} +{"at": 20124, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "state": "queued", "uuid": "87c94a31-75e5-400a-9551-ca24c36b9be5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21070, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJ6bNbeN2V5Wbst25o", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 17000, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 17000, "ephemeral_1h_input_tokens": 0}, "output_tokens": 9, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "02c04bc0-68be-4721-a24d-27e7fa4a93db", "ttft_ms": 1019}} +{"at": 21071, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "c90f97b4-2cd4-482d-af20-cd921f5915ef"}} +{"at": 21071, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FIRST DONE"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "059a395d-c0a0-4a06-b483-240c3aece7ff"}} +{"at": 21082, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJ6bNbeN2V5Wbst25o", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 17000, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 17000, "ephemeral_1h_input_tokens": 0}, "output_tokens": 9, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "fc532a8b-2d4d-4cb9-92a5-ea1c509a8019", "timestamp": "2026-09-28T12:51:37.173Z", "request_id": "req_011CfVsJ5eaEpxJgCRB1U4zr"}} +{"at": 21082, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "81e56773-385a-4016-a0ec-ce01d277b9d1"}} +{"at": 21082, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 17000, "cache_read_input_tokens": 0, "output_tokens": 9, "output_tokens_details": {"thinking_tokens": 0}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ea179db4-6487-4447-927e-ae75d31097a2"}} +{"at": 21082, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6de38f87-568d-4573-960c-a6143bc8ecda"}} +{"at": 21085, "kind": "frame", "frame": {"duration_api_ms": 6803, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.1262354, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 22365, "cache_read_input_tokens": 28272, "output_tokens": 173, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 22365}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1219, "outputTokens": 194, "cacheReadInputTokens": 28272, "cacheCreationInputTokens": 22365, "webSearchRequests": 0, "costUSD": 0.1262354, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 3, "subtype": "success", "api_error_status": null, "result": "FIRST DONE", "ttft_ms": 2391, "type": "result", "duration_ms": 20825, "uuid": "b82a7983-d7ea-496d-9cb7-735f1ffb3cda", "ttft_stream_ms": 1043, "time_to_request_ms": 22, "first_content_frame_ms": 1044, "user_message_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "user_message_uuids": ["712482eb-4c05-4f67-8888-a11843c2729f"], "request_sent_wall_ms": 1790599876383, "queued_turn_count": 0, "result_index": 0}} +{"at": 21085, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "712482eb-4c05-4f67-8888-a11843c2729f", "state": "completed", "uuid": "acb703de-6b64-4496-b552-74ddb042d768", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21085, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "state": "started", "uuid": "77edaf35-52e5-42f1-a84a-f86dc68c96a4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 21088, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/tmp/scrubbed-cwd", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": ["Task", "AskUserQuestion", "Bash", "CronCreate", "CronDelete", "CronList", "DesignSync", "Edit", "EnterPlanMode", "EnterWorktree", "ExitPlanMode", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "ToolSearch", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "design-consent", "design-revoke", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "design", "design-sync", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "telemetry", "path": "builtin", "source": "telemetry@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "f6af6108-710e-4208-8c3c-79b2a9fc14e4", "memory_paths": {"auto": "/Users/user/scrubbed"}, "messaging_socket_path": "/tmp/scrubbed.sock", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 21088, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "7ff5bb60-79be-49cb-bd26-950fa9584ffe"}} +{"at": 22022, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Also say the word banana at the end of your reply."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "timestamp": "2026-09-28T12:51:37.189Z", "isReplay": true}} +{"at": 22022, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_start", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJAVHoszB7NSfdEtk4", "type": "message", "role": "assistant", "content": [], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "cache_creation": {"ephemeral_5m_input_tokens": 51, "ephemeral_1h_input_tokens": 0}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "30e634ea-7725-4e57-bba9-5b6ae1f44c1b", "ttft_ms": 927, "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "user_message_uuids": ["6f0204a5-a21a-47a4-9517-034cc4467b52"]}} +{"at": 22023, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": "", "signature": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "8c52275c-f862-4c49-a1b5-4cb5dde934c8"}} +{"at": 22469, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "91410a95-652d-4e88-8661-19ef21e01a22", "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52"}} +{"at": 22469, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "", "estimated_tokens": 50}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "ea150b67-dad7-4436-84c5-82d73a3ee3ac"}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "", "estimated_tokens": null}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "dcf3a66c-0505-4b70-985f-d5d431de68c5"}} +{"at": 23097, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 192, "estimated_tokens_delta": 142, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "411efdb6-1b14-40d5-b126-6251292c2898", "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52"}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "CAQS9wUKEAgSGAI4AUIIdGhpbmtpbmcSDB/yUaKTcw1uKvgE2xoM//KByDA4fixole7KIjDLAtdDHZ4gV5AjYQGHGc1vQ+EwvB7yHUpWkhn/fsoZ0XTaERvUz6ZoVYhDH7TV/3EqlAW89rxYwkeD/V2vxPnz1iDOQvnSOBk08h5h6cd7CUNMyyM5UU/VtJ84BJDllyBTZLw8RCS/tuodap4P8NmO7I6wK6ZQ8d0hTfpyHsr7J4UfnD9RXhy2x5YdTX11OI8sQ7hxj9HkzhEJpJwRSytLRpclM3ATKwIv/ApihNhfYtbKApQSeIOlFArXz17Xm3RYMs3WjRzL8NMPmV0d5sirOCJ1xtAf3vLSi7awlvG45tpmkW3xaEZsVczOBSNhLPdzwEuHQrAZqx+5WE/b5cC9A2jfW3HgOSdmlmp1z0vYhdbvfu+ALKLUFmAz49QY8YINKNXLbZMUxY5IEDHrRUNjQIY2tJvJWi1SMNNaAvRh1dVBwRDdCXRkWt1Yt8U3iQ2g0dZnfeEGCrIETtzGnRZ5nY0ekGcVrU8QQCCiibceC0dSOmbpNOnDGjxpu3PdoBBoxjZB/Ys1I2td+KDsGwWi0KygWOH7xoa11fsLHvoBu+d0RaTCH64uX6s3bLkajeeCpX0nEt0PR0GT69Jst6J4JwiDTDjjK4LF87Sxm4tSnTzymEYXhk2M5EtdkOUmD5yeI4Qf0liMutVlLWISHzDsAj/t1i7dQtr76wHzrq0/1qdf4OJlFH0qLEM98ekpLOsESLLGryp0UIRIrMaJtpk8e/o9+uHgCP2IwxxEFaWb6PUvNvuI00K/C3dxTD9JQQrNZRdadsnj74/TR2qePDAXzb8vhDSm2b8umySGmHCPrTjBdC0O6riijl1pKqdjRS9yzFVInnZ00zUHpmT9ojVT3dKennDIfdN4ShxGTh5be81hF1pfm9GDC7HJGpG+Eth3qS8JTRQhDx43J4DKNA4XQYQ7CHIxOLET/s8e+qGLnlGCJl6K0h4YAQ=="}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "3b70c219-37e8-485d-a00c-48e3680cc14e"}} +{"at": 23097, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJAVHoszB7NSfdEtk4", "type": "message", "role": "assistant", "content": [{"type": "thinking", "thinking": "", "signature": "CAQS9wUKEAgSGAI4AUIIdGhpbmtpbmcSDB/yUaKTcw1uKvgE2xoM//KByDA4fixole7KIjDLAtdDHZ4gV5AjYQGHGc1vQ+EwvB7yHUpWkhn/fsoZ0XTaERvUz6ZoVYhDH7TV/3EqlAW89rxYwkeD/V2vxPnz1iDOQvnSOBk08h5h6cd7CUNMyyM5UU/VtJ84BJDllyBTZLw8RCS/tuodap4P8NmO7I6wK6ZQ8d0hTfpyHsr7J4UfnD9RXhy2x5YdTX11OI8sQ7hxj9HkzhEJpJwRSytLRpclM3ATKwIv/ApihNhfYtbKApQSeIOlFArXz17Xm3RYMs3WjRzL8NMPmV0d5sirOCJ1xtAf3vLSi7awlvG45tpmkW3xaEZsVczOBSNhLPdzwEuHQrAZqx+5WE/b5cC9A2jfW3HgOSdmlmp1z0vYhdbvfu+ALKLUFmAz49QY8YINKNXLbZMUxY5IEDHrRUNjQIY2tJvJWi1SMNNaAvRh1dVBwRDdCXRkWt1Yt8U3iQ2g0dZnfeEGCrIETtzGnRZ5nY0ekGcVrU8QQCCiibceC0dSOmbpNOnDGjxpu3PdoBBoxjZB/Ys1I2td+KDsGwWi0KygWOH7xoa11fsLHvoBu+d0RaTCH64uX6s3bLkajeeCpX0nEt0PR0GT69Jst6J4JwiDTDjjK4LF87Sxm4tSnTzymEYXhk2M5EtdkOUmD5yeI4Qf0liMutVlLWISHzDsAj/t1i7dQtr76wHzrq0/1qdf4OJlFH0qLEM98ekpLOsESLLGryp0UIRIrMaJtpk8e/o9+uHgCP2IwxxEFaWb6PUvNvuI00K/C3dxTD9JQQrNZRdadsnj74/TR2qePDAXzb8vhDSm2b8umySGmHCPrTjBdC0O6riijl1pKqdjRS9yzFVInnZ00zUHpmT9ojVT3dKennDIfdN4ShxGTh5be81hF1pfm9GDC7HJGpG+Eth3qS8JTRQhDx43J4DKNA4XQYQ7CHIxOLET/s8e+qGLnlGCJl6K0h4YAQ=="}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "cache_creation": {"ephemeral_5m_input_tokens": 51, "ephemeral_1h_input_tokens": 0}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f59a0f60-bc42-4d2f-be93-2d89991bf482", "timestamp": "2026-09-28T12:51:39.200Z", "request_id": "req_011CfVsJA4Uy2C2Rx8uyY7H1", "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "user_message_uuids": ["6f0204a5-a21a-47a4-9517-034cc4467b52"]}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0d8eff25-307b-4247-81e1-9a4575c7e07d"}} +{"at": 23097, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "7328034f-41e4-4823-9bde-8df483de1ce5"}} +{"at": 23098, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "FIRST DONE ban"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "e8f3de55-f311-4eac-baab-9db3fe5ec197"}} +{"at": 23098, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "ana"}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b293546a-d0c5-4cdd-9884-5b155b845e96"}} +{"at": 23098, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_011CfVsJAVHoszB7NSfdEtk4", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "FIRST DONE banana"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "cache_creation": {"ephemeral_5m_input_tokens": 51, "ephemeral_1h_input_tokens": 0}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "31583f02-16b9-418d-89b3-269afb1dc2c0", "timestamp": "2026-09-28T12:51:39.201Z", "request_id": "req_011CfVsJA4Uy2C2Rx8uyY7H1"}} +{"at": 23098, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "content_block_stop", "index": 1}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f8047b07-9ca2-436f-a8e2-f05a17f14970"}} +{"at": 23105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "stop_details": null, "container": null}, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "output_tokens": 65, "output_tokens_details": {"thinking_tokens": 54}}, "context_management": {"applied_edits": []}}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "372ebe0e-8f36-4567-8563-4aff3e059a3f"}} +{"at": 23105, "kind": "frame", "frame": {"type": "stream_event", "event": {"type": "message_stop"}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "b11714ab-fe3d-44fb-991d-c62ae8a60507"}} +{"at": 23107, "kind": "frame", "frame": {"duration_api_ms": 8819, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.1311984, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 51, "cache_read_input_tokens": 17000, "output_tokens": 65, "output_tokens_details": {"thinking_tokens": 54}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 51}, "inference_geo": "not_available", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1221, "outputTokens": 259, "cacheReadInputTokens": 45272, "cacheCreationInputTokens": 22416, "webSearchRequests": 0, "costUSD": 0.1311984, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 54, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "FIRST DONE banana", "ttft_ms": 2012, "type": "result", "duration_ms": 2021, "uuid": "572df995-0b87-436c-9966-5f3740c22820", "ttft_stream_ms": 937, "time_to_request_ms": 10, "first_content_frame_ms": 937, "user_message_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "user_message_uuids": ["6f0204a5-a21a-47a4-9517-034cc4467b52"], "request_sent_wall_ms": 1790599897198, "queued_turn_count": 0, "result_index": 1}} +{"at": 23107, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "6f0204a5-a21a-47a4-9517-034cc4467b52", "state": "completed", "uuid": "bd6b5dbd-002f-4d5e-8311-7d747745ea00", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 23108, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "c7642956-8764-404f-8b89-8eac3a91cb1e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 26162, "kind": "end", "results": 2} diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts new file mode 100644 index 00000000000..8e877a135a8 --- /dev/null +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -0,0 +1,233 @@ +// Verbatim replay of FULL captures recorded through Orca's own adapter against +// the real CLI (`__fixtures__/claude-adapter-capture-*.jsonl`): every frame the +// adapter saw — hook proof, per-cycle init, command_lifecycle, session-state, +// stream events — in the recorded order, with the recorded dispatch points. +// The oracle is the provider's own membership fact: each result's +// `user_message_uuids` must equal the sends that settled into the turn that +// result closed. Nothing here asserts design internals, so a rule change or a +// CLI drift that breaks membership fails these tests whatever the mechanism. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { + ClaudeLateDispatchOutcome, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +type CapturedEvent = + | { at: number; kind: 'meta'; providerSessionId: string } + | { at: number; kind: 'frame'; frame: Record } + | { at: number; kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } + | { at: number; kind: 'end' } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeCapturedEvent(value: unknown): CapturedEvent { + if (!isRecord(value) || typeof value.at !== 'number' || typeof value.kind !== 'string') { + throw new Error('capture line is not a recorded event') + } + if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { + return { at: value.at, kind: 'meta', providerSessionId: value.providerSessionId } + } + if (value.kind === 'frame' && isRecord(value.frame)) { + return { at: value.at, kind: 'frame', frame: value.frame } + } + if ( + value.kind === 'dispatch' && + typeof value.clientMessageId === 'string' && + typeof value.sentUuid === 'string' && + typeof value.text === 'string' + ) { + return { + at: value.at, + kind: 'dispatch', + clientMessageId: value.clientMessageId, + sentUuid: value.sentUuid, + text: value.text + } + } + if (value.kind === 'end') { + return { at: value.at, kind: 'end' } + } + throw new Error(`capture line has unknown kind: ${String(value.kind)}`) +} + +function loadCapture(name: string): CapturedEvent[] { + const path = join(__dirname, '__fixtures__', `claude-adapter-capture-${name}.jsonl`) + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => decodeCapturedEvent(JSON.parse(line))) +} + +type Replay = { + settled: Mock + /** Final revision per turn id, in first-open order. */ + finalTurns: Map>> + everInterrupted: boolean + /** Each result frame's `user_message_uuids`, mapped to live dispatch uuids. */ + resultMemberships: string[][] + /** clientMessageId -> provider uuid its delivery settled under. */ + settledUuids: Map + /** Live dispatch uuid -> turn id its send landed in (turn open at settlement). */ + liveUuidByClient: Map +} + +async function replayCapture(name: string): Promise { + const capture = loadCapture(name) + let nowMs = 1_700_000_200_000 + const finalTurns = new Map>>() + let everInterrupted = false + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + finalTurns.set(turn.turnId, turn) + everInterrupted ||= turn.state === 'interrupted' + } + }, + appendTombstone: () => {}, + publish: () => {} + } + const settled = vi.fn<(input: { sessionId: string } & ClaudeLateDispatchOutcome) => void>() + // The capture supplies every frame, startup proof included. + const claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const deps: ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1, + now: () => nowMs, + persistHandle: async () => {}, + onDispatchSettledLate: settled + } + const adapter = new ClaudeStructuredSessionAdapter(deps) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + + // Captured uuids -> the uuids the live dispatches mint during this replay. + const uuidMap = new Map() + const capturedSessionId = capture.flatMap((event) => + event.kind === 'meta' ? [event.providerSessionId] : [] + )[0] + const mapFrame = (frame: Record): Record => { + let text = JSON.stringify(frame) + for (const [captured, live] of uuidMap) { + text = text.replaceAll(captured, live) + } + if (capturedSessionId) { + text = text.replaceAll(capturedSessionId, PROVIDER_SESSION_ID) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + const resultMemberships: string[][] = [] + let startedAwaited = false + for (const event of capture) { + nowMs = 1_700_000_200_000 + event.at + if (event.kind === 'dispatch') { + if (!startedAwaited) { + // The proof frames have been delivered by now; startup can settle. + await adapter.awaitStarted('session-1') + startedAwaited = true + } + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: event.text }] + } + const before = new Set(uuidMap.values()) + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: event.clientMessageId, + body, + requestedAt: nowMs, + fence: 7 + }) + expect(outcome).toEqual({ state: 'admitted' }) + const sent = claude.connections[0]!.sent.at(-1)?.uuid + if (typeof sent !== 'string' || before.has(sent)) { + throw new Error('replay could not read the dispatched uuid') + } + uuidMap.set(event.sentUuid, sent) + } else if (event.kind === 'frame') { + const frame = mapFrame(event.frame) + if (frame.type === 'result') { + const members = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids : [] + resultMemberships.push(members.filter((m): m is string => typeof m === 'string')) + } + claude.connections[0]!.handlers.onMessage?.(frame) + } + } + const settledUuids = new Map() + for (const [outcome] of settled.mock.calls) { + if ('providerIdentity' in outcome && outcome.providerIdentity.provider === 'claude') { + settledUuids.set(outcome.clientMessageId, outcome.providerIdentity.uuid) + } + } + const liveUuidByClient = new Map() + for (const event of capture) { + if (event.kind === 'dispatch') { + liveUuidByClient.set(event.clientMessageId, uuidMap.get(event.sentUuid)!) + } + } + return { settled, finalTurns, everInterrupted, resultMemberships, settledUuids, liveUuidByClient } +} + +describe.each(['fold', 'early-steer', 'miss'])('adapter capture replay: %s', (name) => { + it('keeps turn membership equal to each result’s user_message_uuids', async () => { + const replay = await replayCapture(name) + + // One turn per result, each opened by that result's first member, and no + // turn ever marked interrupted (no capture interrupts one). + expect([...replay.finalTurns.keys()]).toEqual( + replay.resultMemberships.map((members) => members[0]) + ) + expect(replay.everInterrupted).toBe(false) + for (const turn of replay.finalTurns.values()) { + expect(turn.state).toBe('completed') + } + + // Every send settled accepted under a provider uuid named by exactly the + // result whose turn it belongs to — the provider's membership fact. + const allMembers = new Set(replay.resultMemberships.flat()) + for (const [clientMessageId, liveUuid] of replay.liveUuidByClient) { + expect(replay.settledUuids.get(clientMessageId)).toBe(liveUuid) + expect(allMembers.has(liveUuid)).toBe(true) + } + for (const [index, members] of replay.resultMemberships.entries()) { + for (const member of members) { + // A member of result i must not have opened any OTHER turn. + const owner = [...replay.finalTurns.keys()].indexOf(member) + if (owner !== -1) { + expect(owner).toBe(index) + } + } + } + }) +}) diff --git a/src/main/claude/claude-can-use-tool-test-support.ts b/src/main/claude/claude-can-use-tool-test-support.ts new file mode 100644 index 00000000000..cbf064a0933 --- /dev/null +++ b/src/main/claude/claude-can-use-tool-test-support.ts @@ -0,0 +1,30 @@ +// Invoking the fake connection's canUseTool permission callback, as the CLI would. + +import type { ClaudeStreamJsonConnectionHandlers } from './claude-stream-json-connection' +import type { FakeConnection } from './claude-structured-session-test-support' + +export function invokeCanUseTool( + connection: FakeConnection, + toolName: string, + requestId: string, + toolUseID: string, + extra: { + input?: Record + suggestions?: unknown[] + signal?: AbortSignal + } = {} +): { promise: Promise; settled: () => boolean } { + const options = { + requestId, + toolUseID, + signal: extra.signal ?? new AbortController().signal, + ...(extra.suggestions ? { suggestions: extra.suggestions } : {}) + } as unknown as Parameters>[2] + let done = false + const promise = Promise.resolve( + connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options) + ).finally(() => { + done = true + }) + return { promise, settled: () => done } +} diff --git a/src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts b/src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts new file mode 100644 index 00000000000..8213b4bfa45 --- /dev/null +++ b/src/main/claude/claude-captured-fold-steer-frames.test-fixture.ts @@ -0,0 +1,275 @@ +// Claude CLI 2.1.280 stream-json captures of sends made while a turn is running +// (`--replay-user-messages`), cut to the frames and fields the dispatch/turn path +// reads. Ids, paths and prompts are replaced; the frame order and the relative +// clock (`at`, ms after the first send) are the captured ones. +// +// The captured shape under test: a mid-turn send the CLI folds into the running +// turn is replayed as a root user frame (`isReplay: true`, the client uuid) while +// that turn is still open, and the turn's ONE result names every folded send in +// `user_message_uuids`. A send the CLI runs later (miss) is replayed only when its +// own turn starts, after the first result. A cancelled mid-turn send is never +// replayed at all. + +import { + assistant, + assistantText, + assistantToolUse, + initFrame, + resultFrame, + sessionIdle, + taskFrame, + toolResult, + userReplay, + type CapturedFoldFrame, + type FoldCaptureIds +} from './claude-fold-steer-frame-builders.test-fixture' + +export type { CapturedFoldFrame, FoldCaptureIds } + +export const FIRST_PROMPT = 'Run the sleep command three times, then reply: FIRST DONE' +export const STEER_PROMPT = 'Also say the word banana at the end of your reply.' +export const SECOND_STEER_PROMPT = 'And also say the word mango at the very end.' + +/** p2-fold-fresh: first send at 12, steer at 3878; one result names both sends. */ +export const FOLD_FRESH_SEND_AT = { first: 12, steer: 3_878 } +export function foldFreshCapture({ sessionId, first, steer }: FoldCaptureIds): CapturedFoldFrame[] { + return [ + initFrame(222, sessionId), + userReplay(2_094, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_676, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(7_890, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(7_892, sessionId, steer, STEER_PROMPT), + assistantToolUse(9_725, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(14_740, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantText(23_039, sessionId, 'reply-text-1', 'FIRST DONE banana'), + resultFrame(23_048, sessionId, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 22_845, + numTurns: 4 + }), + sessionIdle(23_049, sessionId) + ] +} + +/** p2-fold-resumed: the same fold on a `--resume` session; steer at 4458. */ +export const FOLD_RESUMED_SEND_AT = { first: 12, steer: 4_458 } +export function foldResumedCapture({ + sessionId, + first, + steer +}: FoldCaptureIds): CapturedFoldFrame[] { + return [ + initFrame(218, sessionId), + userReplay(2_057, sessionId, first, FIRST_PROMPT), + assistant( + 3_229, + sessionId, + 'reply-thinking-1', + [{ type: 'thinking', thinking: 'plan' }], + [first] + ), + assistantToolUse(3_258, sessionId, 'reply-tool-1', 'toolu_sleep_1'), + toolResult(8_464, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(8_467, sessionId, steer, STEER_PROMPT), + assistantToolUse(10_318, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(15_373, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantText(23_587, sessionId, 'reply-text-1', 'FIRST DONE banana'), + resultFrame(23_628, sessionId, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 23_367, + numTurns: 4 + }), + sessionIdle(23_640, sessionId) + ] +} + +/** p2-two-steers: steers at 3630 and 5031, both replayed back to back mid-turn. */ +export const TWO_STEERS_SEND_AT = { first: 31, steer: 3_630, secondSteer: 5_031 } +export function twoSteersCapture({ + sessionId, + first, + steer, + secondSteer +}: Required): CapturedFoldFrame[] { + return [ + initFrame(351, sessionId), + userReplay(1_169, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_412, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(7_949, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(7_963, sessionId, steer, STEER_PROMPT), + userReplay(7_967, sessionId, secondSteer, SECOND_STEER_PROMPT), + assistantToolUse(9_806, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(14_835, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantText(22_210, sessionId, 'reply-text-1', 'FIRST DONE banana mango'), + resultFrame(22_317, sessionId, 'result-1', { + userMessageUuids: [first, steer, secondSteer], + durationMs: 21_859, + numTurns: 4 + }), + sessionIdle(22_341, sessionId) + ] +} + +/** p2-miss: the steer lands too late to fold — its replay trails the first + * result, and it runs as its own turn with its own result. */ +export const MISS_SEND_AT = { first: 30, steer: 23_384 } +export function missCapture({ sessionId, first, steer }: FoldCaptureIds): { + beforeSteerSend: CapturedFoldFrame[] + afterSteerSend: CapturedFoldFrame[] +} { + return { + beforeSteerSend: [ + initFrame(333, sessionId), + userReplay(2_118, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_793, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(8_258, sessionId, 'tool-result-1', 'toolu_sleep_1'), + assistantToolUse(11_019, sessionId, 'reply-tool-2', 'toolu_sleep_2'), + toolResult(15_961, sessionId, 'tool-result-2', 'toolu_sleep_2'), + assistantToolUse(18_362, sessionId, 'reply-tool-3', 'toolu_sleep_3'), + toolResult(23_375, sessionId, 'tool-result-3', 'toolu_sleep_3') + ], + afterSteerSend: [ + assistantText(25_550, sessionId, 'reply-text-1', 'FIRST DONE'), + resultFrame(25_556, sessionId, 'result-1', { + userMessageUuids: [first], + durationMs: 25_241, + numTurns: 4 + }), + initFrame(25_584, sessionId), + userReplay(28_703, sessionId, steer, STEER_PROMPT), + assistantText(29_239, sessionId, 'reply-text-2', 'banana'), + resultFrame(29_275, sessionId, 'result-2', { + userMessageUuids: [steer], + durationMs: 3_711, + numTurns: 1 + }), + sessionIdle(29_306, sessionId) + ] + } +} + +/** p2-cancel: the steer is cancelled while queued — no replay ever arrives, the + * interrupt injects a synthetic user text with NO `isReplay`, and the error + * result names only the first send. */ +export const CANCEL_SEND_AT = { first: 18, steer: 3_578 } +export function cancelCapture({ sessionId, first, steer: _steer }: FoldCaptureIds): { + beforeSteerSend: CapturedFoldFrame[] + afterInterrupt: CapturedFoldFrame[] +} { + return { + beforeSteerSend: [ + initFrame(354, sessionId), + userReplay(1_329, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_366, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]) + ], + afterInterrupt: [ + toolResult( + 3_889, + sessionId, + 'tool-result-1', + 'toolu_sleep_1', + "The user doesn't want to proceed with this tool use.", + true + ), + { + at: 3_900, + frame: { + type: 'user', + session_id: sessionId, + parent_tool_use_id: null, + uuid: 'interrupt-notice-1', + message: { + role: 'user', + content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] + } + } + }, + resultFrame(3_926, sessionId, 'result-1', { + userMessageUuids: [first], + durationMs: 3_573, + numTurns: 3, + subtype: 'error_during_execution', + isError: true, + terminalReason: 'aborted_tools' + }), + sessionIdle(3_941, sessionId) + ] + } +} + +/** p3-early-steer: the steer is written BEFORE the first send's replay arrives + * (send at 312, first replay at 1208) and the CLI still folds it — one init, + * one turn, one result naming both sends. */ +export const EARLY_STEER_SEND_AT = { first: 11, steer: 312 } +export function earlySteerCapture({ + sessionId, + first, + steer +}: FoldCaptureIds): CapturedFoldFrame[] { + return [ + initFrame(205, sessionId), + userReplay(1_208, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_190, sessionId, 'reply-tool-1', 'toolu_sleep_1', [first]), + toolResult(6_316, sessionId, 'tool-result-1', 'toolu_sleep_1'), + userReplay(6_318, sessionId, steer, STEER_PROMPT), + assistantText(7_336, sessionId, 'reply-text-1', 'FIRST DONE banana'), + resultFrame(7_340, sessionId, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 7_155, + numTurns: 2 + }), + sessionIdle(7_341, sessionId) + ] +} + +/** p3-background-wake: after the turn's result, the finished background task + * wakes the CLI — a NEW cycle: its own init, output with no user replay, and a + * result that names no send at all (`user_message_uuids` absent). The task's + * completion frames arrive BEFORE the wake's init. */ +export function backgroundWakeCapture({ sessionId, first }: FoldCaptureIds): { + firstTurn: CapturedFoldFrame[] + wake: CapturedFoldFrame[] +} { + return { + firstTurn: [ + initFrame(252, sessionId), + userReplay(1_193, sessionId, first, FIRST_PROMPT), + assistantToolUse(2_372, sessionId, 'reply-tool-1', 'toolu_bg_1', [first]), + taskFrame(2_530, sessionId, 'task_started', { + task_id: 'task_bg_1', + tool_use_id: 'toolu_bg_1', + description: 'Sleep then print marker', + is_backgrounded: true, + task_type: 'local_bash' + }), + toolResult(2_532, sessionId, 'tool-result-1', 'toolu_bg_1'), + assistantText(3_403, sessionId, 'reply-text-1', 'STARTED'), + resultFrame(3_406, sessionId, 'result-1', { + userMessageUuids: [first], + durationMs: 3_177, + numTurns: 2 + }), + sessionIdle(3_407, sessionId) + ], + wake: [ + taskFrame(17_547, sessionId, 'task_updated', { + task_id: 'task_bg_1', + patch: { status: 'completed' } + }), + taskFrame(17_547, sessionId, 'task_notification', { + task_id: 'task_bg_1', + tool_use_id: 'toolu_bg_1', + status: 'completed', + summary: 'Background command completed (exit code 0)' + }), + initFrame(17_626, sessionId), + assistantText(19_721, sessionId, 'wake-text-1', 'The background command finished.'), + resultFrame(19_729, sessionId, 'result-2', { + userMessageUuids: [], + durationMs: 2_105, + numTurns: 1 + }), + sessionIdle(19_730, sessionId) + ] + } +} diff --git a/src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts b/src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts new file mode 100644 index 00000000000..efb5781bad8 --- /dev/null +++ b/src/main/claude/claude-fold-steer-frame-builders.test-fixture.ts @@ -0,0 +1,184 @@ +// The frame shapes the fold-steer captures are rebuilt from, keeping only the +// fields the dispatch/turn path reads. + +export type CapturedFoldFrame = { at: number; frame: Record } + +export type FoldCaptureIds = { + /** The provider session every frame names. */ + sessionId: string + /** Client uuid of the turn-opening send; the CLI adopts it on the replay. */ + first: string + /** Client uuid of the mid-turn send. */ + steer: string + /** Client uuid of the second mid-turn send (two-steers only). */ + secondSteer?: string +} + +export function userReplay( + at: number, + sessionId: string, + uuid: string, + text: string +): CapturedFoldFrame { + return { + at, + frame: { + type: 'user', + session_id: sessionId, + parent_tool_use_id: null, + uuid, + isReplay: true, + message: { role: 'user', content: [{ type: 'text', text }] } + } + } +} + +export function assistant( + at: number, + sessionId: string, + uuid: string, + content: unknown[], + userMessageUuids?: string[] +): CapturedFoldFrame { + return { + at, + frame: { + type: 'assistant', + session_id: sessionId, + parent_tool_use_id: null, + uuid, + message: { id: `msg-${uuid}`, role: 'assistant', content }, + // Only the reply that directly answers a send carries the correlation. + ...(userMessageUuids && userMessageUuids.length > 0 + ? { user_message_uuid: userMessageUuids[0], user_message_uuids: userMessageUuids } + : {}) + } + } +} + +export function assistantToolUse( + at: number, + sessionId: string, + uuid: string, + toolUseId: string, + userMessageUuids?: string[] +): CapturedFoldFrame { + return assistant( + at, + sessionId, + uuid, + [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 5' } }], + userMessageUuids + ) +} + +export function assistantText( + at: number, + sessionId: string, + uuid: string, + text: string, + userMessageUuids?: string[] +): CapturedFoldFrame { + return assistant(at, sessionId, uuid, [{ type: 'text', text }], userMessageUuids) +} + +export function toolResult( + at: number, + sessionId: string, + uuid: string, + toolUseId: string, + content = '(Bash completed with no output)', + isError = false +): CapturedFoldFrame { + return { + at, + frame: { + type: 'user', + session_id: sessionId, + parent_tool_use_id: null, + uuid, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }] + } + } + } +} + +export function resultFrame( + at: number, + sessionId: string, + uuid: string, + fields: { + userMessageUuids: string[] + durationMs: number + numTurns: number + subtype?: string + isError?: boolean + terminalReason?: string + } +): CapturedFoldFrame { + return { + at, + frame: { + type: 'result', + subtype: fields.subtype ?? 'success', + session_id: sessionId, + uuid, + is_error: fields.isError ?? false, + terminal_reason: fields.terminalReason ?? 'completed', + duration_ms: fields.durationMs, + num_turns: fields.numTurns, + result: 'FIRST DONE', + ...(fields.userMessageUuids.length > 0 + ? { + user_message_uuid: fields.userMessageUuids[0], + user_message_uuids: fields.userMessageUuids + } + : {}) + } + } +} + +export function sessionIdle(at: number, sessionId: string): CapturedFoldFrame { + return { + at, + frame: { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + session_id: sessionId, + uuid: `ssc-idle-${at}` + } + } +} + +/** Root `system/init`: the CLI starting a request cycle. Emitted at startup and + * again for every later cycle — sequential turn, queued turn, background wake, + * /compact (p3 captures). */ +export function initFrame(at: number, sessionId: string): CapturedFoldFrame { + return { + at, + frame: { + type: 'system', + subtype: 'init', + session_id: sessionId, + uuid: `init-${at}`, + model: 'claude-sonnet-5', + apiKeySource: 'none' + } + } +} + +/** A root background-task system frame (`task_started`, `task_updated`, ...). */ +export function taskFrame( + at: number, + sessionId: string, + subtype: string, + fields: Record +): CapturedFoldFrame { + return { + at, + frame: { type: 'system', subtype, session_id: sessionId, uuid: `${subtype}-${at}`, ...fields } + } +} diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts new file mode 100644 index 00000000000..4726dc55d82 --- /dev/null +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -0,0 +1,622 @@ +// A send the CLI folds into the running request cycle: its adopted replay is a +// delivery receipt, never a new turn boundary. The provider's own cycle state +// decides — a root init announces each new cycle, and a result's +// `user_message_uuids` names every send the cycle ran. The measured miss, a +// lost result followed by a new cycle, and fresh replay uuids all keep the +// replay-driven opener path. Captured orders +// from Claude CLI 2.1.280 (`claude-captured-fold-steer-frames.test-fixture.ts`). + +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' +import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' +import { + assistantText, + initFrame, + resultFrame, + userReplay, + type CapturedFoldFrame +} from './claude-fold-steer-frame-builders.test-fixture' +import { + backgroundWakeCapture, + cancelCapture, + CANCEL_SEND_AT, + earlySteerCapture, + EARLY_STEER_SEND_AT, + FIRST_PROMPT, + foldFreshCapture, + FOLD_FRESH_SEND_AT, + foldResumedCapture, + FOLD_RESUMED_SEND_AT, + missCapture, + MISS_SEND_AT, + SECOND_STEER_PROMPT, + STEER_PROMPT, + twoSteersCapture, + TWO_STEERS_SEND_AT +} from './claude-captured-fold-steer-frames.test-fixture' + +const T0 = 1_700_000_100_000 + +type Rig = { + adapter: ClaudeStructuredSessionAdapter + claude: ReturnType + events: ClaudeStructuredSessionEvent[] + settled: Mock + /** Every revision of every turn lifecycle row, in append order. */ + turns: () => NonNullable>[] + deliver: (captured: CapturedFoldFrame) => void + /** Dispatches at the captured send offset and returns the client uuid the CLI adopts. */ + dispatchAt: (at: number, clientMessageId: string, text: string) => Promise +} + +async function riggedAdapter( + launch: Partial = {}, + claudeOptions: Parameters[0] = {} +): Promise { + let nowMs = T0 + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: () => {}, + publish: () => {} + } + const events: ClaudeStructuredSessionEvent[] = [] + const settled = vi.fn() + const claude = fakeClaude({ replayUuid: null, ...claudeOptions }) + const deps: ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => T0 - 1_000, + now: () => nowMs, + persistHandle: async () => {}, + onDispatchSettledLate: settled + } + const adapter = new ClaudeStructuredSessionAdapter(deps) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + await adapter.awaitStarted('session-1') + return { + adapter, + claude, + events, + settled, + turns: () => + appended.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [turn] : [] + }), + deliver: (captured) => { + nowMs = T0 + captured.at + claude.connections[0]!.handlers.onMessage?.(captured.frame) + }, + dispatchAt: async (at, clientMessageId, text) => { + nowMs = T0 + at + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + } + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body, + requestedAt: nowMs, + fence: 7 + }) + expect(outcome).toEqual({ state: 'admitted' }) + const sentUuid = claude.connections[0]!.sent.at(-1)?.uuid + if (typeof sentUuid !== 'string') { + throw new Error('the fake connection recorded no sent uuid') + } + return sentUuid + } + } +} + +function replayEventFor(events: ClaudeStructuredSessionEvent[], uuid: string) { + return events.find( + (event) => + event.type === 'message' && event.message.type === 'user' && event.message.uuid === uuid + ) +} + +describe('Claude fold receipt for a mid-turn send (captured orders)', () => { + it('fold-fresh: the folded steer settles as a receipt inside the one turn it was sent during', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(FOLD_FRESH_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + foldFreshCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(FOLD_FRESH_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + + // ONE turn record for the whole run, never marked interrupted. + const turnIds = [...new Set(rig.turns().map((turn) => turn.turnId))] + expect(turnIds).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + // The settled duration is the single turn's, spanning both sends' work. + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'completed', + outcome: 'success', + startedAt: T0 + 2_094, + completedAt: T0 + 23_048, + durationMs: 22_845 + }) + // The steer's replay is a receipt: delivery settles under the steer's own + // provider identity, which is what attributes the user item inside the turn. + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + // And it opens no boundary: the replay event carries no startsTurn. + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(replayEventFor(rig.events, first)).toMatchObject({ startsTurn: true }) + }) + + it('session-start proof: a hook-frame startup proof with init only at the first cycle still folds', async () => { + // Live sessions prove the session from a SessionStart hook frame BEFORE + // system/init arrives (measured against the real CLI); nothing about the + // fold may depend on fields of the startup proof frame. + const rig = await riggedAdapter({}, { initProof: 'session-start' }) + const first = await rig.dispatchAt(FOLD_FRESH_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + foldFreshCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(FOLD_FRESH_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > FOLD_FRESH_SEND_AT.steer) { + rig.deliver(captured) + } + } + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + }) + + it('fold-resumed: the receipt holds on a resumed provider session', async () => { + const rig = await riggedAdapter({ + resumeLeafUuid: 'leaf-1', + resumesTranscript: true, + continuesChain: true + }) + const first = await rig.dispatchAt(FOLD_RESUMED_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + foldResumedCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < FOLD_RESUMED_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(FOLD_RESUMED_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > FOLD_RESUMED_SEND_AT.steer) { + rig.deliver(captured) + } + } + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'completed', + durationMs: 23_367 + }) + }) + + it('two-steers: both folded sends settle individually inside the one turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(TWO_STEERS_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string, secondSteerUuid: string) => + twoSteersCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: steerUuid, + secondSteer: secondSteerUuid + }) + for (const captured of framesFor('pending', 'pending-2')) { + if (captured.at < TWO_STEERS_SEND_AT.steer) { + rig.deliver(captured) + } + } + // Both steers go out mid-turn, before the next captured frame at 7949. + const steer = await rig.dispatchAt(TWO_STEERS_SEND_AT.steer, 'client-steer', STEER_PROMPT) + const secondSteer = await rig.dispatchAt( + TWO_STEERS_SEND_AT.secondSteer, + 'client-steer-2', + SECOND_STEER_PROMPT + ) + for (const captured of framesFor(steer, secondSteer)) { + if (captured.at > TWO_STEERS_SEND_AT.secondSteer) { + rig.deliver(captured) + } + } + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer-2', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: secondSteer } + }) + }) + + it('early-steer: a steer written before the first replay arrives still folds into the one turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(EARLY_STEER_SEND_AT.first, 'client-first', FIRST_PROMPT) + const framesFor = (steerUuid: string) => + earlySteerCapture({ sessionId: PROVIDER_SESSION_ID, first, steer: steerUuid }) + for (const captured of framesFor('pending')) { + if (captured.at < EARLY_STEER_SEND_AT.steer) { + rig.deliver(captured) + } + } + const steer = await rig.dispatchAt(EARLY_STEER_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of framesFor(steer)) { + if (captured.at > EARLY_STEER_SEND_AT.steer) { + rig.deliver(captured) + } + } + + // No turn was open when the steer was written, and it still folded. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'completed', + durationMs: 7_155 + }) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + }) + + it('background-wake: the wake cycle opens from its output and its result settles no waiter', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(13, 'client-first', FIRST_PROMPT) + const { firstTurn, wake } = backgroundWakeCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'unused' + }) + for (const captured of [...firstTurn, ...wake]) { + rig.deliver(captured) + } + + // The task's completion revises a live row ahead of the wake's init, and + // that provider output is what opens the wake turn. + const finalByTurn = new Map(rig.turns().map((turn) => [turn.turnId, turn])) + expect([...finalByTurn.keys()]).toEqual([first, 'task_updated-17547']) + expect(finalByTurn.get('task_updated-17547')).toMatchObject({ state: 'completed' }) + // The wake result names no send; only the first send ever settled. + expect(rig.settled).toHaveBeenCalledTimes(1) + }) + + it('background-wake: a steer replayed after the wake cycle began work folds into the wake turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(13, 'client-first', FIRST_PROMPT) + const { firstTurn, wake } = backgroundWakeCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'pending' + }) + const wakeResult = wake.findIndex((captured) => captured.frame.type === 'result') + for (const captured of [...firstTurn, ...wake.slice(0, wakeResult)]) { + rig.deliver(captured) + } + // Captured order up to the wake's output; the steer's replay is placed + // mid-cycle, where the CLI replays a send it folded (p3-early-steer). + const steer = await rig.dispatchAt(19_725, 'client-steer', STEER_PROMPT) + rig.deliver(userReplay(19_726, PROVIDER_SESSION_ID, steer, STEER_PROMPT)) + for (const captured of wake.slice(wakeResult)) { + rig.deliver(captured) + } + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([ + first, + 'task_updated-17547' + ]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + }) + + it('mid-turn auto-compaction emits no root init, so a steer after the boundary still folds', async () => { + // Measured (p4-autocompact, CLAUDE_CODE_AUTO_COMPACT_WINDOW forced): the + // boundary is `status compacting` + `compact_boundary` + a synthetic + // continuation user frame — and NO root init, so cycle work survives it. + const rig = await riggedAdapter() + const first = await rig.dispatchAt(14, 'client-first', FIRST_PROMPT) + rig.deliver(initFrame(234, PROVIDER_SESSION_ID)) + rig.deliver(userReplay(2_437, PROVIDER_SESSION_ID, first, FIRST_PROMPT)) + rig.deliver(assistantText(2_864, PROVIDER_SESSION_ID, 'reply-1', 'reading files')) + rig.deliver({ + at: 11_518, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + session_id: PROVIDER_SESSION_ID, + uuid: 'status-compacting-1' + } + }) + rig.deliver({ + at: 26_584, + frame: { + type: 'system', + subtype: 'compact_boundary', + session_id: PROVIDER_SESSION_ID, + uuid: 'compact-boundary-1', + compact_metadata: { trigger: 'auto', pre_tokens: 69_960, post_tokens: 9_311 } + } + }) + rig.deliver({ + at: 26_585, + frame: { + type: 'user', + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null, + uuid: 'continuation-1', + isSynthetic: true, + message: { + role: 'user', + content: [{ type: 'text', text: 'This session is being continued.' }] + } + } + }) + const steer = await rig.dispatchAt(26_987, 'client-steer', STEER_PROMPT) + rig.deliver(userReplay(34_601, PROVIDER_SESSION_ID, steer, STEER_PROMPT)) + rig.deliver(assistantText(37_824, PROVIDER_SESSION_ID, 'reply-2', 'FIRST DONE banana')) + rig.deliver( + resultFrame(37_828, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [first, steer], + durationMs: 37_616, + numTurns: 6 + }) + ) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + expect(replayEventFor(rig.events, steer)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-steer', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: steer } + }) + }) + + it('miss: a steer whose replay trails the result keeps the opener path and its own turn', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(MISS_SEND_AT.first, 'client-first', FIRST_PROMPT) + const { beforeSteerSend } = missCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'pending' + }) + for (const captured of beforeSteerSend) { + rig.deliver(captured) + } + const steer = await rig.dispatchAt(MISS_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of missCapture({ sessionId: PROVIDER_SESSION_ID, first, steer }) + .afterSteerSend) { + rig.deliver(captured) + } + + // Two real turns: the first completed by its result — not interrupted — + // and the late steer's own turn opened by its replay. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first, steer]) + expect(rig.turns().every((turn) => turn.state !== 'interrupted')).toBe(true) + const finalByTurn = new Map(rig.turns().map((turn) => [turn.turnId, turn])) + expect(finalByTurn.get(first)).toMatchObject({ + state: 'completed', + completedAt: T0 + 25_556, + durationMs: 25_241 + }) + expect(finalByTurn.get(steer)).toMatchObject({ + state: 'completed', + startedAt: T0 + 28_703, + completedAt: T0 + 29_275, + durationMs: 3_711 + }) + expect(replayEventFor(rig.events, steer)).toMatchObject({ startsTurn: true }) + }) + + it('cancel: a cancelled steer settles nothing and the receipt path changes none of it', async () => { + const rig = await riggedAdapter() + const first = await rig.dispatchAt(CANCEL_SEND_AT.first, 'client-first', FIRST_PROMPT) + const { beforeSteerSend, afterInterrupt } = cancelCapture({ + sessionId: PROVIDER_SESSION_ID, + first, + steer: 'never-replayed' + }) + for (const captured of beforeSteerSend) { + rig.deliver(captured) + } + await rig.dispatchAt(CANCEL_SEND_AT.steer, 'client-steer', STEER_PROMPT) + for (const captured of afterInterrupt) { + rig.deliver(captured) + } + + // One turn, ended by the interrupt's error result exactly as before. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([first]) + expect(rig.turns().at(-1)).toMatchObject({ + turnId: first, + state: 'interrupted', + outcome: 'cancellation' + }) + // The steer was never replayed and its result never named it: no settlement. + expect(rig.settled).not.toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'client-steer' }) + ) + // The interrupt's synthetic user text opens no turn either. + expect(replayEventFor(rig.events, 'interrupt-notice-1')).not.toHaveProperty('startsTurn') + }) +}) + +describe('Claude fold receipt boundaries (synthetic orders)', () => { + it('lost result: a root init starts a cycle with no work yet, so the next adopted replay is a boundary', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // A's result never arrives, but the CLI announces its next request cycle: + // the open turn stops folding, so B's replay opens its own turn. + rig.deliver(initFrame(2_000, PROVIDER_SESSION_ID)) + rig.deliver(userReplay(3_000, PROVIDER_SESSION_ID, uuidB, STEER_PROMPT)) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA, uuidB]) + expect(replayEventFor(rig.events, uuidB)).toMatchObject({ startsTurn: true }) + }) + + it('provider wake: an adopted replay during the live wake cycle folds into the wake turn', async () => { + // Cycle semantics: a wake is its own init-led cycle (p3-background-wake) and + // a replay the CLI emits mid-cycle was folded into that cycle (p3-early-steer). + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + rig.deliver( + resultFrame(2_000, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [uuidA], + durationMs: 1_990, + numTurns: 1 + }) + ) + const uuidB = await rig.dispatchAt(2_500, 'client-b', STEER_PROMPT) + rig.deliver(initFrame(3_000, PROVIDER_SESSION_ID)) + rig.deliver(assistantText(3_500, PROVIDER_SESSION_ID, 'provider-resumed-1', 'background done')) + rig.deliver(userReplay(4_000, PROVIDER_SESSION_ID, uuidB, STEER_PROMPT)) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([ + uuidA, + 'provider-resumed-1' + ]) + expect(replayEventFor(rig.events, uuidB)).not.toHaveProperty('startsTurn') + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-b', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: uuidB } + }) + }) + + it('a fresh replay uuid with user_message_uuid correlation keeps the opener path, not a fold receipt', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // A replay that did not adopt the client uuid is not the measured fold + // shape, so it keeps the opener path it had before the receipt existed. + const fresh = userReplay(2_000, PROVIDER_SESSION_ID, 'fresh-turn-2', STEER_PROMPT) + rig.deliver({ ...fresh, frame: { ...fresh.frame, user_message_uuid: uuidB } }) + + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA, 'fresh-turn-2']) + expect(replayEventFor(rig.events, 'fresh-turn-2')).toMatchObject({ startsTurn: true }) + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-b', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'fresh-turn-2' } + }) + }) + + it('plural result uuids: an unsettled folded waiter settles under its own uuid, never a shared result alias', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + const uuidB = await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // B's replay never arrives; the folded turn's one result still names it. + rig.deliver( + resultFrame(5_000, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [uuidA, uuidB], + durationMs: 4_990, + numTurns: 2 + }) + ) + + expect(rig.settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-b', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: uuidB } + }) + expect(rig.settled).not.toHaveBeenCalledWith( + expect.objectContaining({ providerIdentity: expect.objectContaining({ uuid: 'result-1' }) }) + ) + // The correlated result opened nothing and the one turn completed normally. + expect([...new Set(rig.turns().map((turn) => turn.turnId))]).toEqual([uuidA]) + expect(rig.turns().at(-1)).toMatchObject({ turnId: uuidA, state: 'completed' }) + }) + + it('plural result uuids: a result naming only settled sends does not claim an unnamed live waiter by queue order', async () => { + const rig = await riggedAdapter() + const uuidA = await rig.dispatchAt(10, 'client-a', 'first prompt') + rig.deliver(userReplay(1_000, PROVIDER_SESSION_ID, uuidA, 'first prompt')) + await rig.dispatchAt(1_500, 'client-b', STEER_PROMPT) + // A result that names only sends already settled must not fall back to + // queue order and claim B, the still-live waiter it did not name. + rig.deliver( + resultFrame(5_000, PROVIDER_SESSION_ID, 'result-1', { + userMessageUuids: [uuidA], + durationMs: 4_990, + numTurns: 1 + }) + ) + expect(rig.settled).not.toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'client-b' }) + ) + }) +}) diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 7a7fc4291c7..1ccf24e1393 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -13,6 +13,10 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** True while a turn is open and the provider's current request cycle has + * done root work since its init — the state in which the CLI folds an + * arriving send into the turn. */ + readonly openTurnInLiveProviderCycle: boolean flush: () => void childToolOwner?: ClaudeChildToolQueries['childToolOwner'] childActivity?: ClaudeChildToolQueries['childActivity'] diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 08a7916a97b..8a9dc30cd5a 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -30,6 +30,11 @@ export class ClaudeOpenTurn { * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ private reopenSuppressed = false + /** Whether the provider's current request cycle has done root work — a send + * echo or model output — since its init. Output can open a turn ahead of its + * cycle's init (a background task finishing), so membership is read from the + * cycle's work, not from when the turn opened. */ + private cycleWorkObserved = false private readonly opener: ( frame: Record, source: ClaudeTurnSource | null, @@ -63,6 +68,23 @@ export class ClaudeOpenTurn { return this.current !== null } + /** Whether a turn is open inside a provider request cycle that has already + * done work — the state in which the CLI folds an arriving send into it. A + * cycle's first send is its opener, never a fold. */ + get openedInLiveProviderCycle(): boolean { + return this.current !== null && this.cycleWorkObserved + } + + /** A root init frame: the CLI is starting a new request cycle. */ + observeProviderCycleStart(): void { + this.cycleWorkObserved = false + } + + /** A root send echo or model output inside the current request cycle. */ + observeProviderCycleWork(): void { + this.cycleWorkObserved = true + } + /** Open a turn, ending whichever one was still open. A new turn starting is the * only end the previous one gets when its result never arrives; settling it * later would sweep THIS turn. */ @@ -91,6 +113,8 @@ export class ClaudeOpenTurn { /** End the open turn, if one is open, and clear the live activity line. The * context facts the end brings ride the same revision. */ settle(end: ClaudeTurnEnd, contextUsage?: AgentSessionContextUsage): void { + // Every settle is a provider cycle ending (result, idle, child exit). + this.cycleWorkObserved = false if (this.current) { this.publish(this.current, end, contextUsage) this.current = null diff --git a/src/main/claude/claude-real-cli-availability-test-support.ts b/src/main/claude/claude-real-cli-availability-test-support.ts new file mode 100644 index 00000000000..faa322c456b --- /dev/null +++ b/src/main/claude/claude-real-cli-availability-test-support.ts @@ -0,0 +1,64 @@ +// Whether a real, signed-in Claude CLI is present — the gate every real-CLI +// suite skips on. Probed once per test process. + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { CLAUDE_AUTH_ENV_VARS } from '../claude-accounts/environment' +import { resolveClaudeCommand } from '../codex-cli/command' + +export const realClaudeCommand = resolveClaudeCommand() + +// Why paired: the probe must run the CLI under the same node the structured launch gives it. +function probeRealClaude(args: string[]): ProcessResult | null { + try { + return runProcessSync({ + program: realClaudeCommand, + args, + env: withCliRuntimeOnPath(realClaudeCommand, process.env), + stdio: ['ignore', 'pipe', 'pipe'], + timeoutMs: 5_000 + }) + } catch { + return null + } +} + +export const realClaudeAvailable = probeRealClaude(['--version'])?.code === 0 + +/** The CLI's own account report — the only source of truth for where it writes that + * is not derived from Orca's own path expressions. */ +export const realClaudeAuthStatus = (() => { + if (!realClaudeAvailable) { + return null + } + const result = probeRealClaude(['auth', 'status', '--json']) + if (!result || result.code !== 0) { + return null + } + try { + return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } + } catch { + return null + } +})() + +export const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true + +/** The config dir and env auth the availability probe above saw, for a real-CLI launch. + * The connection strips an inherited CLAUDE_CONFIG_DIR and inherited auth vars, so + * without these the child silently runs against ~/.claude whatever the probe checked. */ +export function realClaudeLaunchHome(): { claudeConfigDir: string; env: Record } { + const env: Record = {} + for (const key of CLAUDE_AUTH_ENV_VARS) { + const value = process.env[key] + if (value) { + env[key] = value + } + } + return { + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'), + env + } +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts new file mode 100644 index 00000000000..d34d0efc206 --- /dev/null +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -0,0 +1,221 @@ +// Which dispatch a provider frame settles, and whether it opens a turn. +// +// A replay or result is joined to its waiter by the client uuid Claude echoes. +// A send Claude FOLDS into the running request cycle is replayed mid-cycle with +// the client uuid adopted: once that cycle has done work, that replay is a +// delivery receipt and opens no boundary. + +import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' +import { + claudeHasReplayContent, + readClaudeMessageEnvelope +} from './claude-structured-item-translation' +import type { + ClaudeDispatchWaiter, + ClaudeLateDispatchOutcome, + ClaudeSession +} from './claude-structured-session-state' +import { readClaudeFrameString } from './claude-structured-init-proof' +import { claudeDispatchContentKey } from './claude-structured-dispatch-content' + +/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ +export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void + +export type ClaudeReplayTurnOrigin = { requestedAt: number | null } + +export function resolveClaudeReplayTurn( + session: ClaudeSession, + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement +): ClaudeReplayTurnOrigin | null { + const envelope = readClaudeMessageEnvelope(message) + const isUserReplay = + envelope?.role === 'user' && + message.parent_tool_use_id === null && + claudeHasReplayContent(envelope) + const isCompletedCommand = message.type === 'result' + if ( + (!isUserReplay && !isCompletedCommand) || + readClaudeFrameString(message, 'session_id') !== session.providerSessionId + ) { + return null + } + const uuid = readClaudeFrameString(message, 'uuid') + if (!uuid) { + return null + } + + // Newer SDK frames carry the client uuid that caused a turn. A correlation + // value is authoritative: never fall back to queue order or content, since + // identical prompts may be in flight across a timeout boundary. + const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid') + // A folded turn's result names every send it ran under `user_message_uuids`. + // Each member settles its own waiter under its own uuid — the result frame's + // uuid is never adopted as a shared alias for multiple submissions. + const resultUserMessageUuids = isCompletedCommand ? claudeResultUserMessageUuids(message) : [] + for (const member of resultUserMessageUuids) { + if (member === userMessageUuid) { + continue + } + const live = session.dispatchWaiters.find((candidate) => candidate.sentUuid === member) + if (live) { + settleWaiter(session, live, member, onSettledLate) + continue + } + const late = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === member) + if (late) { + forgetRetiredWaiter(session, late) + recoverLateIdentity(session, late, member, false, onSettledLate) + } + } + if (userMessageUuid) { + const exact = session.dispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (exact) { + const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) + settleWaiter(session, exact, uuid, onSettledLate) + return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + } + const retired = session.retiredDispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (retired) { + forgetRetiredWaiter(session, retired) + recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) + return null + } + return null + } + if (resultUserMessageUuids.length > 0) { + // The plural list is this result's complete correlation; queue order must + // not join anyone it did not name. + return null + } + + const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (exact) { + const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) + settleWaiter(session, exact, uuid, onSettledLate) + return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + } + const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (retired) { + forgetRetiredWaiter(session, retired) + recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) + return null + } + + if (isUserReplay) { + // Compatibility CLIs may mint a new replay uuid instead of echoing the + // client uuid. Content is an acceptable join only when it is the sole + // candidate on one side of the timeout boundary; with active and retired + // candidates present, identical prompts are intentionally left unknown. + const replayContentKey = claudeDispatchContentKey(envelope.content) + if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { + const compatible = session.dispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (compatible.length === 1) { + const [candidate] = compatible + settleWaiter(session, candidate!, uuid, onSettledLate) + return { requestedAt: candidate!.requestedAt } + } + } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { + const lateCompatible = session.retiredDispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (lateCompatible.length === 1) { + const [candidate] = lateCompatible + forgetRetiredWaiter(session, candidate!) + recoverLateIdentity(session, candidate!, uuid, true, onSettledLate) + return null + } + } + return null + } + const current = session.dispatchWaiters[0] + if (isCompletedCommand && !current?.acceptsResult) { + return null + } + // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous, + // even when the retired dispatch was an ordinary turn rather than a slash command. + if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) { + return null + } + // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order. + if (isCompletedCommand && session.replayContentFallbackBlocked) { + return null + } + const waiter = uuid ? session.dispatchWaiters.shift() : undefined + if (waiter && uuid) { + settleWaiter(session, waiter, uuid, onSettledLate) + return isUserReplay ? { requestedAt: waiter.requestedAt } : null + } + return null +} + +/** The provider's own cycle state decides a fold: the CLI folds a send into the + * request cycle that is running when the send arrives, and it replays a folded + * send mid-cycle with the client uuid ADOPTED (measured: fold-fresh/-resumed, + * two-steers, early-steer). So an adopted replay after the running cycle has + * done work is a delivery receipt, not a turn boundary; a cycle's first send is + * its opener. Adoption is the capability check, read off the frame itself: a + * CLI that mints fresh replay uuids never qualifies. A new cycle announces + * itself with a root init (per-turn, measured), so a lost result cannot leave a + * stale turn swallowing the next turn's replay. */ +function claudeReplayIsFoldReceipt( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + replayUuid: string +): boolean { + return replayUuid === waiter.sentUuid && session.translator?.openTurnInLiveProviderCycle === true +} + +function claudeResultUserMessageUuids(message: Record): string[] { + const uuids = message.user_message_uuids + return Array.isArray(uuids) + ? uuids.filter((member): member is string => typeof member === 'string' && member.length > 0) + : [] +} + +function settleWaiter( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + waiter.settledUuid = uuid + waiter.resolve(uuid) + // Dispatch returned on admission, so the replay is what settles delivery. + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) + } +} + +function recoverLateIdentity( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + isUserReplay: boolean, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + if (!isUserReplay && !waiter.acceptsResult) { + return + } + // The provider acted on this dispatch, so the send it came from is delivered. + // A retired replay settles delivery only; it cannot reopen a turn. + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) + } +} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 3eaf496f75a..7f197788b68 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -243,6 +243,7 @@ describe('answerClaudePrompt', () => { const resolvePrompt = vi.fn() session.translator = { handle: vi.fn(), + openTurnInLiveProviderCycle: false, journalPrompts: { cancel: vi.fn(() => ({ accepted: true as const })), resolve: resolvePrompt diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8c1a6462e9c..8ec1aa2e9c4 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,10 +1,8 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' -import { - settleCancelledClaudeDispatchWaiters, - type ClaudeLateDispatchSettlement -} from './claude-structured-dispatch' +import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index 75553addd3c..c4578ca38d8 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -2,7 +2,8 @@ // completes, and nothing about elapsed time ever puts a message in doubt. import { describe, expect, it, vi } from 'vitest' -import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' import { childExited, sessionFor, @@ -61,8 +62,9 @@ describe('Claude structured dispatch admission', () => { true ) - // Queued while turn one is still running: Claude cannot echo it until that - // turn ends, so nothing about the wait is evidence of a delivery problem. + // Queued while turn one is still running: a fold is echoed mid-turn, a + // queued send only when its own turn starts — either way elapsed time is + // not evidence of a delivery problem. const queued = await dispatchClaudeTurn(session, { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index f3a3501ec8a..92b6a5048b2 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -10,11 +10,11 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat } /** - * A waiter with no deadline. The echo Claude sends is emitted when the provider - * STARTS the turn, so a message queued behind a running turn cannot be echoed - * until that turn ends — an interval bounded only by the previous turn. Elapsed - * time is therefore not evidence about delivery, and nothing here expires. - * Waiters are retired by process facts instead: a failed write, or child exit. + * A waiter with no deadline. A mid-turn send Claude folds into the running turn + * is replayed mid-turn; one it runs later is replayed only when its own turn + * starts — an interval bounded only by the previous turn. Elapsed time is + * therefore not evidence about delivery, and nothing here expires. Waiters are + * retired by process facts instead: a failed write, or child exit. */ export function waitForReplay( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index c540167de4c..92ae0253711 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' import { ClaudeDispatchContentError, readClaudeImage } from './claude-structured-dispatch-content' import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' import type { ClaudeSession } from './claude-structured-session-state' diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 220542fab91..612f6391ced 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,16 +7,8 @@ import { } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - claudeHasReplayContent, - readClaudeMessageEnvelope -} from './claude-structured-item-translation' -import type { - ClaudeDispatchWaiter, - ClaudeLateDispatchOutcome, - ClaudeSession -} from './claude-structured-session-state' -import { readClaudeFrameString } from './claude-structured-init-proof' +import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { claudeDispatchContentKey, claudeDispatchContentRejection, @@ -40,158 +32,6 @@ import { const MAX_ACTIVE_DISPATCH_WAITERS = 64 -/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ -export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void - -export type ClaudeReplayTurnOrigin = { requestedAt: number | null } - -export function resolveClaudeReplayTurn( - session: ClaudeSession, - message: Record, - onSettledLate?: ClaudeLateDispatchSettlement -): ClaudeReplayTurnOrigin | null { - const envelope = readClaudeMessageEnvelope(message) - const isUserReplay = - envelope?.role === 'user' && - message.parent_tool_use_id === null && - claudeHasReplayContent(envelope) - const isCompletedCommand = message.type === 'result' - if ( - (!isUserReplay && !isCompletedCommand) || - readClaudeFrameString(message, 'session_id') !== session.providerSessionId - ) { - return null - } - const uuid = readClaudeFrameString(message, 'uuid') - if (!uuid) { - return null - } - - // Newer SDK frames carry the client uuid that caused a turn. A correlation - // value is authoritative: never fall back to queue order or content, since - // identical prompts may be in flight across a timeout boundary. - const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid') - if (userMessageUuid) { - const exact = session.dispatchWaiters.find( - (candidate) => candidate.sentUuid === userMessageUuid - ) - if (exact) { - settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay ? { requestedAt: exact.requestedAt } : null - } - const retired = session.retiredDispatchWaiters.find( - (candidate) => candidate.sentUuid === userMessageUuid - ) - if (retired) { - forgetRetiredWaiter(session, retired) - recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) - return null - } - return null - } - - const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) - if (exact) { - settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay ? { requestedAt: exact.requestedAt } : null - } - const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) - if (retired) { - forgetRetiredWaiter(session, retired) - recoverLateIdentity(session, retired, uuid, isUserReplay, onSettledLate) - return null - } - - if (isUserReplay) { - // Compatibility CLIs may mint a new replay uuid instead of echoing the - // client uuid. Content is an acceptable join only when it is the sole - // candidate on one side of the timeout boundary; with active and retired - // candidates present, identical prompts are intentionally left unknown. - const replayContentKey = claudeDispatchContentKey(envelope.content) - if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { - const compatible = session.dispatchWaiters.filter( - (candidate) => candidate.replayContentKey === replayContentKey - ) - if (compatible.length === 1) { - const [candidate] = compatible - settleWaiter(session, candidate!, uuid, onSettledLate) - return { requestedAt: candidate!.requestedAt } - } - } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { - const lateCompatible = session.retiredDispatchWaiters.filter( - (candidate) => candidate.replayContentKey === replayContentKey - ) - if (lateCompatible.length === 1) { - const [candidate] = lateCompatible - forgetRetiredWaiter(session, candidate!) - recoverLateIdentity(session, candidate!, uuid, true, onSettledLate) - return null - } - } - return null - } - const current = session.dispatchWaiters[0] - if (isCompletedCommand && !current?.acceptsResult) { - return null - } - // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous, - // even when the retired dispatch was an ordinary turn rather than a slash command. - if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) { - return null - } - // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order. - if (isCompletedCommand && session.replayContentFallbackBlocked) { - return null - } - const waiter = uuid ? session.dispatchWaiters.shift() : undefined - if (waiter && uuid) { - settleWaiter(session, waiter, uuid, onSettledLate) - return isUserReplay ? { requestedAt: waiter.requestedAt } : null - } - return null -} - -function settleWaiter( - session: ClaudeSession, - waiter: ClaudeDispatchWaiter, - uuid: string, - onSettledLate?: ClaudeLateDispatchSettlement -): void { - const index = session.dispatchWaiters.indexOf(waiter) - if (index !== -1) { - session.dispatchWaiters.splice(index, 1) - } - waiter.settledUuid = uuid - waiter.resolve(uuid) - // Dispatch returned on admission, so the replay is what settles delivery. - if (waiter.clientMessageId) { - onSettledLate?.({ - clientMessageId: waiter.clientMessageId, - providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } - }) - } -} - -function recoverLateIdentity( - session: ClaudeSession, - waiter: ClaudeDispatchWaiter, - uuid: string, - isUserReplay: boolean, - onSettledLate?: ClaudeLateDispatchSettlement -): void { - if (!isUserReplay && !waiter.acceptsResult) { - return - } - // The provider acted on this dispatch, so the send it came from is delivered. - // A retired replay settles delivery only; it cannot reopen a turn. - if (waiter.clientMessageId) { - onSettledLate?.({ - clientMessageId: waiter.clientMessageId, - providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } - }) - } -} - export function settleCancelledClaudeDispatchWaiters( session: ClaudeSession, cancelledUuids: readonly string[], @@ -327,6 +167,7 @@ export async function dispatchClaudeTurn( } // The write is the admission signal. Awaiting the echo here would block on the // turn already running, which is why the deadline this replaces kept declaring - // doubt about messages that were delivered. `settleWaiter` finishes the job. + // doubt about messages that were delivered. The replay resolution + // (`claude-replay-turn-resolution.ts`) finishes the job. return { state: 'admitted' } } diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 7cf59ee555b..2c319c86ee7 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -7,7 +7,7 @@ import { import { readClaudeSettingsEffort } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' -import { acquired, fakeClaude } from './claude-structured-session-test-support' +import { USER_MESSAGE, acquired, fakeClaude } from './claude-structured-session-test-support' /** Verbatim from Claude Code 2.1.258's get_settings response. */ const REAL_SETTINGS = { @@ -93,7 +93,14 @@ describe('Claude effort reporting', () => { it('keeps the init fixture free of an effort the real frame never sends', async () => { const events: ClaudeStructuredSessionEvent[] = [] - await acquired(fakeClaude(), {}, events) + const adapter = await acquired(fakeClaude(), {}, events) + // Live: init arrives when the first command starts a cycle, not at startup. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) const init = events.flatMap((event) => event.type === 'message' && event.message.subtype === 'init' ? [event.message] : [] ) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 16c054945a1..082b0024061 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -184,6 +184,21 @@ export function createClaudeJournalTranslator( } if (event.type === 'message') { context.observe(event.message, event.observedAt ?? Date.now()) + // A root init is the CLI starting a new request cycle (measured per turn, + // per queued turn, per background wake, per /compact); a send replayed + // after that cycle's first root work was folded into it. Task frames are + // not cycle work: they arrive between cycles too. + if (isRootClaudeFrame(event.message)) { + if (event.message.type === 'system' && event.message.subtype === 'init') { + turn.observeProviderCycleStart() + } else if ( + event.startsTurn === true || + event.message.type === 'assistant' || + event.message.type === 'stream_event' + ) { + turn.observeProviderCycleWork() + } + } } if (event.type === 'message' && handleStream(event.message, event.observedAt ?? Date.now())) { return @@ -281,6 +296,9 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + get openTurnInLiveProviderCycle() { + return turn.openedInLiveProviderCycle + }, flush: streamedText.flush, childToolOwner: childQueries.childToolOwner, childActivity: childQueries.childActivity, diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index 96d7e9daa39..f4034400cbf 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' -import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + acquired, + fakeClaude +} from './claude-structured-session-test-support' /** Verbatim rows from Claude Code 2.1.258's list_models response. */ const CATALOG = [ @@ -147,6 +152,13 @@ describe('Claude model confirmation', () => { routes: { list_models: () => CATALOG } }) const adapter = await acquired(claude) + // The model is confirmed by a cycle's init frame, so start one. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 3c290fa5184..53d6f63623d 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -17,9 +17,9 @@ import { acquired, adapterFor, fakeClaude, - identityFor, - invokeCanUseTool + identityFor } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' function deferred(): { promise: Promise; resolve: () => void } { let resolve = (): void => {} diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 864a99a0b77..e1c1aa371bf 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -10,7 +10,7 @@ import { cancelClaudeTurn, supportsClaudeQueuedInterruptCancellation } from './claude-structured-control-actions' -import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { buildClaudePromptReply } from './claude-structured-prompt-replies' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudePendingPrompt } from './claude-prompt-registry' diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts new file mode 100644 index 00000000000..5ce21d83f17 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -0,0 +1,194 @@ +// The fold receipt against the real CLI: a message sent while a turn runs is +// folded by the CLI into the running turn, and Orca must keep ONE turn row — +// no interrupted marking, no second bar. Runs only where a signed-in Claude CLI +// exists, like the rest of the real-CLI suite. Live sessions prove the session +// from a SessionStart hook frame BEFORE system/init arrives, which is exactly +// the path the fixture harness cannot fake end to end. + +import { randomUUID } from 'node:crypto' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + realClaudeAuthenticated, + realClaudeAvailable, + realClaudeCommand, + realClaudeLaunchHome +} from './claude-real-cli-availability-test-support' + +const SESSION_ID = 'real-cli-fold' +// Pins the live proof order (SessionStart hook frame before system/init) and lets the +// Bash steps run unprompted, whatever the config dir under test configures. +const FOLD_SESSION_SETTINGS = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo' }] }] }, + permissions: { allow: ['Bash(sleep:*)'] } +}) + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'real-cli-fold-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +async function until(check: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (check()) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return check() +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { + it.skipIf(!realClaudeAuthenticated)( + 'keeps one turn row when the CLI folds a mid-turn send', + async () => { + const providerSessionId = randomUUID() + const { claudeConfigDir, env } = realClaudeLaunchHome() + const cwd = await mkdtemp(join(tmpdir(), 'orca-real-fold-')) + const events: ClaudeStructuredSessionEvent[] = [] + const turnRows: NonNullable>[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turnRows.push(turn) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + const settled = vi.fn() + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: realClaudeCommand, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + settings: FOLD_SESSION_SETTINGS + }, + sessionId: providerSessionId + }, + cwd, + env, + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => events.push(event), + onDispatchSettledLate: settled, + readProcessStartTime: async () => 1 + }) + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-fold', + events: sink + }) + await adapter.awaitStarted(SESSION_ID) + // Startup proved from the SessionStart hook frame, with no init yet. + expect( + events.some( + (event) => + event.type === 'message' && + event.message.type === 'system' && + event.message.subtype === 'init' + ) + ).toBe(false) + + await expect( + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'client-A', + body: { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'Use the Bash tool to run `sleep 8` two separate times, one call at a time, waiting for each. Then reply exactly: FIRST DONE' + } + ] + }, + requestedAt: Date.now(), + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + + // Wait for A's replay to open the turn, then send B mid-turn: the first + // `sleep 8` guarantees the CLI is still inside A's request cycle. + expect( + await until( + () => events.some((event) => event.type === 'message' && event.startsTurn === true), + 30_000 + ) + ).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + await expect( + adapter.dispatch({ + sessionId: SESSION_ID, + clientMessageId: 'client-B', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Also say the word banana at the end of your reply.' }] + }, + requestedAt: Date.now(), + fence: 1 + }) + ).resolves.toEqual({ state: 'admitted' }) + + const results = () => + events.flatMap((event) => + event.type === 'message' && event.message.type === 'result' ? [event.message] : [] + ) + expect(await until(() => results().length > 0, 90_000)).toBe(true) + + // The CLI folded: one result names both sends. (If this ever reports a + // lone send, the steer raced past the fold window — a miss, not a fold.) + const named = results().flatMap((message) => + Array.isArray(message.user_message_uuids) ? [message.user_message_uuids] : [] + ) + expect(named[0]).toHaveLength(2) + + // ONE turn for the whole run: never interrupted, and B settled accepted + // into it under its own adopted uuid. + expect(turnRows.every((turn) => turn.state !== 'interrupted')).toBe(true) + expect([...new Set(turnRows.map((turn) => turn.turnId))]).toHaveLength(1) + expect(turnRows.at(-1)).toMatchObject({ state: 'completed' }) + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + clientMessageId: 'client-B', + providerIdentity: expect.objectContaining({ provider: 'claude' }) + }) + ) + } finally { + await adapter.closeAll() + } + }, + 150_000 + ) +}) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index cfd4c8c33ea..20670147ca0 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -1,50 +1,24 @@ -import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join, relative } from 'node:path' import { describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' -import { getSpawnArgsForWindows } from '../win32-utils' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + realClaudeAuthenticated, + realClaudeAuthStatus, + realClaudeAvailable, + realClaudeCommand +} from './claude-real-cli-availability-test-support' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' -const command = resolveClaudeCommand() -const versionLaunch = getSpawnArgsForWindows(command, ['--version']) -const realClaudeAvailable = - spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, { - stdio: 'ignore', - windowsHide: true, - timeout: 5_000 - }).status === 0 -const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) -/** The CLI's own account report — the only source of truth for where it writes that - * is not derived from Orca's own path expressions. */ -const realClaudeAuthStatus = (() => { - if (!realClaudeAvailable) { - return null - } - const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { - encoding: 'utf8', - windowsHide: true, - timeout: 5_000 - }) - if (result.status !== 0) { - return null - } - try { - return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } - } catch { - return null - } -})() -const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true +const command = realClaudeCommand function realAdapter( providerSessionId: string, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index ac8d65ba107..b5a7a5d3061 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -7,7 +7,7 @@ import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/envir import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' -import { resolveClaudeReplayTurn } from './claude-structured-dispatch' +import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' import { readClaudeCapabilities, readClaudeFrameString, diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index c77d5ee0a91..d410861464a 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -223,6 +223,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { } }) const adapter = await acquired(claude) + // The model is reported by a cycle's init frame, so start one. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ models: [ @@ -251,6 +258,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { } }) const adapter = await acquired(claude) + // The custom model only reports on the first cycle's init frame. + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'seed-cycle', + body: USER_MESSAGE, + fence: 7 + }) const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(result.models.map((model) => model.id)).toEqual([ diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 376cac9d52f..ee14b6b525a 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -18,12 +18,12 @@ import { adapterFor, fakeClaude, identityFor, - invokeCanUseTool, PROVIDER_SESSION_ID, tick, USER_MESSAGE, type FakeConnection } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' describe('ClaudeStructuredSessionAdapter.acquire', () => { it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { @@ -62,7 +62,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { mintedAtFence: 7, observedAt: 1_700_000_000_500 }) - expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } }) + // Live proof order: the SessionStart hook frame arrives before any init. + expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started' } }) }) it('restores persisted model and effort before publishing a reacquired session', async () => { @@ -344,7 +345,10 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { session_id: 'foreign-provider-session' }) await Promise.resolve() - expect(events.filter((event) => event.type === 'message')).toHaveLength(1) + // Startup hook proof + the first cycle's init are admitted; nothing foreign is. + expect( + events.flatMap((event) => (event.type === 'message' ? [event.message.subtype] : [])) + ).toEqual(['hook_started', 'hook_response', 'init']) expect(settled).not.toHaveBeenCalled() connection.handlers.onMessage?.({ diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index a5647caee84..71a7ac9ce31 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -9,10 +9,10 @@ import { adapterFor, fakeClaude, identityFor, - invokeCanUseTool, PROVIDER_SESSION_ID, tick } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { it('shares concurrent close finalization and emits lifecycle once', async () => { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index a0b0f321679..6848d9a1943 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -42,7 +42,9 @@ export type ClaudeStructuredSessionEvent = type: 'message' sessionId: string message: Record - /** Present only when this replay acknowledged Orca's in-flight dispatch. */ + /** Present only when this replay acknowledged Orca's in-flight dispatch + * AND opens a turn; a replay folded into the running turn settles + * delivery without one. */ startsTurn?: true /** Submission instant of the dispatch this replay acknowledged; the origin * of the turn it opens. Absent when the host cannot name a send. */ diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 51c7c1c8fff..0380d5ac12f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -51,6 +51,9 @@ export function fakeClaude( initSessionId?: string initUuid?: string initModel?: string + /** 'session-start' (default) mirrors live: a SessionStart hook frame proves the + * session and system/init arrives only when the first command starts a cycle. + * 'init' emits init at startup — an UNMEASURED shape, opt-in only. */ initProof?: 'init' | 'session-start' | 'none' initAccount?: unknown initCommands?: unknown @@ -81,6 +84,22 @@ export function fakeClaude( return route ? route(params) : undefined } const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { + let cycleInitEmitted = false + // Keys mirror the real system/init frame, which carries `model` but no + // effort of any kind: the current effort only comes back from get_settings. + // Never add a field the CLI does not send. + const emitCycleInit = (): void => { + cycleInitEmitted = true + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid', + model: options.initModel ?? 'claude-sonnet-5', + apiKeySource: 'none', + ...(options.capabilities ? { capabilities: options.capabilities } : {}) + }) + } const connection: FakeConnection = { launch, handlers, @@ -102,7 +121,13 @@ export function fakeClaude( // The SDK rejects pending control requests once the transport ends. throw new Error('Query closed before response received') } - if (options.initProof === 'session-start') { + if (options.initProof === 'init') { + // UNMEASURED startup shape, kept only as an explicit opt-in: live + // sessions prove startup with a SessionStart hook frame instead. + emitCycleInit() + } else if (options.initProof !== 'none') { + // The live proof order (measured through Orca's adapter): SessionStart + // hook frames arrive first; system/init only when a cycle starts. handlers.onMessage?.({ type: 'system', subtype: 'hook_started', @@ -110,22 +135,19 @@ export function fakeClaude( session_id: options.initSessionId ?? PROVIDER_SESSION_ID, uuid: options.initUuid ?? 'init-uuid' }) - } else if (options.initProof !== 'none') { - // Keys mirror the real system/init frame, which carries `model` but no - // effort of any kind: the current effort only comes back from - // get_settings. Never add a field the CLI does not send. handlers.onMessage?.({ type: 'system', - subtype: 'init', + subtype: 'hook_response', + hook_name: 'SessionStart:startup', session_id: options.initSessionId ?? PROVIDER_SESSION_ID, - uuid: options.initUuid ?? 'init-uuid', - model: options.initModel ?? 'claude-sonnet-5', - apiKeySource: 'none', - ...(options.capabilities ? { capabilities: options.capabilities } : {}) + uuid: 'hook-response-uuid' }) } return { models: options.initModels ?? [{ value: 'claude-sonnet', displayName: 'Sonnet' }], + // Capabilities ride the initialize result, where startup facts read + // them regardless of when the first init frame arrives. + ...(options.capabilities ? { capabilities: options.capabilities } : {}), ...(options.initCommands === undefined ? {} : { commands: options.initCommands }), ...(options.initAccount === undefined ? {} : { account: options.initAccount }) } @@ -184,6 +206,11 @@ export function fakeClaude( await beforeDispatch() } connection.sent.push(message) + // Live: the first command starts a request cycle, whose init precedes + // the replay. Later cycles are the test's own frames. + if (message.type === 'user' && !cycleInitEmitted && options.initProof !== 'none') { + emitCycleInit() + } if (message.type === 'user' && options.replayUuid !== null) { const configuredReplayUuid = options.replayUuids ? options.replayUuids[replayIndex++] @@ -297,29 +324,3 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { export function tick(): Promise { return new Promise((resolve) => setImmediate(resolve)) } - -export function invokeCanUseTool( - connection: FakeConnection, - toolName: string, - requestId: string, - toolUseID: string, - extra: { - input?: Record - suggestions?: unknown[] - signal?: AbortSignal - } = {} -): { promise: Promise; settled: () => boolean } { - const options = { - requestId, - toolUseID, - signal: extra.signal ?? new AbortController().signal, - ...(extra.suggestions ? { suggestions: extra.suggestions } : {}) - } as unknown as Parameters>[2] - let done = false - const promise = Promise.resolve( - connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options) - ).finally(() => { - done = true - }) - return { promise, settled: () => done } -} diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 338a6937716..81a84989aa4 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -96,6 +96,7 @@ function sessionHoldingTurn(turnId: string | null): ReturnType { session.dispatchSequence = 1 session.translator = { handle: vi.fn(), + openTurnInLiveProviderCycle: false, journalPrompts: { cancel: vi.fn(), resolve: vi.fn() }, currentTurnId: 'turn-1', flush: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 7c195a9a124..b1836983869 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -37,7 +37,17 @@ beforeEach(async () => { resetHostTestOperationIds() lifecycle = [] statuses = [] - const claude = fakeClaude({ initDelayMs: INIT_DELAY_MS, initModel: 'claude-opus-9' }) + // A CLI whose own default is not the catalog's: startup reports it through get_settings, + // since system/init arrives only with the first command. + const claude = fakeClaude({ + initDelayMs: INIT_DELAY_MS, + initModel: 'claude-opus-9', + settings: { + applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, + effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, + sources: {} + } + }) adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ pathToClaudeCodeExecutable: 'claude', diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index ba28ee43174..1588e110a1d 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -42,7 +42,7 @@ describe('structured mailbox pointer host', () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is - // then delivered mid-turn, which Codex coalesces into the running turn and Claude queues behind + // then delivered mid-turn, which Codex coalesces into the running turn and Claude folds into // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] hostRef.current = { journalSnapshot: () => ({ items }) } diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 4071d17a073..040bdd43190 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -68,7 +68,8 @@ export function structuredSessionGateFacts( * them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into * the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none * of which refuse it and none of which fire a second `turn/started` -- and - * Claude queues it behind the turn. Both therefore + * Claude folds it into the running turn (or runs it as the next turn when the + * turn ends first). Both therefore * fold the nudge into work already in flight, where it reads as part of the * running turn rather than a new instruction. Waiting for the turn to settle is * the one contract that holds for both, and it preserves orchestration's diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 2f7b0a49eaf..c553e8e39d1 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -164,7 +164,7 @@ describe('group addressing and structured workers', () => { // The same trap that already cost this branch once: settlement tombstones the lifecycle item // rather than rewriting it, so a long tool-calling turn pushes it arbitrarily far from the // tail and any page-sized read reports a BUSY worker as idle — then `@idle` broadcasts into a - // running turn, which Codex refuses outright and Claude queues behind. + // running turn, which Codex refuses outright and Claude folds into it. registerWorker() installHost({ items: [runningTurn(), ...transcript(500)] }) expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('working') diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 4115f168552..3a6161f8187 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -68,8 +68,8 @@ export function listAddressableStructuredWorkers( * A structured worker's agent status, in the vocabulary `@idle` already matches on. * * Null when the session cannot be read: unknown must not read as idle, or a broadcast to `@idle` - * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues - * behind it. + * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude folds + * into it. */ export async function structuredWorkerAgentStatus(sessionId: string): Promise { const facts = await readStructuredSessionGateFacts(sessionId) From c8af48d8a4159feaa4bc40e8773cf730f7ab0d3c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:39:01 -0400 Subject: [PATCH 338/387] fix(runtime): settle a quiet Codex composer as ready on every version (#23765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): settle a quiet Codex composer as tui-idle on every version Codex 0.158 dropped `model:`/`directory:` from its startup header, which both Codex readiness rules require, so `worker-start --agent codex` timed out; an idle Codex pane after a turn also had no readiness signal once the header left the screen. Generalize the Muse tier-1b lane into a quiet-ready-screen lane: a Codex (or agent-unknown) pane whose live screen shows the empty composer placeholder, no `to interrupt)` status row, no header `loading`, and no dialog wording in its live window, settles once the stream has been quiet for the tui-idle quiescence window. Additive only: the tier-1 rules and the Muse rule are unchanged. Fixtures: codex 0.150.1-0.158.0 captures at 120x40, including chunk-timed turns. * refactor(runtime): anchor the Codex quiet lane to the empty composer line Move the Codex screen rules into codex-terminal-readiness.ts and the quiet-screen body beside isKnownReadyPromptBody. The composer rule now matches only the `› Ask Codex to do anything` line and drops its dialog markers: every Codex dialog replaces the composer, and an answer ending "Would you like to…?" above a live composer must not hold the lane forever. The quiet lane checks quiescence before reading the screen. Trim the redundant startup and untimed turn fixtures. * fix(runtime): read Codex's busy row above the composer and scope the lane to codex panes * fix(runtime): read only Codex's live status row above the composer --- .oxfmtrc.json | 3 +- .../serialize-grid-transcript-replay.test.ts | 5 + .../__fixtures__/codex-0-150-1-turn.meta.json | 16 + .../__fixtures__/codex-0-150-1-turn.txt | 18 + .../codex-0-155-1-timed-turn.meta.json | 14 + .../codex-0-155-1-timed-turn.timing.json | 1 + .../__fixtures__/codex-0-155-1-timed-turn.txt | 26 ++ .../codex-0-157-1-update-dialog.meta.json | 9 + .../codex-0-157-1-update-dialog.txt | 1 + .../codex-0-158-0-approval.meta.json | 18 + .../__fixtures__/codex-0-158-0-approval.txt | 1 + .../codex-0-158-0-timed-turn.meta.json | 15 + .../codex-0-158-0-timed-turn.timing.json | 1 + .../__fixtures__/codex-0-158-0-timed-turn.txt | 1 + .../codex-0-158-0-trustprompt.meta.json | 15 + .../codex-0-158-0-trustprompt.txt | 1 + .../agent-transcript-replay-test-harness.ts | 56 +++ ...codex-header-readiness-transcripts.test.ts | 65 +-- .../runtime/codex-quiet-ready-screen.test.ts | 372 ++++++++++++++++++ src/main/runtime/codex-terminal-readiness.ts | 63 +++ src/main/runtime/terminal-wait-detection.ts | 51 +-- src/main/runtime/tui-idle-evidence.test.ts | 44 +-- src/main/runtime/tui-idle-evidence.ts | 50 +-- 23 files changed, 726 insertions(+), 120 deletions(-) create mode 100644 src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-150-1-turn.txt create mode 100644 src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json create mode 100644 src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt create mode 100644 src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-approval.txt create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt create mode 100644 src/main/runtime/agent-transcript-replay-test-harness.ts create mode 100644 src/main/runtime/codex-quiet-ready-screen.test.ts create mode 100644 src/main/runtime/codex-terminal-readiness.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0a6625f6fe7..19856d6ca84 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,6 +8,7 @@ "cloud/**", "resources/licenses/**", ".github/actions/cloud-sql-rollout-lease/**", - ".anti-slop-plugin/**" + ".anti-slop-plugin/**", + "src/main/runtime/__fixtures__/*.timing.json" ] } diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index dca03b69a05..298aaa681bf 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -43,6 +43,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'codex-0157-effort-override-embedded-warning': 4, 'codex-0157-no-daemon-effort-override': 16, 'codex-0157-plain-ready': 18, + // Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures). + 'codex-0-157-1-update-dialog': 16, + 'codex-0-158-0-approval': 12, + 'codex-0-158-0-timed-turn': 20, + 'codex-0-158-0-trustprompt': 36, 'claude-dialog-trust-workspace-answered': 13, // DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this // transcript's divergences is the same shape: `visible-grid row=0`, a true-colour diff --git a/src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json new file mode 100644 index 00000000000..21b39d7625b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-150-1-turn.meta.json @@ -0,0 +1,16 @@ +{ + "capturedAt": "2026-09-29T01:35:32.695Z", + "platform": "darwin", + "command": [ + "codex", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox", + "-m", + "gpt-5.6-sol" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.150.1: launch, one short turn, settled idle after it. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-150-1-turn.txt b/src/main/runtime/__fixtures__/codex-0-150-1-turn.txt new file mode 100644 index 00000000000..4d4c5dacccf --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-150-1-turn.txt @@ -0,0 +1,18 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.150.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.150.1) ││ ││ model: loading /model to change ││ directory: ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;scratch[?2026h[0 q[?25h[?2026l[?2026hgpt-5.6-sol default · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026hMMMMMMMMM +╭────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.150.1) │ +│ │ +│ model: gpt-5.6-sol medium /model to change │ +│ directory: ~/orca-lanes/sta8834/corpus/scratch │ +│ permissions: YOLO mode │ +╰────────────────────────────────────────────────────╯ + + Tip: Try the Desktop app. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true • Booting MCP server: node_repl (0s • esc to interrupt)  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;scratch[?2026h  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026hMM + +• You have 3 usage limit resets available. Run /usage to use one.[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hReply with the single wordOK.[0 q[?25h[?2026l[?2026hMMMM + + +› Reply with the single word OK. +Ask Codex to do anything[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h • Working (0s • esc to interrupt)  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026h•Wor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Worki[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026hWorkin[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠋ scratch[?2026hWorking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Working1[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠸ scratch[?2026horking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026hrking[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h•king[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026hing[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠦ scratch[?2026hng[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026hg[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠙ scratch[?2026h2[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026h•Wor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Worki[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026hWorkin[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠋ scratch[?2026hWorking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Working3[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠸ scratch[?2026horking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026hrking[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h•king[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026hing[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠦ scratch[?2026hng[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hg[0 q[?25h[?2026l]0;⠧ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠙ scratch[?2026h4[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠧ scratch[?2026h•Wor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hWorki[0 q[?25h[?2026l]0;⠏ scratch[?2026h•[0 q[?25h[?2026l[?2026hWorkin[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠋ scratch[?2026hWorking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠙ scratch[?2026hWorking5[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•Working[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026hWorking[0 q[?25h[?2026l[?2026h•[0 q[?25h[?2026l]0;⠸ scratch[?2026horking[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h•rking[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h•king[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h•ing[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠦ scratch[?2026hng[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hg[0 q[?25h[?2026l]0;⠧ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠋ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h6[0 q[?25h[?2026l]0;⠙ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠹ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠸ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠼ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠴ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hW[0 q[?25h[?2026l]0;⠦ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hWo[0 q[?25h[?2026l]0;⠧ scratch[?2026h•[0 q[?25h[?2026l[?2026hWor[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠇ scratch[?2026h•Work[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l[?2026hMM + +• OK  › Ask Codex to do anything  gpt-5.6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;⠏ scratch[?2026h[0 q[?25h[?2026l[?2026h[0 q[?25h[?2026l]0;scratch[?2026h[0 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json new file mode 100644 index 00000000000..ca6db1a3254 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.meta.json @@ -0,0 +1,14 @@ +{ + "capturedAt": "2026-09-29T01:44:01.149Z", + "platform": "darwin", + "command": [ + "codex", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.155.1, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. .timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json new file mode 100644 index 00000000000..8258e899ce0 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.timing.json @@ -0,0 +1 @@ +{"promptSentAtMs":4478.2,"chunks":[[63.6,8],[63.7,47],[164.3,17],[164.5,730],[164.6,8],[164.6,78],[164.8,78],[165.4,78],[171.5,78],[172.5,78],[174.7,17],[174.8,794],[174.9,132],[175.2,79],[175.7,79],[197.3,79],[197.5,79],[203.1,79],[205.8,71],[205.8,8],[206,71],[206.2,87],[636.8,79],[641.5,79],[642.3,12],[642.6,202],[642.8,71],[642.9,8],[671.8,79],[728.1,14],[749.8,31],[749.8,767],[749.9,506],[750.9,77],[792.1,12],[793.2,77],[995.7,72],[995.7,178],[1340.2,77],[3899.1,153],[4478.5,104],[4478.5,94],[4478.6,160],[4483.3,77],[4488.7,77],[4510.6,14],[4511.7,18],[4511.9,509],[4545.3,14],[4545.4,77],[4579.4,77],[4613,77],[4646.2,14],[4646.3,77],[4679.2,77],[4712.4,77],[4721.2,77],[4732.5,77],[4741.8,77],[4755.1,14],[4755.2,77],[4756.4,30],[4763.7,132],[4798.3,77],[4831.8,77],[4865.3,30],[4865.4,77],[4898,77],[4931.8,77],[4965.6,30],[4965.7,77],[4998.3,77],[5032,77],[5066.6,30],[5066.7,77],[5100.6,111],[5134.7,77],[5168.8,30],[5168.8,77],[5202.5,111],[5235.8,77],[5269.8,30],[5269.9,77],[5304,111],[5338,77],[5372.6,30],[5372.7,111],[5406.4,77],[5440.5,77],[5474.5,30],[5474.6,111],[5508.1,77],[5542.1,30],[5542.2,82],[5542.2,8],[5576.7,111],[5610.9,77],[5645.1,30],[5645.3,77],[5678.4,111],[5712.5,77],[5745.7,30],[5745.9,77],[5779,111],[5812.7,77],[5845.4,30],[5845.6,111],[5879.6,77],[5913.5,77],[5945.9,30],[5946,111],[5980.4,77],[6014.5,77],[6048.6,30],[6048.7,77],[6082,77],[6116.5,77],[6150.1,30],[6150.2,77],[6183.5,77],[6217.5,77],[6251.7,30],[6252.1,77],[6285.1,77],[6318.6,77],[6352.7,30],[6352.9,77],[6386.1,77],[6425.6,77],[6459.8,30],[6460,77],[6493.6,77],[6527.3,90],[6561.3,30],[6561.4,77],[6594.4,77],[6627.4,77],[6661,30],[6661.2,77],[6695,77],[6729.1,77],[6763.1,30],[6763.2,77],[6797.2,77],[6831.3,77],[6865.4,30],[6865.5,77],[6898.9,77],[6933.1,77],[6967.1,30],[6967.2,77],[7001.2,77],[7035.3,77],[7068.9,30],[7069,77],[7102.2,111],[7136.6,77],[7169.9,30],[7170.3,77],[7204.3,111],[7238.4,77],[7272.3,30],[7272.3,77],[7305.3,111],[7339,77],[7373,30],[7373.2,111],[7407.1,77],[7441.1,77],[7474.7,30],[7474.9,111],[7508.5,77],[7542.6,30],[7542.8,90],[7576.9,111],[7610,77],[7644.6,30],[7644.7,77],[7679,111],[7712.9,77],[7746.6,30],[7746.8,77],[7779.2,111],[7813,77],[7847,30],[7847.1,111],[7881.2,77],[7915.3,77],[7949.3,30],[7949.4,111],[7982.8,77],[8016.8,77],[8050.1,30],[8050.1,29],[8050.1,48],[8083.8,77],[8117.9,77],[8151.5,30],[8151.5,77],[8185.7,77],[8219.4,77],[8252.2,30],[8252.3,77],[8286.4,77],[8320.4,77],[8354.4,30],[8354.5,77],[8388,77],[8422,77],[8456,30],[8456,77],[8490.2,77],[8524.5,90],[8558.4,30],[8558.6,77],[8592.8,77],[8626.1,77],[8659.8,30],[8660.1,77],[8694.5,77],[8727.9,77],[8761.8,30],[8762,77],[8796.8,77],[8830.8,77],[8864.1,30],[8864.3,77],[8897.6,77],[8931.7,77],[8965.2,30],[8965.3,77],[8999.4,77],[9032.6,77],[9066.7,30],[9066.8,77],[9101.1,111],[9135.1,77],[9169,30],[9169.2,77],[9202.6,111],[9236.7,77],[9270.7,30],[9270.8,77],[9305,111],[9339.3,77],[9373.7,30],[9373.9,111],[9407.8,77],[9441,77],[9474.5,30],[9474.6,111],[9507,77],[9540.9,90],[9574.4,30],[9574.5,111],[9608.6,77],[9642.6,30],[9642.7,77],[9676.7,111],[9710.9,77],[9744.2,30],[9744.2,77],[9778.4,111],[9812.5,77],[9846.5,30],[9846.6,111],[9880.7,77],[9914.9,77],[9948.5,30],[9948.6,111],[9982.6,77],[10015.9,77],[10049.2,30],[10049.2,77],[10082.8,69],[10082.8,8],[10116.3,77],[10149.7,30],[10149.8,77],[10183.7,77],[10218.2,77],[10251,30],[10251.1,77],[10284.1,77],[10317.7,77],[10352.3,30],[10352.3,69],[10352.4,8],[10386.5,77],[10420.5,77],[10454.8,30],[10455.1,77],[10488.6,77],[10521.9,90],[10555.8,30],[10556.1,77],[10590.5,77],[10624.7,77],[10658.7,30],[10658.9,77],[10692.4,69],[10692.5,8],[10726.2,77],[10760.1,30],[10760.2,77],[10794.4,77],[10828.5,77],[10861.6,30],[10861.8,77],[10894.7,77],[10928.6,77],[10962.6,30],[10962.8,77],[10995.4,77],[11029.6,77],[11063.4,30],[11063.5,77],[11097.3,111],[11131.7,77],[11165,30],[11165.1,77],[11199.2,111],[11233.1,77],[11266.3,30],[11266.4,77],[11299.6,111],[11333.2,77],[11367.2,30],[11367.3,77],[11401.5,111],[11435.5,77],[11469.4,30],[11469.5,111],[11503.6,77],[11537.7,90],[11571.7,30],[11571.8,111],[11606,77],[11639.6,77],[11672.8,30],[11672.9,111],[11695.8,77],[11730.1,77],[11764.1,30],[11764.2,111],[11798.4,77],[11832.2,77],[11864.9,30],[11865,111],[11898.6,77],[11932.3,77],[11966.4,30],[11966.5,97],[11966.5,14],[12000.2,77],[12033.5,77],[12067.5,30],[12067.6,77],[12101.2,77],[12135.4,69],[12135.5,8],[12168.8,30],[12168.9,69],[12169,8],[12203.1,77],[12237.2,77],[12270.6,30],[12270.8,63],[12270.8,14],[12304.9,77],[12339.7,77],[12373.6,30],[12373.8,77],[12406.9,77],[12441.1,77],[12475.2,30],[12475.4,77],[12509.1,77],[12543.1,30],[12543.4,90],[12577.4,77],[12611.4,77],[12644.6,30],[12644.8,77],[12678.6,77],[12712.7,77],[12746.3,30],[12746.4,77],[12779.6,77],[12812.5,77],[12846.1,30],[12846.2,77],[12880.3,77],[12914.3,77],[12948.3,30],[12948.4,77],[12982.7,77],[13016.9,77],[13049.9,30],[13050.1,77],[13083.6,111],[13117.1,77],[13151.1,30],[13151.2,77],[13184.2,111],[13217.4,77],[13250.7,30],[13250.8,77],[13284.9,111],[13301.8,77],[13336.6,77],[13370.1,30],[13370.1,111],[13403.8,77],[13438.1,77],[13472,30],[13472.2,111],[13505.2,77],[13539.6,90],[13572.7,30],[13572.8,111],[13607.6,77],[13641.8,77],[13675.5,30],[13675.6,111],[13709.7,77],[13742.2,30],[13742.3,63],[13742.3,14],[13776.5,111],[13810.3,77],[13844.3,30],[13844.6,77],[13877.7,111],[13911.7,77],[13944.4,30],[13944.6,111],[13978,77],[14012.1,77],[14046.6,30],[14046.8,77],[14080.9,77],[14115,77],[14148.5,30],[14148.7,63],[14148.7,14],[14182.8,77],[14217,77],[14250.6,30],[14250.7,77],[14284.9,77],[14319,77],[14353.5,30],[14353.5,77],[14387.7,77],[14421.9,77],[14456,30],[14456.2,77],[14490.3,77],[14523.9,119],[14558.3,30],[14558.3,77],[14592.5,77],[14626.6,77],[14660,30],[14660,77],[14694.1,77],[14728,77],[14761.6,30],[14761.6,69],[14761.7,8],[14771.2,73],[14771.3,192],[14805.5,77],[14839.7,77],[14873.5,30],[14873.7,77],[14907.8,77],[14941.4,77],[14974,30],[14974.1,77],[15007.3,77],[15040.5,77],[15074.5,30],[15074.6,77],[15106.9,111],[15140.6,77],[15172.8,30],[15172.9,77],[15180,14],[15182.8,41],[15183,177],[15192.7,77],[15226.5,77],[15260.2,41],[15260.2,77],[15294.3,111],[15328.5,77],[15361.7,41],[15361.9,63],[15362,14],[15395.9,111],[15429.6,77],[15463.6,41],[15463.8,77],[15496.8,111],[15531.1,90],[15565,41],[15565.2,111],[15599.4,77],[15632.9,77],[15666.2,41],[15666.4,111],[15700.3,77],[15734.5,77],[15768.5,41],[15768.6,111],[15802.6,77],[15835.4,77],[15869.2,41],[15869.3,111],[15903.2,77],[15935.8,77],[15969.9,41],[15970,111],[16003.5,77],[16037.5,77],[16071.6,41],[16071.8,77],[16105.7,77],[16138.7,77],[16172.9,41],[16172.9,77],[16207,77],[16241.4,77],[16275.4,41],[16275.4,69],[16275.5,8],[16309.6,77],[16343.6,41],[16343.8,77],[16377.8,77],[16411.9,77],[16445.8,41],[16445.9,77],[16480.1,77],[16513.3,90],[16547.1,41],[16547.2,77],[16580.9,77],[16615.2,77],[16648.6,41],[16648.7,77],[16682,77],[16715.1,77],[16748.6,41],[16748.7,77],[16781.9,77],[16816.1,77],[16850.1,41],[16850.2,77],[16884.8,77],[16919,77],[16952.9,41],[16953,77],[16987.7,77],[17021.1,77],[17054.5,41],[17054.5,69],[17054.6,8],[17088.6,103],[17088.7,8],[17122.5,77],[17155.9,41],[17156,77],[17190.1,111],[17224.3,77],[17258.3,41],[17258.4,69],[17258.4,8],[17292.3,111],[17326.3,77],[17360.7,41],[17360.8,77],[17395.7,111],[17429.9,77],[17463.9,41],[17464.1,77],[17498,111],[17531.3,90],[17564.9,41],[17565.1,111],[17599.1,77],[17633.1,77],[17666.7,41],[17666.7,111],[17700.9,77],[17734.8,77],[17768.8,41],[17768.9,103],[17769,8],[17803,77],[17837.1,77],[17871.1,41],[17871.1,111],[17904.8,77],[17939,77],[17973.1,41],[17973.2,111],[18006.8,77],[18040.7,77],[18074.5,41],[18074.5,63],[18074.5,14],[18108.6,77],[18142,41],[18142.1,77],[18175.5,77],[18209.1,69],[18209.2,8],[18242.3,41],[18242.4,77],[18276.6,77],[18310.2,77],[18344.2,41],[18344.3,77],[18378.4,77],[18412.6,77],[18446.6,41],[18446.7,77],[18479.9,69],[18479.9,8],[18513.5,90],[18546.9,41],[18547,77],[18576.3,77],[18609.3,77],[18642.9,41],[18643,77],[18677.1,77],[18711.2,77],[18744.2,41],[18744.3,77],[18777.3,77],[18787.6,18],[18787.8,759],[18797.1,77],[18813.8,77],[18823.3,77],[18856.4,41],[18856.4,77],[18890.3,77],[18924.4,77],[18958.4,41],[18958.5,77],[18992.4,77],[19026.6,77],[19060.6,41],[19060.7,77],[19094.9,111],[19129,77],[19162.9,41],[19163,69],[19163,8],[19197.1,111],[19231.5,77],[19264.7,41],[19264.9,69],[19265,8],[19299.2,111],[19332.9,77],[19366.9,41],[19367.1,29],[19367.1,48],[19401.3,111],[19435.4,77],[19469.5,41],[19469.6,63],[19469.6,48],[19503.8,77],[19537,90],[19571,41],[19571.1,111],[19605.3,77],[19639.3,77],[19672.5,41],[19672.5,111],[19716,77],[19746.1,41],[19746.1,77],[19779.7,111],[19813.8,77],[19847.7,41],[19847.7,97],[19847.8,14],[19881.8,77],[19916,77],[19950.1,41],[19950.1,103],[19950.2,8],[19984.4,77],[20018.7,77],[20052.7,41],[20052.9,77],[20087.2,77],[20121.5,77],[20155.6,41],[20155.8,69],[20155.8,8],[20189,77],[20223.5,77],[20257.4,41],[20257.6,63],[20257.6,14],[20291.2,69],[20291.3,8],[20324.7,77],[20358.1,41],[20358.2,77],[20392.4,77],[20426.5,77],[20460.6,41],[20460.7,77],[20494.8,77],[20528.9,90],[20562.9,41],[20563,77],[20597.2,77],[20630.5,77],[20664.6,41],[20664.7,77],[20699.5,77],[20733.6,77],[20766.9,41],[20767,77],[20800.2,77],[20834.6,77],[20867.8,41],[20868,77],[20901.6,77],[20935.6,77],[20968.8,41],[20969,29],[20969.1,48],[21003.3,69],[21003.4,8],[21037.4,77],[21071.4,41],[21071.6,69],[21071.6,8],[21105.8,111],[21139.9,77],[21174,41],[21174.1,77],[21207.6,111],[21218.4,77],[21251.8,41],[21251.9,77],[21286.2,111],[21320.1,77],[21354.2,41],[21354.3,63],[21354.3,14],[21388.7,111],[21422.8,77],[21456.1,41],[21456.2,77],[21490.3,111],[21524.4,90],[21558.6,41],[21558.9,77],[21593.2,111],[21627.5,77],[21661.5,41],[21661.6,63],[21661.6,48],[21695.9,77],[21730,77],[21763.4,41],[21763.4,103],[21763.5,8],[21797.6,77],[21831.4,77],[21865.4,41],[21865.4,97],[21865.4,14],[21899.6,77],[21933.7,77],[21967.8,41],[21967.8,111],[22002,77],[22036.1,77],[22070.2,41],[22070.2,29],[22070.3,48],[22104.2,77],[22138.5,77],[22171.9,41],[22172.1,77],[22206.3,77],[22240.6,77],[22273.1,41],[22273.2,77],[22307.3,77],[22341.1,77],[22375,41],[22375.1,69],[22375.1,8],[22409.4,77],[22443.5,41],[22443.6,63],[22443.7,14],[22477.9,77],[22511.2,90],[22544.7,41],[22544.8,77],[22579,77],[22612.5,77],[22646.4,41],[22646.5,77],[22679.8,77],[22713.9,77],[22748.5,41],[22748.6,69],[22748.6,8],[22782.7,77],[22817,77],[22850.2,41],[22850.3,63],[22850.3,14],[22883.7,77],[22894.2,18],[22894.2,117],[22894.3,673],[22894.3,8],[22928.5,77],[22962,41],[22962,77],[22996.5,77],[23030.5,77],[23064.6,41],[23064.7,29],[23064.7,48],[23098.2,111],[23133,77],[23166.4,41],[23166.5,77],[23193.2,18],[23193.2,65],[23193.3,561],[23193.3,8],[23295.2,41],[23397.5,41],[23499.7,41],[23601.5,41],[23703.1,41],[23805.7,41],[23908.4,41],[24010.6,41],[24048,41],[24048.1,245],[24057.7,77],[24083.3,77],[24083.7,39],[24092.3,73],[24092.4,108],[24092.4,14]]} diff --git a/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt new file mode 100644 index 00000000000..6064224f724 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-155-1-timed-turn.txt @@ -0,0 +1,26 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.155.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.155.1) ││ ││ model: loading /model to change ││ directory: ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯  › Ask Codex to do anything  ? for shortcuts[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l]0;scratch[?2026hgpt-6-sol default · ~/orca-lanes/sta8834/corpus/scratch[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l[?2026h[0 q╭[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026hMMMMMMMMM +╭──────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.155.1) │ +│ │ +│ model: gpt-6-sol medium /model to change │ +│ directory: ~/orca-lanes/sta8834/corpus/scratch │ +│ permissions: YOLO mode │ +╰──────────────────────────────────────────────────╯ + + Tip: New Use /fast to enable our fastest inference with increased plan usage.  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;scratch[?2026h[0 q [?25h[?2026l[?2026hMM + +• You have 3 usage limit resets available. Run /usage to use one.[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026hList the files here and summarizein2bullets.[0 q [?25h[?2026l[?2026hMMMM + + +› List the files here and summarize in 2 bullets. +Ask Codex to do anything[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ scratch[?2026h • Working (0s • esc to interrupt)  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch[0 q [?25h[?2026l]0;⠏ scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ scratch[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h · renaming... ⠙[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h1[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h2[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h3[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h4[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h5[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h6[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h7[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h8[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h9[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ renaming... ⠦ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ renaming... ⠧ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ renaming... ⠇ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h10s • esc to interupt)[0 q [?25h[?2026l]0;⠏ renaming... ⠏ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ renaming... ⠋ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ renaming... ⠙ | scratch[?2026h[0 q [?25h[?2026l[?2026hMM + +• I’ll inspect the current directory and summarize what’s there in two bullets.[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ renaming... ⠹ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ renaming... ⠸ | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ renaming... ⠼ | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ renaming... ⠴ | scratch[?2026h[0 q [?25h[?2026l]0;⠴ scratch]0;⠴ List and summarize files | scratch[?2026h•List ad summarize files[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h1[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h2[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h3[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h4[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h • Explored └ List rg --files -g '*' -g '!*/.git/*' • Working (14s • esc to interrupt)  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch · List and summarize files[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h5[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h6[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h7[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h8[0 q [?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026hMMM + +• Explored + └ List rg --files -g '*' -g '!*/.git/*' • Working (18s • esc to interrupt)  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch · List and summarize files[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026h•[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[0 q [?25h[?2026l[?2026hMM + +• - README.md and notes.txt are the two working files.  › Ask Codex to do anything  gpt-6-sol medium · ~/orca-lanes/sta8834/corpus/scratch · List and summarize files[0 q [?25h[?2026l]0;⠦ List and summarize files | scratch]0;⠧ List and summarize files | scratch]0;⠇ List and summarize files | scratch]0;⠏ List and summarize files | scratch]0;⠋ List and summarize files | scratch]0;⠙ List and summarize files | scratch]0;⠹ List and summarize files | scratch]0;⠸ List and summarize files | scratch]0;⠼ List and summarize files | scratch[?2026hM + - A .git/ directory is present, containing repository metadata and history.[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l[?2026h[0 q [?25h[?2026l]0;List and summarize files | scratch[?2026hMM + + done 9:43 PM[0 q [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json new file mode 100644 index 00000000000..f43deccb966 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T01:41:43.328Z", + "platform": "darwin", + "command": ["codex", "--no-daemon", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.1: launch with the update check on; the update dialog owns the screen. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt new file mode 100644 index 00000000000..4bd32773735 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-157-1-update-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Updat available · 0.157.1→0.158.0 Releasenotes:]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next versionenter continue · esc skip [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json b/src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json new file mode 100644 index 00000000000..a333bd29473 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-approval.meta.json @@ -0,0 +1,18 @@ +{ + "capturedAt": "2026-09-29T01:43:13.919Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "-a", + "on-request", + "-s", + "read-only" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0: read-only sandbox with on-request approvals; a command approval dialog owns the screen at the end. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-approval.txt b/src/main/runtime/__fixtures__/codex-0-158-0-approval.txt new file mode 100644 index 00000000000..925a5165fc5 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-approval.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingAll right. What have you got?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25lGPT-6-Sol default·~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026h[?25lmdim · ~/orca-lanes/sta834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lRun `touch approved.txt`.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› Run`touchapproved.txt`.Ask Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ scratch[?2026h[?25l•Working(0s • esc to interrupt)[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ scratch[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l·⠙[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l1[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l2[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l5[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l7[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l8[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l9[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l• I [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l’ll[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25lcreate[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l⠴[?25h[?2026l[?2026h[?25lthe[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l⠦[?25h[?2026l[?2026h[?25lfile[?25h[?2026l[?2026h[?25linthecurrent[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l⠧[?25h[?2026l[?2026h[?25lworkspace;[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l⠇[?25h[?2026l[?2026h[?25lthis[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25lrequires[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25l⠋[?25h[?2026l[?2026h[?25lwrite[?25h[?2026l[?2026h[?25laccess[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l⠙[?25h[?2026l[?2026h[?25lbeyond[?25h[?2026l[?2026h[?25lthe[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25l⠹[?25h[?2026l[?2026h[?25lread-only[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25lsandbox.[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l⠼[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l⠴[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l⠦[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l⠧[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l⠇[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25l⠏[?25h[?2026l[?2026h[?25l•Working(11s • esc to interrupt)[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l2[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;[ . ] Action Required | ⠸ | scratch[?2026h[?25l•Runningtouch approved.txtWould you like to run the following command?Environment:localReason:May I create approved.txt in the current workspace as requested?$touch approved.txt › 1. Yes, proceed (y)  2. Yes, and don't ask againforcommandsthatstartwith`touchapproved.txt`(p)3.No,andtellCodexwhattododifferently(esc)Press enter to confirm or esc to cancel [?2026l]0;[ . ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠹ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠹ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠹ | scratch]0;[ ! ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ ! ] Action Required | ⠏ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠋ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠙ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠹ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠸ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠼ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠴ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠦ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠧ | scratch[?2026h[?2026l]0;[ . ] Action Required | ⠇ | scratch[?2026h[?2026l]0;[ . ] Action Required | scratch]0;[ . ] Action Required | Create approved.txt | scratch[?2026h[?2026l]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch]0;[ . ] Action Required | Create approved.txt | scratch]0;[ ! ] Action Required | Create approved.txt | scratch \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json new file mode 100644 index 00000000000..2c3defda30a --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-29T01:46:36.717Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. .timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json new file mode 100644 index 00000000000..02745d043e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.timing.json @@ -0,0 +1 @@ +{"promptSentAtMs":7226.9,"chunks":[[70.2,28],[70.4,27],[321.3,80],[321.5,9],[324.7,14],[324.8,578],[325.8,494],[327,43],[329.1,43],[331.5,43],[333.5,43],[335.5,43],[340.4,216],[340.5,8],[342.3,43],[344,43],[346,43],[372.5,43],[374.1,43],[385.8,43],[1330.3,43],[1332.2,35],[1332.2,8],[1334.4,43],[1336.3,43],[1338.5,43],[1340.5,43],[2820.3,43],[2829.5,43],[2831.2,12],[2833.5,43],[2835.6,169],[2835.7,8],[2842.6,43],[2866,43],[2922.5,14],[2944.6,14],[2946.2,14],[2946.2,180],[2947.8,43],[2949.3,43],[3006.8,12],[3009.4,43],[3941,43],[4089.1,43],[6627.2,43],[6627.3,377],[6627.3,14],[6627.4,35],[6627.4,8],[6627.6,43],[6627.7,43],[6627.8,43],[6627.9,43],[6628,43],[6628.2,43],[6628.3,43],[6628.4,35],[6628.4,8],[6628.5,35],[6628.5,8],[6628.6,43],[6628.7,43],[6628.9,43],[6629,43],[6629.1,43],[6629.2,43],[6629.3,43],[6629.4,35],[6629.4,8],[6629.5,43],[6629.7,43],[6629.8,43],[6629.9,35],[6629.9,8],[6630,35],[6630,8],[6630.1,43],[6630.2,35],[6630.3,8],[6630.4,43],[6630.5,35],[6630.5,8],[6630.6,35],[6630.6,8],[6630.7,43],[6630.8,35],[6630.9,8],[6630.9,43],[6631.1,43],[6631.2,35],[6631.2,8],[6631.3,35],[6631.3,8],[6631.4,35],[6631.4,8],[6631.5,35],[6631.5,8],[6631.6,35],[6631.6,8],[6631.7,35],[6631.7,8],[6631.9,35],[6631.9,8],[6632,35],[6632,8],[6632.1,43],[6632.2,35],[6632.2,8],[6632.3,43],[6632.4,43],[6632.5,43],[6659.2,117],[6659.3,8],[7227.9,44],[7228.6,138],[7228.7,14],[7229,161],[7229.2,8],[7239.4,43],[7247.7,43],[7271.8,14],[7273,163],[7273.1,8],[7306.6,43],[7339.4,14],[7339.5,35],[7339.6,8],[7374.3,43],[7407.9,43],[7441.3,14],[7441.5,43],[7474.6,43],[7490.5,43],[7513.7,43],[7535.2,14],[7535.3,35],[7535.3,8],[7536.4,18],[7545.7,89],[7578.7,43],[7611.6,43],[7646,18],[7646.1,43],[7679.3,43],[7713.6,43],[7747,18],[7747.2,35],[7747.2,8],[7780,43],[7813.5,43],[7846.7,18],[7846.8,75],[7846.9,8],[7879.7,43],[7912.9,43],[7946.5,18],[7946.7,83],[7980.7,43],[8015.1,43],[8048.9,18],[8049.1,83],[8083.6,43],[8116.9,43],[8150.3,18],[8150.5,69],[8150.6,14],[8185.3,43],[8219.4,35],[8219.5,8],[8253.4,18],[8253.5,75],[8253.5,8],[8287.5,62],[8321.7,83],[8354.9,18],[8355,43],[8388.5,43],[8422.7,83],[8456.2,18],[8456.4,35],[8456.4,8],[8489.6,43],[8522.9,83],[8534.4,18],[8534.6,43],[8568.8,43],[8603.1,43],[8637.1,18],[8637.4,69],[8637.4,14],[8672.2,43],[8706.4,83],[8738.4,18],[8738.6,43],[8772.8,43],[8806.7,43],[8840,18],[8840.2,35],[8840.3,8],[8874.6,43],[8908,43],[8941.4,18],[8941.7,35],[8941.7,8],[8976.8,43],[9010.8,43],[9044.4,18],[9044.7,43],[9077.8,43],[9112.4,43],[9146.4,18],[9146.5,35],[9146.5,8],[9179.7,43],[9213.5,43],[9247,18],[9247.2,35],[9247.2,8],[9281.4,54],[9281.4,8],[9315.9,43],[9349.2,18],[9349.4,35],[9349.4,8],[9383.7,43],[9418,43],[9446.6,18],[9446.8,90],[9447,8],[9456.9,43],[9493.4,63],[9502.6,60],[9519.3,82],[9534.9,18],[9535.1,52],[9535.2,8],[9555.5,58],[9555.7,8],[9575.4,61],[9597.6,59],[9619.5,64],[9629.8,51],[9629.9,8],[9643,18],[9643.2,52],[9643.3,8],[9668.1,56],[9668.4,8],[9684.5,58],[9786.6,18],[9788.1,72],[9788.2,8],[9889.3,18],[9890.6,80],[9918.4,171],[9953.3,101],[9985.2,43],[10018.5,43],[10051.6,18],[10051.8,75],[10051.8,8],[10085.6,43],[10119.4,43],[10152.9,18],[10153.1,75],[10153.1,8],[10186.5,43],[10220.8,43],[10254.6,18],[10254.8,69],[10254.9,14],[10289.8,54],[10290,8],[10324.3,83],[10357.6,18],[10357.9,43],[10391.4,43],[10424,83],[10457.5,18],[10457.7,35],[10457.7,8],[10491,43],[10524.9,83],[10558.6,18],[10559,35],[10559.1,8],[10592.5,43],[10626.9,83],[10660.9,18],[10661.1,43],[10694.8,43],[10729.4,83],[10763.4,18],[10763.5,43],[10798.4,43],[10833.1,18],[10833.2,43],[10866.5,43],[10900.5,43],[10933.6,18],[10933.7,43],[10966.9,43],[11000.6,43],[11034.6,18],[11034.9,43],[11069.1,43],[11103,43],[11137.1,18],[11137.2,35],[11137.3,8],[11169.2,43],[11199,43],[11231.3,18],[11231.4,43],[11260.7,338],[11260.8,8],[11270.5,43],[11286.1,62],[11296.4,72],[11296.5,8],[11330.4,43],[11364.5,18],[11364.7,43],[11398.1,43],[11432.1,18],[11432.3,35],[11432.3,8],[11465.9,43],[11498.9,43],[11532.8,18],[11533,35],[11533,8],[11566.1,43],[11598.9,43],[11633,18],[11633.2,35],[11633.2,8],[11667.1,43],[11700.9,43],[11735,18],[11735.2,43],[11769.5,43],[11802.8,43],[11835.7,18],[11835.9,35],[11835.9,8],[11869.6,83],[11903.7,43],[11937.8,18],[11938.1,35],[11938.1,8],[11961.5,119],[11962.2,14],[11965.3,41],[11970.6,103],[12004.5,43],[12038.7,41],[12038.9,75],[12039,8],[12073.3,43],[12107.2,43],[12141,41],[12141.3,14],[12141.4,69],[12174.7,43],[12208.4,43],[12241.5,41],[12241.8,14],[12241.8,69],[12276.1,62],[12309.7,43],[12343.6,41],[12343.7,69],[12343.8,14],[12378,43],[12412.2,43],[12445.3,41],[12445.4,75],[12445.4,8],[12479.1,43],[12512.6,75],[12512.7,8],[12545.6,41],[12545.8,35],[12545.9,8],[12579.3,43],[12612.8,83],[12646.7,41],[12647,35],[12647,8],[12664.5,43],[12698.5,43],[12731.6,41],[12732,14],[12732,69],[12766.1,43],[12799.5,43],[12833.8,41],[12834.3,35],[12834.3,8],[12867.9,43],[12902.4,43],[12935.2,41],[12935.7,35],[12935.8,8],[12969.7,43],[13003.8,43],[13037.5,41],[13037.7,43],[13071.9,43],[13104.8,43],[13137.8,41],[13138,43],[13171.4,43],[13205.7,43],[13240.2,41],[13240.3,43],[13274.4,62],[13307.9,43],[13341.3,41],[13341.5,35],[13341.5,8],[13376.5,43],[13409.8,43],[13443.1,41],[13443.3,43],[13476.8,43],[13510.5,43],[13544.3,41],[13544.5,35],[13544.5,8],[13578.7,43],[13612.2,43],[13645.2,41],[13645.4,35],[13645.4,8],[13679.6,43],[13714.4,43],[13747.5,41],[13747.8,35],[13747.9,8],[13781.8,43],[13814.6,43],[13848,41],[13848.3,69],[13848.3,14],[13881.6,43],[13916.7,43],[13950.2,41],[13950.4,69],[13950.4,14],[13984.6,43],[14018.3,43],[14052.6,41],[14052.7,69],[14052.7,14],[14086.4,43],[14120.6,43],[14154.9,41],[14155,69],[14155,14],[14181.8,43],[14211.3,43],[14244.5,41],[14244.7,75],[14244.7,8],[14277.6,54],[14277.7,8],[14287.3,273],[14287.3,8],[14305.4,43],[14315.6,43],[14349.5,41],[14349.6,75],[14349.7,8],[14383.6,43],[14417.1,75],[14417.2,8],[14451.1,41],[14451.3,35],[14451.3,8],[14485.5,43],[14520.1,75],[14520.2,8],[14554.3,41],[14554.5,35],[14554.6,8],[14589.7,43],[14624.4,83],[14656.7,41],[14657,35],[14657,8],[14691.2,43],[14724.7,83],[14758.2,41],[14758.3,35],[14758.3,8],[14792.3,43],[14826.5,43],[14859.9,41],[14860,35],[14860,8],[14893.7,43],[14927.8,43],[14961.2,41],[14961.4,43],[14971.2,43],[14981.8,89],[14981.8,14],[14992.9,54],[14993,8],[15014.7,52],[15014.8,8],[15038.6,41],[15038.8,93],[15038.9,14],[15060.8,50],[15060.8,8],[15078.9,68],[15078.9,8],[15100.6,56],[15100.7,8],[15120.6,50],[15120.6,8],[15148.5,41],[15148.8,56],[15148.8,8],[15158.6,51],[15158.6,8],[15211.6,65],[15223,53],[15223.1,8],[15232,41],[15232.3,53],[15232.3,8],[15333.7,41],[15435.2,41],[15436.8,14],[15436.9,136],[15447.1,59],[15485.9,43],[15524.5,43],[15534.6,41],[15534.7,43],[15551,43],[15551.2,39],[15559.7,180]]} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt new file mode 100644 index 00000000000..f589f998bb8 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingShall we see where this goes?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch permissions: YOLO modeShall we see where this goes?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25lGPT-6-Sol default·~/orca-lanes/sta8834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ scratch]0;⠙ scratch[?2026h[?25lmdim · ~/orca-lanes/sta834/corpus/scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lList the files here and summarizein2bullets.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lAsk Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25l› Listthefileshereandsummarizein2bullets.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ scratch[?2026h[?25l•Working(0s • esc to interrupt)[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ scratch[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25l·⠧[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l1[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l2[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25l• I’ll [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25linspect[?25h[?2026l[?2026h[?25lthe[?25h[?2026l[?2026h[?25ldirectorycontents[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25land[?25h[?2026l[?2026h[?25lsummarize[?25h[?2026l[?2026h[?25lwhat[?25h[?2026l[?2026h[?25l’s[?25h[?2026l[?2026h[?25lpresent[?25h[?2026l[?2026h[?25lin[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25ltwo[?25h[?2026l[?2026h[?25lbullets[?25h[?2026l[?2026h[?25l.[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25l⠏[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25l⠋[?25h[?2026l[?2026h[?25l•Working(2s • esc to interrupt)[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l3[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | scratch[?2026h[?25h[?2026l[?2026h[?25l•Explored └ Listrg--files-g'*'-g'.*' + Show details [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ ⠋ | scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | scratch[?2026h[?25h[?2026l[?2026h[?25l•⠙[?25h[?2026l]0;⠙ scratch]0;⠙ List and summarize files | scratch[?2026h[?25lList and summarize files[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l5[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25l7[?25h[?2026l[?2026h[?25l Read README.md,notes.txt + Show details [?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ List and summarize files | scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠇ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠏ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l• •` [?25h[?2026l[?2026h[?25lREADME[?25h[?2026l[?2026h[?25l.md[?25h[?2026l]0;⠹ List and summarize files | scratch[?2026h[?25lREADME.md[?25h[?2026l[?2026h[?25l—[?25h[?2026l[?2026h[?25lcontainsthe[?25h[?2026l[?2026h[?25lheading[?25h[?2026l[?2026h[?25l“[?25h[?2026l]0;⠸ List and summarize files | scratch[?2026h[?25lscratch[?25h[?2026l[?2026h[?25l.”[?25h[?2026l[?2026h[?25l•`[?25h[?2026l[?2026h[?25lnotes[?25h[?2026l]0;⠼ List and summarize files | scratch[?2026h[?25l.txt[?25h[?2026l]0;⠴ List and summarize files | scratch]0;⠦ List and summarize files | scratch[?2026h[?25lnotes.txt —containsthetext“hello[?25h[?2026l[?2026h[?25l.”[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ List and summarize files | scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;List and summarize files | scratch[?2026h[?25l 9:46 PM [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json new file mode 100644 index 00000000000..a80d88f5af9 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-29T01:32:55.701Z", + "platform": "darwin", + "command": [ + "codex", + "--no-daemon", + "-c", + "check_for_update_on_startup=false", + "--dangerously-bypass-approvals-and-sandbox" + ], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0: launch in an untrusted folder; the recording ends on the unanswered trust dialog. STA-8834.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt new file mode 100644 index 00000000000..fd002b0fe04 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-158-0-trustprompt.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHello, world. Hello, you.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch permissions: YOLO modeHello, world. Hello, you.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[?1007h[?2026h[?25l Folder access/Users/xxxxxx/orca-lanes/sta8834/corpus/scratch  Trustthisfolder?Codexcanread,edit,andrunfileshere,subjecttoyourpermissionsettings.Foldersettingscan run code automatically,evenwithoutamodelrequest.Continueonlyifyoutrustthesefiles.Yourtrustdecisionwillbesaved.› 1. Trust and continue 2.Quitenter continue · esc quit [?2026l \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts new file mode 100644 index 00000000000..5b57656ab4a --- /dev/null +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -0,0 +1,56 @@ +// Replays captured PTY bytes the way onPtyData does, for suites asserting a rule on every frame. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection' +import { normalizeTerminalChunk } from './terminal-ansi-normalization' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { buildPreview } from './terminal-tail-state' +import { buildTerminalWaitText } from './terminal-wait-tail-state' + +const DEFAULT_CHUNK_CHARS = 64 + +export type TranscriptReplayFrame = { screenLines: string[]; waitText: string } + +export function readRuntimeFixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') +} + +/** A string is cut into fixed 64-char chunks; an array replays the recorded PTY chunks as-is. */ +export async function* replayTranscript( + data: string | readonly string[], + cols: number, + rows: number +): AsyncGenerator { + const chunks = typeof data === 'string' ? splitIntoChunks(data) : data + const emulator = new HeadlessEmulator({ cols, rows }) + let lines: string[] = [] + let partialLine = '' + let pendingAnsi = '' + let redrawCursor: ReturnType['redrawCursor'] = null + try { + for (const chunk of chunks) { + await emulator.write(chunk) + const normalized = normalizeTerminalChunk(chunk, pendingAnsi) + pendingAnsi = normalized.pendingAnsi + const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor) + lines = tail.lines + partialLine = tail.partialLine + redrawCursor = tail.redrawCursor + yield { + screenLines: projectTerminalVisibleLines(emulator).lines, + waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine)) + } + } + } finally { + emulator.dispose() + } +} + +function splitIntoChunks(data: string): string[] { + const chunks: string[] = [] + for (let offset = 0; offset < data.length; offset += DEFAULT_CHUNK_CHARS) { + chunks.push(data.slice(offset, offset + DEFAULT_CHUNK_CHARS)) + } + return chunks +} diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 6cb3c08179f..0cfcf548017 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -1,14 +1,11 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { HeadlessEmulator } from '../daemon/headless-emulator' import { createTranscriptPane } from './agent-transcript-pane-test-harness' -import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection' -import { normalizeTerminalChunk } from './terminal-ansi-normalization' -import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' -import { buildPreview } from './terminal-tail-state' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' import { isKnownReadyPromptBody, isKnownReadyPromptPreview } from './terminal-wait-detection' -import { buildTerminalWaitText } from './terminal-wait-tail-state' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -23,44 +20,14 @@ const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' const NO_DAEMON = 'codex-0157-no-daemon-effort-override' const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON] -const CHUNK_CHARS = 64 -function readFixture(name: string): string { - return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') -} - -type ReplayFrame = { screenLines: string[]; waitText: string } - -/** Feeds the bytes the way onPtyData does: one emulator grid, one line-folded wait text. */ -async function* replay(data: string, cols: number, rows: number): AsyncGenerator { - const emulator = new HeadlessEmulator({ cols, rows }) - let lines: string[] = [] - let partialLine = '' - let pendingAnsi = '' - let redrawCursor: ReturnType['redrawCursor'] = null - try { - for (let offset = 0; offset < data.length; offset += CHUNK_CHARS) { - const chunk = data.slice(offset, offset + CHUNK_CHARS) - await emulator.write(chunk) - const normalized = normalizeTerminalChunk(chunk, pendingAnsi) - pendingAnsi = normalized.pendingAnsi - const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor) - lines = tail.lines - partialLine = tail.partialLine - redrawCursor = tail.redrawCursor - yield { - screenLines: projectTerminalVisibleLines(emulator).lines, - waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine)) - } - } - } finally { - emulator.dispose() - } -} - -async function finalFrame(name: string, cols: number, rows: number): Promise { - let last: ReplayFrame | null = null - for await (const frame of replay(readFixture(name), cols, rows)) { +async function finalFrame( + name: string, + cols: number, + rows: number +): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { last = frame } if (!last) { @@ -89,8 +56,8 @@ describe('Codex 0.157 header readiness from captured bytes', () => { '%s: the screen never adds readiness while loading, and is ready at the final screen', async (name) => { let sawLoadingHeader = false - let last: ReplayFrame | null = null - for await (const frame of replay(readFixture(name), 120, 40)) { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { if (screenShowsLoadingHeader(frame.screenLines)) { sawLoadingHeader = true expect(isKnownReadyPromptBody('', 'codex', () => frame.screenLines)).toBe(false) @@ -113,7 +80,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { [60, 5] ])('at %ix%i the screen never takes readiness away from the text rules', (cols, rows) => { it.each(ALL_FIXTURES)('%s', async (name) => { - for await (const frame of replay(readFixture(name), cols, rows)) { + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { if (isKnownReadyPromptPreview(frame.waitText)) { expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( true @@ -191,7 +158,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => { paneTitle: 'Terminal', foregroundProcess: 'codex', launchAgent: 'codex', - data: readFixture(name), + data: readRuntimeFixture(name), size }) } diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts new file mode 100644 index 00000000000..50052365905 --- /dev/null +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -0,0 +1,372 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' +import { isCodexComposerReadyScreen } from './codex-terminal-readiness' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isQuietReadyScreenBody +} from './terminal-wait-detection' +import { + evaluateTuiIdle, + hasQuietReadyScreen, + isTuiIdleReadyVerdict, + type TuiIdleEvaluationInput +} from './tui-idle-evidence' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const QUIESCENCE_MS = 3000 + +// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn, +// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's. +const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn'] +const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES] +const DIALOG_FIXTURES = [ + 'codex-0-157-1-update-dialog', + 'codex-0-158-0-approval', + 'codex-0-158-0-trustprompt' +] +const STA_8628_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override' +] + +const BUSY_STATUS_RE = /to interrupt\)/ +const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m +const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m +const DIALOG_RE = + /update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/ + +function screenOf(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n').toLowerCase() +} + +async function collectFrames( + data: string | readonly string[], + cols = 120, + rows = 40 +): Promise { + const frames: TranscriptReplayFrame[] = [] + for await (const frame of replayTranscript(data, cols, rows)) { + frames.push(frame) + } + return frames +} + +describe('Codex composer ready screen, frame by frame', () => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])( + '%s: never ready while loading, mid-turn, or under a drawn dialog', + async (name) => { + const seen = { loading: 0, busy: 0, dialog: 0 } + for (const frame of await collectFrames(readRuntimeFixture(name))) { + const screen = screenOf(frame) + // Why only once the composer is gone: a half-drawn dialog keeps it for a few frames, + // which quiescence absorbs; a drawn dialog replaces it. + const kind = HEADER_LOADING_RE.test(screen) + ? 'loading' + : BUSY_STATUS_RE.test(screen) + ? 'busy' + : DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen) + ? 'dialog' + : null + if (kind) { + seen[kind] += 1 + expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({ + kind, + ready: false + }) + } + } + // Presence preconditions: each fixture exercises the states it was recorded for. + expect(seen.loading).toBeGreaterThan(0) + if (DIALOG_FIXTURES.includes(name)) { + expect(seen.dialog).toBeGreaterThan(0) + } else { + expect(seen.busy).toBeGreaterThan(0) + } + }, + 30_000 + ) + + it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])( + '%s: ready on the final idle screen', + async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true) + } + ) + + it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => { + const frames = await collectFrames(readRuntimeFixture(name)) + expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false) + }) + + it('is not ready without a live screen', () => { + expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false) + }) + + it('reads the placeholder only as the composer line, not quoted mid-line', () => { + const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$'] + expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false) + }) + + it('reads the busy row only above the composer, not a status row quoted in the answer', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› what does the status row look like mid-turn?', + '', + '• It reads like this:', + '', + ' • Working (0s • esc to interrupt)', + '', + ' The timer counts up until the turn ends, and the row', + ' disappears once the answer is complete.', + '', + '› Ask Codex to do anything', + '', + ' gpt-5.6-sol medium · ~/repo', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + const composer = screenLines.indexOf('› Ask Codex to do anything') + const busy = [ + ...screenLines.slice(0, composer), + '• Working (3s • esc to interrupt)', + ' └ Tip: start a fresh idea with /new', + '', + ...screenLines.slice(composer) + ] + expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false) + }) + + it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => { + // Live 0.158 screen after a worker answered with a status row verbatim (STA-8834). + const screenLines = [ + ' === TASK ===', + ' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.', + '• • Working (0s • esc to interrupt)', + '• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…', + ' └ Sent msg_ff7be3f25d7e', + ' + Show details', + '• • Working (0s • esc to interrupt)', + ' 11:15 PM', + '› Ask Codex to do anything', + ' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome', + ' ? for shortcuts' + ] + expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true) + }) + + it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => { + const screenLines = [ + '>_ OpenAI Codex (v0.158.0)', + ' ~/repo', + '', + '› tidy the README', + '', + '• Done. Would you like to proceed with the changelog too?', + '', + '› Ask Codex to do anything', + '', + ' ? for shortcuts' + ] + const waitText = screenLines.join('\n') + const verdict = evaluateTuiIdle({ + record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText), + readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText, 'codex', () => screenLines), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText, 'codex', () => screenLines), + agent: 'codex', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + }) + expect(verdict.kind).toBe('ready-strong') + }) +}) + +type Timing = { promptSentAtMs: number; chunks: [number, number][] } + +function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { + const data = readRuntimeFixture(name) + const timing: Timing = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') + ) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + expect(offset).toBe(data.length) + return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } +} + +describe('the quiet lane over recorded chunk timing (default animations)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(TIMED_FIXTURES)( + '%s: never settles mid-turn, and settles once the finished turn is quiet', + async (name) => { + const { chunks, times, promptAt } = readTimedFixture(name) + const frames = await collectFrames(chunks) + // Why after the replay: the emulator's write flush runs on real timers. + vi.useFakeTimers() + const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame))) + const firstTurnChunk = times.findIndex((at) => at >= promptAt) + expect(lastBusy).toBeGreaterThan(firstTurnChunk) + // The latest moment each frame stays on screen: just before the next chunk lands. + const settlesAt = (index: number, now: number): boolean => { + vi.setSystemTime(now) + return hasQuietReadyScreen( + { lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null }, + 'codex', + () => + isQuietReadyScreenBody( + frames[index]!.waitText, + 'codex', + () => frames[index]!.screenLines + ), + QUIESCENCE_MS + ) + } + let readyBodyMidTurn = 0 + for (let index = firstTurnChunk; index <= lastBusy; index += 1) { + if (isCodexComposerReadyScreen(screenOf(frames[index]!))) { + readyBodyMidTurn += 1 + } + expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({ + index, + settles: false + }) + } + // Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing. + expect(readyBodyMidTurn).toBeGreaterThan(0) + const last = frames.length - 1 + // Why +1: the fake clock keeps whole milliseconds; the recorded times do not. + expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true) + }, + 120_000 + ) +}) + +describe('never less ready than origin/main', () => { + const records = [ + { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null }, + { lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' }, + { lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' } + ] + describe.each([ + [120, 40], + [80, 24] + ])('at %ix%i', (cols, rows) => { + it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])( + '%s', + async (name) => { + for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) { + for (const agent of ['codex', null] as const) { + const base = { + readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines), + agent, + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + } satisfies Omit + for (const record of records) { + const before = evaluateTuiIdle({ + ...base, + record, + // Why a withheld screen: that leaves exactly main's Muse lane, the only widening. + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => null) + }) + const after = evaluateTuiIdle({ + ...base, + record, + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines) + }) + if (isTuiIdleReadyVerdict(before)) { + expect(isTuiIdleReadyVerdict(after)).toBe(true) + } + } + } + } + }, + 60_000 + ) + }) +}) + +describe('agent gate', () => { + const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts'] + + it("never reads another agent's screen, even one showing Codex's placeholder", () => { + const readScreenLines = vi.fn(() => placeholderScreen) + for (const agent of ['claude', 'muse', 'gemini'] as const) { + expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false) + } + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) + + it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => { + const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ '] + const readScreenLines = vi.fn(() => catted) + expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true) + }) +}) + +describe('through the runtime', () => { + // Why 0.158 alone: its header carries no `model:`, so only this lane settles it. + const CODEX_0158 = 'codex-0-158-0-timed-turn' + async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: launchAgent, + launchAgent, + data: readRuntimeFixture(name), + size + }) + } + + // Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. + it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 15_000) + + it('keeps a Claude pane showing the same screen pending', async () => { + const { runtime, handle } = await pane(CODEX_0158, 'claude', { + cols: 120, + rows: 40 + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) +}) diff --git a/src/main/runtime/codex-terminal-readiness.ts b/src/main/runtime/codex-terminal-readiness.ts new file mode 100644 index 00000000000..3b15c3359df --- /dev/null +++ b/src/main/runtime/codex-terminal-readiness.ts @@ -0,0 +1,63 @@ +// Why both shapes: 0.150-0.157 paint `model: loading` in a box, 0.158 a bare `loading` under the title. +const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m +// Why a line cap: 0.158 draws no box, so nothing else ends its header before the chat. +const CODEX_HEADER_LINES = 6 +// Why the whole line: a pager, a `cat`ed transcript, or chat can quote the placeholder mid-line. +const CODEX_EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/ +// Why not "working": reasoning summaries replace it, and a remapped key still ends this way. +const CODEX_BUSY_STATUS_MARKER = 'to interrupt)' +// Why only a tip: it is the one line Codex draws between its status row and the composer (120x40 corpus). +const CODEX_STATUS_TIP_PREFIX = '└ tip:' + +// Why the header only: chat below it can mention "OpenAI Codex" or `model: loading`. +function findCodexHeader(screen: string): { index: number; text: string } | null { + const index = screen.indexOf('openai codex') + if (index === -1) { + return null + } + const boxEnd = screen.indexOf('╰', index) + const text = screen + .slice(index, boxEnd === -1 ? undefined : boxEnd) + .split('\n', CODEX_HEADER_LINES) + .join('\n') + return { index, text } +} + +/** Tier 1: the 0.150-0.157 header, which only a grid reassembles (see isKnownReadyPromptBody). */ +export function findCodexScreenReadyPromptIndex(screen: string): number | null { + const header = findCodexHeader(screen) + return header !== null && + header.text.includes('model:') && + header.text.includes('directory:') && + !CODEX_HEADER_LOADING_RE.test(header.text) + ? header.index + : null +} + +/** + * Tier 1b, codex panes only: the empty composer with no busy status row just above it and no + * header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header + * away, so this is its only version-stable rest body. No dialog check: every Codex dialog + * replaces the composer, while an answer ending "Would you like to…?" must not block the lane. + * The mid-turn guard is the caller's quiescence, fed by the ~100 ms title spinner and status + * timer; `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a + * `tui.terminal_title` without activity/spinner removes it. + */ +export function isCodexComposerReadyScreen(screen: string): boolean { + const lines = screen.split('\n') + const composer = lines.findLastIndex((line) => CODEX_EMPTY_COMPOSER_RE.test(line)) + if (composer === -1 || hasBusyStatusRowAbove(lines, composer)) { + return false + } + const header = findCodexHeader(screen) + return header === null || !CODEX_HEADER_LOADING_RE.test(header.text) +} + +// Why only the row above the composer: a finished answer (or one above 0.158's timestamp) can quote it. +function hasBusyStatusRowAbove(lines: readonly string[], composer: number): boolean { + const above = lines.slice(0, composer).filter((line) => line.trim() !== '') + const row = above.at(-1)?.trimStart().startsWith(CODEX_STATUS_TIP_PREFIX) + ? above.at(-2) + : above.at(-1) + return row?.includes(CODEX_BUSY_STATUS_MARKER) ?? false +} diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index bb102a3e7b8..1540eef2d5f 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -8,6 +8,10 @@ import { import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' +import { + findCodexScreenReadyPromptIndex, + isCodexComposerReadyScreen +} from './codex-terminal-readiness' import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i @@ -72,12 +76,29 @@ export function isKnownReadyPromptBody( if (agent !== null && agent !== 'codex') { return false } - const screenLines = readScreenLines() - if (screenLines === null) { - return false + const screen = readScreen(readScreenLines) + return screen !== null && isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) +} + +/** + * Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1 + * it only proves the TUI is up, so the ranking holds it to quiescence. + * Why codex panes only: a `cat`ed transcript or pager in an unknown pane can show the composer. + */ +export function isQuietReadyScreenBody( + waitText: string, + agent: TuiAgent | null, + readScreenLines: () => readonly string[] | null +): boolean { + if (agent === 'codex') { + const screen = readScreen(readScreenLines) + return screen !== null && isCodexComposerReadyScreen(screen) } - const screen = screenLines.join('\n').toLowerCase() - return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) + return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText) +} + +function readScreen(readScreenLines: () => readonly string[] | null): string | null { + return readScreenLines()?.join('\n').toLowerCase() ?? null } function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean { @@ -88,8 +109,6 @@ function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): return blockedSignal === null || blockedSignal.index <= readyIndex } -// Why separate from isKnownReadyPromptPreview: that one settles tier 1 immediately, while -// a Muse ready screen only proves the TUI is up — the ranking holds it to quiescence. export function isMuseReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized)) @@ -179,24 +198,6 @@ function findCodexReadyPromptIndex(normalized: string): number | null { return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null } -const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading/ - -// Why the header box only: chat below it can mention "OpenAI Codex" or `model: loading`. -// Why `loading`: a header still loading is not ready; the screen must not add readiness early. -function findCodexScreenReadyPromptIndex(screen: string): number | null { - const headerIndex = screen.indexOf('openai codex') - if (headerIndex === -1) { - return null - } - const boxEnd = screen.indexOf('╰', headerIndex) - const header = screen.slice(headerIndex, boxEnd === -1 ? undefined : boxEnd) - return header.includes('model:') && - header.includes('directory:') && - !CODEX_HEADER_LOADING_RE.test(header) - ? headerIndex - : null -} - export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index de9e955099c..ee8e908c61b 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -7,8 +7,8 @@ import { isKnownReadyPromptBody } from './terminal-wait-detection' import { evaluateTuiIdle, hasFreshDoneFirstPartyStatus, + hasQuietReadyScreen, isTuiIdleReadyVerdict, - hasQuietMuseReadyPrompt, nameOnlyIdleNeedsCorroboration, type TuiIdleEvaluationInput, type TuiIdleEvidenceRecord @@ -30,7 +30,7 @@ function input(overrides: Partial = {}): TuiIdleEvaluati record: record(), readTailBlockedReason: () => null, readPositiveBodyEvidence: () => false, - readMuseReadyBodyEvidence: () => true, + readQuietReadyBodyEvidence: () => true, agent: 'muse', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -38,39 +38,38 @@ function input(overrides: Partial = {}): TuiIdleEvaluati } } -describe('hasQuietMuseReadyPrompt', () => { +describe('hasQuietReadyScreen', () => { it('settles a Muse ready screen once the stream has gone quiet', () => { - expect(hasQuietMuseReadyPrompt(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietReadyScreen(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true) }) it('refuses while the pane is still streaming', () => { expect( - hasQuietMuseReadyPrompt( - record({ lastOutputAt: Date.now() }), - 'muse', - () => true, - QUIESCENCE_MS - ) + hasQuietReadyScreen(record({ lastOutputAt: Date.now() }), 'muse', () => true, QUIESCENCE_MS) ).toBe(false) }) it('refuses without an output clock, like the tier-3 lane', () => { expect( - hasQuietMuseReadyPrompt(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS) + hasQuietReadyScreen(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS) ).toBe(false) }) it('refuses without a ready screen', () => { - expect(hasQuietMuseReadyPrompt(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) + expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) }) it('covers adopted panes that carry no launch metadata', () => { - expect(hasQuietMuseReadyPrompt(record(), null, () => true, QUIESCENCE_MS)).toBe(true) - expect(hasQuietMuseReadyPrompt(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietReadyScreen(record(), null, () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietReadyScreen(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true) }) - it('refuses another agent quoting Muse in its scrollback', () => { - expect(hasQuietMuseReadyPrompt(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(false) + it('covers Codex, whose title carries no rest signal once idle', () => { + expect(hasQuietReadyScreen(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(true) + }) + + it('refuses another agent quoting Muse or Codex in its scrollback', () => { + expect(hasQuietReadyScreen(record(), 'claude', () => true, QUIESCENCE_MS)).toBe(false) }) }) @@ -87,7 +86,7 @@ describe('evaluateTuiIdle muse lane', () => { }) describe('evaluateTuiIdle ranking', () => { - const noMuse = { readMuseReadyBodyEvidence: () => false } + const noMuse = { readQuietReadyBodyEvidence: () => false } it('ranks a blocking prompt in the tail above an explicit idle title', () => { const verdict = evaluateTuiIdle( @@ -201,13 +200,14 @@ describe('rest signal agrees with the lanes that can settle a wait', () => { screenRead = true return null }) - const museBody = hasQuietMuseReadyPrompt(record(), agent, () => true, QUIESCENCE_MS) - if (museBody) { - expect(signal).toBe('ready-body') + const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS) + // Why not only ready-body: Codex keeps its stronger hook-driven title beside this lane. + if (quietScreenBody) { + expect(signal).not.toBe('none') } // Why a screen read also counts: Qoder's ready body is its composer, read by identity. if (signal === 'ready-body') { - expect(museBody || screenRead).toBe(true) + expect(quietScreenBody || screenRead).toBe(true) } if (signal !== 'none') { return @@ -241,7 +241,7 @@ describe('a DSH pane settles tui-idle on its own hook', () => { record: { lastAgentStatus: null, lastOutputAt: null, lastOscTitle: '\u2726 \u{1F40B} repo' }, rendererTitle: undefined, readPositiveBodyEvidence: () => false, - readMuseReadyBodyEvidence: () => false, + readQuietReadyBodyEvidence: () => false, readTailBlockedReason: () => null, agent: 'dsh' as const, firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index c9cd6675d38..cb7dc13714b 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -14,7 +14,7 @@ import { detectExplicitIdleStatusFromTitle, detectTerminalWaitBlockedReason, isKnownReadyPromptBody, - isMuseReadyPromptPreview + isQuietReadyScreenBody } from './terminal-wait-detection' /** @@ -29,8 +29,8 @@ import { * 0. BLOCKED — the tail shows a prompt waiting on the user. * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. - * 1b. MUSE — Muse emits no title signal at all, so its ready-screen body stands in - * for the strong evidence, believed only once the stream has gone quiet. + * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, so their + * ready-screen body stands in for the strong evidence, believed only once quiet. * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting, or a working title. The agent's own account of itself outranks anything * inferred. @@ -187,8 +187,8 @@ export type TuiIdleEvaluationInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean - /** Tier 1b body evidence: a Muse ready screen. Thunk for the same reason as above. */ - readMuseReadyBodyEvidence: () => boolean + /** Tier 1b body evidence: a Muse or Codex ready screen. Thunk for the same reason as above. */ + readQuietReadyBodyEvidence: () => boolean agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number @@ -207,34 +207,34 @@ const READY_STRONG: TuiIdleVerdict = { kind: 'ready-strong' } const READY_WEAK: TuiIdleVerdict = { kind: 'ready-weak' } const WORKING: TuiIdleVerdict = { kind: 'working' } +const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex']) + /** - * Tier 1b: a Muse ready screen in the body, believed only once the stream has gone quiet. + * Tier 1b: a ready screen in the body, believed only once the stream has gone quiet. * - * Muse is the one agent with no title signal at all — its OSC title is the bare cwd and - * never changes — so neither the explicit-idle nor the sustained-title lane can fire. - * The ready screen proves the TUI is up; the quiescence demand keeps a mid-turn - * streaming pane from satisfying, mirroring the codex tier-3 lane's - * positive-evidence-plus-quiet shape. Scoped to Muse and agent-unknown panes: another - * agent's scrollback quoting Muse must not settle its wait. + * Muse's OSC title is the bare cwd and never changes, and an idle Codex titles its pane with + * the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the + * sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence + * demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's + * positive-evidence-plus-quiet shape. Scoped to those agents and agent-unknown panes (which + * read only Muse's screen): another agent's scrollback quoting them must not settle its wait. */ -export function hasQuietMuseReadyPrompt( +export function hasQuietReadyScreen( record: TuiIdleEvidenceRecord, agent: TuiAgent | null | undefined, readBodyEvidence: () => boolean, quiescenceMs: number ): boolean { - if (agent !== null && agent !== undefined && agent !== 'muse') { - return false - } - if (!readBodyEvidence()) { + if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent)) { return false } // Why: same rule as the tier-3 lane — without an output clock there is no // corroboration available, so hold out instead of settling. - if (record.lastOutputAt === null) { + if (record.lastOutputAt === null || Date.now() - record.lastOutputAt < quiescenceMs) { return false } - return Date.now() - record.lastOutputAt >= quiescenceMs + // Why last: a streaming pane never pays for the screen projection. + return readBodyEvidence() } /** The one place the tiers are combined; every settle site branches only on the verdict. */ @@ -273,11 +273,13 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { : { kind: 'pending', quietForeground: 'closed' } } // Why after the veto: a first-party working account outranks inferred body evidence. + // Why before the working title: Codex can leave a stale spinner title after a turn, and a + // live spinner emits output every ~100 ms, so a spinning pane is never quiet here. if ( - hasQuietMuseReadyPrompt( + hasQuietReadyScreen( input.record, input.agent, - input.readMuseReadyBodyEvidence, + input.readQuietReadyBodyEvidence, input.quiescenceMs ) ) { @@ -328,7 +330,8 @@ export function leafTuiIdleEvidence( rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), - readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), agent, firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: source.quiescenceMs @@ -348,7 +351,8 @@ export function ptyTuiIdleEvidence( readPositiveBodyEvidence: () => (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), - readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()), + readQuietReadyBodyEvidence: () => + isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), agent, firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: source.quiescenceMs From 18bd9cb1f65c4c8d67719a9e1b171bdae1ac4505 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:47:23 -0700 Subject: [PATCH 339/387] test(status-bar): cover re-probing a roomier level after the tightest level's fit moves (#23793) --- .../components/status-bar/status-bar-density.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/renderer/src/components/status-bar/status-bar-density.test.ts b/src/renderer/src/components/status-bar/status-bar-density.test.ts index 668f5c90a9e..1ddcbaf35f0 100644 --- a/src/renderer/src/components/status-bar/status-bar-density.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-density.test.ts @@ -70,6 +70,17 @@ describe('recordStatusBarDensityWidth', () => { ).toEqual([w(700), w(600), w(500), w(600, 430), undefined]) }) + it('re-probes a roomier collapsing level once the tightest level sees its fit move', () => { + // Level 3's stale fit (450) kept the bar at level 4; fewer urgent chips now let it fit. + const settled = [w(700), w(600), w(500), w(600, 450), w(500, 400)] + expect(pickStatusBarDensityLevel(settled, 420)).toBe(TIGHTEST) + const remeasured = recordStatusBarDensityWidth(settled, TIGHTEST, w(500, 380)) + expect(remeasured).toEqual([w(700), w(600), w(500), undefined, w(500, 380)]) + expect(pickStatusBarDensityLevel(remeasured, 420)).toBe(3) + const probed = recordStatusBarDensityWidth(remeasured, 3, w(600, 410)) + expect(pickStatusBarDensityLevel(probed, 420)).toBe(3) + }) + it('keeps other levels on a sub-pixel re-measure', () => { expect(recordStatusBarDensityWidth([w(700), w(600), w(500)], 1, w(600.5))).toEqual([ w(700), From 7445feaca88f5097d9c0a580c798dc2a718a7d54 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:07:04 -0700 Subject: [PATCH 340/387] fix(terminal): only diagnose disk exhaustion from capacity errors Merged after required checks passed. --- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 7 ++- src/main/ipc/pty/runtime/spawn-commit.ts | 4 +- .../pty-spawn-replacement-durability.test.ts | 40 +++++++++++--- ...ofile-state-writer-capacity-errors.test.ts | 47 ++++++++++++++++ .../profile-state-writer-errors.ts | 3 +- .../terminal-pane/TerminalPaneSurface.tsx | 9 ++-- .../TerminalSessionStateSaveFailureDialog.tsx | 54 +++++++++++-------- .../use-terminal-pane-foundation.ts | 8 +-- .../use-terminal-pane-startup-actions.ts | 4 +- .../use-terminal-pane-title-state.ts | 4 +- .../src/i18n/en-runtime-required.json | 4 ++ src/renderer/src/i18n/locales/en.json | 7 +++ src/shared/storage-capacity-error.ts | 25 +++++++++ ...erminal-session-state-save-failure.test.ts | 44 +++++++++++++++ .../terminal-session-state-save-failure.ts | 14 +++-- 15 files changed, 226 insertions(+), 48 deletions(-) create mode 100644 src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts create mode 100644 src/shared/storage-capacity-error.ts create mode 100644 src/shared/terminal-session-state-save-failure.test.ts diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 3740e1489a0..46fd8e714bb 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -32,7 +32,7 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ it.each( ['ipc', 'runtime'].flatMap((controller) => - ['save', 'shutdown'].map((replacementDuring) => ({ controller, replacementDuring })) + ['save', 'shutdown'].flatMap((replacementDuring) => + ['profile-state-writer-exit', 'ENOSPC'].map((code) => ({ + controller, + replacementDuring, + code + })) + ) ) )( - 'preserves a successor during $replacementDuring when the predecessor $controller save fails', - async ({ controller, replacementDuring }) => { + 'preserves a successor during $replacementDuring when the predecessor $controller save fails with $code', + async ({ controller, replacementDuring, code }) => { const { store, authority } = await fixture() const runtime = new OrcaRuntimeService(store) runtime.onPtySpawned(binding.ptyId, binding.incarnationId) @@ -70,10 +77,18 @@ it.each( commit = () => commitRuntimePtySpawn(ctx) } const gate = authority.pause() - const pending = expect(commit()).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + const pending = commit().catch((error: unknown) => { + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) { + throw error + } + expect(error.message).toContain('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + expect(isTerminalSessionStorageCapacityFailure(error.message)).toBe(code === 'ENOSPC') + return 'rejected' + }) await gate.started.promise if (replacementDuring === 'shutdown') { - gate.finish.reject(new Error('disk full')) + gate.finish.reject(Object.assign(new Error('worker exited'), { code })) await shutdownStarted.promise } await runtime.onPtyExit(binding.ptyId, 0, binding.incarnationId, { providerExitObserved: true }) @@ -81,10 +96,10 @@ it.each( ptyIncarnationById.set(binding.ptyId, replacementIncarnation) ptyOwnership.set(binding.ptyId, 'successor-host') if (replacementDuring === 'save') { - gate.finish.reject(new Error('disk full')) + gate.finish.reject(Object.assign(new Error('worker exited'), { code })) } shutdownFinished.resolve() - await pending + expect(await pending).toBe('rejected') if (replacementDuring === 'save') { expect(shutdown).not.toHaveBeenCalled() } else { @@ -144,3 +159,14 @@ it.each(['ipc', 'runtime'])( }) } ) + +it('does not label an IPC binding ownership refusal as a storage failure', async () => { + const { store } = await fixture() + const runtime = new OrcaRuntimeService(store) + const deps = createPtySpawnCommitDependencies(runtime, store) + const ctx = createPtyIpcSpawnState(deps, { ...binding, cols: 80, rows: 24 }) + ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId } + ctx.validatedLeafId = binding.leafId + vi.spyOn(store, 'persistPtyBinding').mockResolvedValue(false) + await expect(commitPtyIpcSpawn(ctx)).rejects.toThrow('terminal_pane_owner_changed') +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts b/src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts new file mode 100644 index 00000000000..f537af8833c --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-capacity-errors.test.ts @@ -0,0 +1,47 @@ +import Database from '../../sqlite/sync-database' +import { expect, it } from 'vitest' +import { + decodeProfileStateWriterError, + encodeProfileStateWriterError +} from './profile-state-writer-errors' +import { + createTerminalSessionStateSaveFailureMessage, + isTerminalSessionStorageCapacityFailure +} from '../../../shared/terminal-session-state-save-failure' + +it.each(['ENOSPC', 'EDQUOT', 'SQLITE_FULL', 'EACCES', 'SQLITE_BUSY'])( + 'preserves capacity evidence across the writer boundary for %s', + (code) => { + const error = new Error('private path must not cross the worker boundary', { + cause: Object.assign(new Error('private details'), { code }) + }) + const encoded = encodeProfileStateWriterError(error) + expect(encoded.message).toBe('Profile state persistence failed') + const decoded = decodeProfileStateWriterError(JSON.parse(JSON.stringify(encoded))) + const capacity = ['ENOSPC', 'EDQUOT', 'SQLITE_FULL'].includes(code) + expect( + isTerminalSessionStorageCapacityFailure(createTerminalSessionStateSaveFailureMessage(decoded)) + ).toBe(capacity) + } +) + +it('classifies a real SQLite capacity failure after worker serialization', () => { + const db = new Database(':memory:') + try { + db.exec('PRAGMA page_size=512; CREATE TABLE writes(data BLOB); PRAGMA max_page_count=2') + let failure: unknown + try { + db.exec('INSERT INTO writes VALUES (zeroblob(4096))') + } catch (error) { + failure = error + } + expect(failure).toBeDefined() + const decoded = decodeProfileStateWriterError(encodeProfileStateWriterError(failure)) + expect( + isTerminalSessionStorageCapacityFailure(createTerminalSessionStateSaveFailureMessage(decoded)) + ).toBe(true) + db.exec("INSERT INTO writes VALUES ('small')") + } finally { + db.close() + } +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-errors.ts b/src/main/persistence/profile-state/profile-state-writer-errors.ts index 28237862ce2..afb30d520c8 100644 --- a/src/main/persistence/profile-state/profile-state-writer-errors.ts +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -1,3 +1,4 @@ +import { storageCapacityErrorCode } from '../../../shared/storage-capacity-error' import { ProfileStateDocumentCorruptionError, ProfileStateRevisionConflictError @@ -64,7 +65,7 @@ export function encodeProfileStateWriterError( return { code: error.code, message: error.message, outcome } } return { - code: 'profile-state-write-failed', + code: storageCapacityErrorCode(error) ?? 'profile-state-write-failed', message: 'Profile state persistence failed', outcome } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 0b5da641c95..7eda851e7e9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -92,14 +92,14 @@ export function TerminalPaneSurface({ searchStateRef, searchInputRef, sessionRestoredBannerPaneIds, - sessionStateSaveFailureOpen, + sessionStateSaveFailureMessage, setAgentSessionContinuation, setAgentSessionFork, setContainerRef, setQuickCommandEditorOpen, setRenameValue, setSearchOpen, - setSessionStateSaveFailureOpen, + setSessionStateSaveFailureMessage, showSplitButton, showSshReconnectOverlay, splitTerminalPaneFromHeader, @@ -201,8 +201,9 @@ export function TerminalPaneSurface({ {isActive && ( setSessionStateSaveFailureOpen(false)} + open={sessionStateSaveFailureMessage !== null} + failureMessage={sessionStateSaveFailureMessage ?? ''} + onDismiss={() => setSessionStateSaveFailureMessage(null)} onOpenSpaceAnalyzer={openDiskSpaceAnalyzer} /> )} diff --git a/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx b/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx index 6ab425685a3..889fa69d7ef 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSessionStateSaveFailureDialog.tsx @@ -1,4 +1,4 @@ -import { HardDrive } from 'lucide-react' +import { AlertCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, @@ -9,16 +9,20 @@ import { DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' +import { isTerminalSessionStorageCapacityFailure } from '../../../../shared/terminal-session-state-save-failure' export function TerminalSessionStateSaveFailureDialog({ open, + failureMessage, onDismiss, onOpenSpaceAnalyzer }: { open: boolean + failureMessage: string onDismiss: () => void onOpenSpaceAnalyzer: () => void }): React.JSX.Element { + const capacityFailure = isTerminalSessionStorageCapacityFailure(failureMessage) return (

- +
- {translate( - 'auto.components.terminal.pane.TerminalSessionStateSaveFailureDialog.678c780a2c', - 'Disk space is unavailable' - )} + {translate('terminal.sessionSaveFailure.title', 'Could not save terminal session')}
- {translate( - 'auto.components.terminal.pane.TerminalSessionStateSaveFailureDialog.e2fcf07c0d', - 'Orca could not save this terminal session because local storage is full or not writable. Open the disk space analyzer to find workspace storage you can clean up.' - )} + {capacityFailure + ? translate( + 'terminal.sessionSaveFailure.capacity', + 'The device saving this terminal reported full storage or an exceeded storage quota. Free space on that device, then try again.' + ) + : translate( + 'terminal.sessionSaveFailure.unknown', + 'Orca could not save the terminal state. Try again. If this continues, share the Orca logs with support so we can identify the cause.' + )} -
- {translate( - 'auto.components.terminal.pane.TerminalSessionStateSaveFailureDialog.38c282a2c4', - 'The analyzer opens directly from here. You can also open it later from the lower-left toolbox menu by choosing Space Analyzer.' - )} -
+ {capacityFailure && ( +
+ {translate( + 'auto.components.terminal.pane.TerminalSessionStateSaveFailureDialog.38c282a2c4', + 'The analyzer opens directly from here. You can also open it later from the lower-left toolbox menu by choosing Space Analyzer.' + )} +
+ )} - + {capacityFailure && ( + + )}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts index 7a0da0ec79e..008925a0688 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts @@ -148,7 +148,9 @@ export function useTerminalPaneFoundation( const [ptyRecoveryStatesByPaneId, setPtyRecoveryStatesByPaneId] = useState< Record >({}) - const [sessionStateSaveFailureOpen, setSessionStateSaveFailureOpen] = useState(false) + const [sessionStateSaveFailureMessage, setSessionStateSaveFailureMessage] = useState< + string | null + >(null) const daemonActions = useDaemonActions() const { refreshMobileOverlays } = useMobileOverlayTicks({ managerRef, @@ -235,8 +237,8 @@ export function useTerminalPaneFoundation( setTerminalError, ptyRecoveryStatesByPaneId, setPtyRecoveryStatesByPaneId, - sessionStateSaveFailureOpen, - setSessionStateSaveFailureOpen, + sessionStateSaveFailureMessage, + setSessionStateSaveFailureMessage, daemonActions, refreshMobileOverlays } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts index 5f59aa73e25..deff696c93f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts @@ -46,7 +46,7 @@ export function useTerminalPaneStartupActions(controller: TerminalPaneStoreContr setQuickCommandEditorHostId, setQuickCommandEditorOpen, setSessionRestoredBannerPaneIds, - setSessionStateSaveFailureOpen, + setSessionStateSaveFailureMessage, setShouldMeasureHiddenStartup, setTerminalError, setTerminalErrorsByPaneId, @@ -114,7 +114,7 @@ export function useTerminalPaneStartupActions(controller: TerminalPaneStoreContr ) const openDiskSpaceAnalyzer = useCallback(() => { - setSessionStateSaveFailureOpen(false) + setSessionStateSaveFailureMessage(null) openSpacePage() void refreshWorkspaceSpace().catch((error: unknown) => { console.warn('Failed to refresh Space Analyzer after terminal session save failure:', error) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts index 07db5bef38c..5c0522c97f1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts @@ -17,7 +17,7 @@ export function useTerminalPaneTitleState(controller: TerminalPaneFoundation) { managerRef, paneTransportsRef, setPtyRecoveryStatesByPaneId, - setSessionStateSaveFailureOpen, + setSessionStateSaveFailureMessage, setTerminalError, setTerminalErrorsByPaneId, sshReconnectOwnsTerminalErrorsRef @@ -89,7 +89,7 @@ export function useTerminalPaneTitleState(controller: TerminalPaneFoundation) { if (isTerminalSessionStateSaveFailure(message)) { setTerminalError(null) setTerminalErrorsByPaneId({}) - setSessionStateSaveFailureOpen(true) + setSessionStateSaveFailureMessage(message) return } const visibleMessage = sshReconnectOwnsTerminalErrorsRef.current diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8b971208580..90fc6192a11 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2480,6 +2480,10 @@ "TerminalRemoteRuntimeReconnectBanner": { "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns." }, + "TerminalSessionStateSaveFailureDialog": { + "678c780a2c": "Disk space is unavailable", + "e2fcf07c0d": "Orca could not save this terminal session because local storage is full or not writable. Open the disk space analyzer to find workspace storage you can clean up." + }, "TerminalSshReconnectOverlay": { "connectButton": "Connect", "connectingButton": "Connecting..." diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbbba795d59..e44421a9732 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18501,5 +18501,12 @@ }, "common": { "retry": "Retry" + }, + "terminal": { + "sessionSaveFailure": { + "title": "Could not save terminal session", + "capacity": "The device saving this terminal reported full storage or an exceeded storage quota. Free space on that device, then try again.", + "unknown": "Orca could not save the terminal state. Try again. If this continues, share the Orca logs with support so we can identify the cause." + } } } diff --git a/src/shared/storage-capacity-error.ts b/src/shared/storage-capacity-error.ts new file mode 100644 index 00000000000..63575e7fa1c --- /dev/null +++ b/src/shared/storage-capacity-error.ts @@ -0,0 +1,25 @@ +export function storageCapacityErrorCode( + error: unknown, + seen = new Set() +): 'ENOSPC' | 'EDQUOT' | 'SQLITE_FULL' | null { + if (typeof error !== 'object' || error === null || seen.has(error)) { + return null + } + seen.add(error) + // node:sqlite reports SQLITE_FULL through its numeric SQLite result code. + if ( + 'code' in error && + error.code === 'ERR_SQLITE_ERROR' && + 'errcode' in error && + error.errcode === 13 + ) { + return 'SQLITE_FULL' + } + if ( + 'code' in error && + (error.code === 'ENOSPC' || error.code === 'EDQUOT' || error.code === 'SQLITE_FULL') + ) { + return error.code + } + return 'cause' in error ? storageCapacityErrorCode(error.cause, seen) : null +} diff --git a/src/shared/terminal-session-state-save-failure.test.ts b/src/shared/terminal-session-state-save-failure.test.ts new file mode 100644 index 00000000000..aee76908a62 --- /dev/null +++ b/src/shared/terminal-session-state-save-failure.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + createTerminalSessionStateSaveFailureMessage, + isTerminalSessionStateSaveFailure, + isTerminalSessionStorageCapacityFailure +} from './terminal-session-state-save-failure' + +describe('terminal save failure classification', () => { + it.each(['ENOSPC', 'EDQUOT', 'SQLITE_FULL'])('recognizes %s through a wrapper', (code) => { + const message = createTerminalSessionStateSaveFailureMessage( + new Error('write failed', { cause: Object.assign(new Error('failure'), { code }) }) + ) + expect(isTerminalSessionStorageCapacityFailure(message)).toBe(true) + expect(isTerminalSessionStateSaveFailure(message)).toBe(true) + }) + + it.each(['EACCES', 'EPERM', 'EIO', 'SQLITE_BUSY', 'profile-state-writer-exit'])( + 'does not diagnose %s as exhausted storage', + (code) => { + const message = createTerminalSessionStateSaveFailureMessage({ code, message: 'disk full' }) + expect(isTerminalSessionStateSaveFailure(message)).toBe(true) + expect(isTerminalSessionStorageCapacityFailure(message)).toBe(false) + } + ) + + it('treats legacy and unknown host errors as unclassified', () => { + for (const message of [ + 'ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED: local storage is unavailable.', + 'Failed to save terminal session state', + createTerminalSessionStateSaveFailureMessage(new Error('disk full')) + ]) { + expect(isTerminalSessionStateSaveFailure(message)).toBe(true) + expect(isTerminalSessionStorageCapacityFailure(message)).toBe(false) + } + }) + + it('handles cyclic causes', () => { + const error = new Error('cycle') + error.cause = error + expect( + isTerminalSessionStorageCapacityFailure(createTerminalSessionStateSaveFailureMessage(error)) + ).toBe(false) + }) +}) diff --git a/src/shared/terminal-session-state-save-failure.ts b/src/shared/terminal-session-state-save-failure.ts index 309a98b653a..c286cd568bd 100644 --- a/src/shared/terminal-session-state-save-failure.ts +++ b/src/shared/terminal-session-state-save-failure.ts @@ -1,10 +1,18 @@ +import { storageCapacityErrorCode } from './storage-capacity-error' + export const TERMINAL_SESSION_STATE_SAVE_FAILED_CODE = 'ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED' export const TERMINAL_SESSION_STATE_SAVE_FAILED_MESSAGE = - 'Orca could not save this terminal session because local storage is unavailable.' + 'Orca could not save this terminal session.' +const CAPACITY_MARKER = '[storage-capacity-exhausted]' -export function createTerminalSessionStateSaveFailureMessage(): string { - return `${TERMINAL_SESSION_STATE_SAVE_FAILED_CODE}: ${TERMINAL_SESSION_STATE_SAVE_FAILED_MESSAGE}` +export function createTerminalSessionStateSaveFailureMessage(error?: unknown): string { + const capacity = storageCapacityErrorCode(error) ? ` ${CAPACITY_MARKER}` : '' + return `${TERMINAL_SESSION_STATE_SAVE_FAILED_CODE}: ${TERMINAL_SESSION_STATE_SAVE_FAILED_MESSAGE}${capacity}` +} + +export function isTerminalSessionStorageCapacityFailure(message: string): boolean { + return isTerminalSessionStateSaveFailure(message) && message.includes(CAPACITY_MARKER) } export function isTerminalSessionStateSaveFailure(message: string): boolean { From 2ea3fb1d46cc585c6d7e58a5e3089bb03d389088 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:13:35 -0700 Subject: [PATCH 341/387] perf(ci): take advisory unit-selection evidence off the gate (#23776) selection_evidence is continue-on-error on both the job and its comparison step, so it can never fail a PR -- it downloads the shard reports, compares selection against the full results and uploads a review artifact. But a caller's `needs: test` waits for every job in the called workflow, so living inside unit-tests.yml it held verify for ~36s after the last shard finished. It moves to its own reusable workflow called as a sibling, so it still runs on every PR and still uploads its artifact, but verify no longer waits for it. It is deliberately absent from verify's needs, and a contract test pins both that and its advisory status so it cannot drift back onto the critical path. Measured on a recent run: the shards finished, then selection_evidence ran 36s, then verify 3s. Only the last of those gates anything. --- .github/workflows/pr.yml | 8 ++++ .github/workflows/unit-selection-evidence.yml | 41 +++++++++++++++++++ .github/workflows/unit-tests.yml | 29 ------------- .../scripts/pr-workflow-parallelism.test.mjs | 16 ++++++++ 4 files changed, 65 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/unit-selection-evidence.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2a4065bff47..795beca131e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -667,6 +667,14 @@ jobs: runner: ubuntu-24.04-arm shards: ${{ needs.unit_plan.outputs.shards }} + # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the + # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s + # after the last shard. Deliberately absent from verify's needs for the same reason. + unit_selection_evidence: + needs: [test] + if: ${{ !cancelled() && needs.test.result == 'success' }} + uses: ./.github/workflows/unit-selection-evidence.yml + # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. orcad_browser: diff --git a/.github/workflows/unit-selection-evidence.yml b/.github/workflows/unit-selection-evidence.yml new file mode 100644 index 00000000000..4ff46be84d0 --- /dev/null +++ b/.github/workflows/unit-selection-evidence.yml @@ -0,0 +1,41 @@ +name: Unit selection evidence + +# Why its own workflow: this job is advisory -- `continue-on-error` on both the job and its +# comparison step, so it can never fail a PR. But a caller's `needs: test` waits for every job in +# the called workflow, so while it lived in unit-tests.yml it held `verify` for ~36s after the +# last shard finished. Called as a sibling instead, it still runs on every PR and still uploads +# its review artifact; it just no longer sits on the critical path. +on: + workflow_call: + +permissions: + contents: read + +jobs: + selection_evidence: + if: ${{ !cancelled() }} + continue-on-error: true + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/ci-unit-selection-review.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/download-artifact@v8 + with: + pattern: unit-shard-node-*-attempt-${{ github.run_attempt }} + path: unit-evidence/ + - name: Compare selection with full results + continue-on-error: true + run: node config/scripts/ci-unit-selection-review.mjs unit-evidence + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: unit-selection-review-attempt-${{ github.run_attempt }} + path: unit-evidence/selection-review.json + retention-days: 30 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 0090387b75c..abe9cf28a41 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -75,35 +75,6 @@ jobs: retention-days: 14 if-no-files-found: warn - selection_evidence: - needs: test - if: ${{ !cancelled() }} - continue-on-error: true - runs-on: ubuntu-slim - steps: - - uses: actions/checkout@v6 - with: - sparse-checkout: config/scripts/ci-unit-selection-review.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: '24' - - uses: actions/download-artifact@v8 - with: - pattern: unit-shard-node-*-attempt-${{ github.run_attempt }} - path: unit-evidence/ - - name: Compare selection with full results - continue-on-error: true - run: node config/scripts/ci-unit-selection-review.mjs unit-evidence - - uses: actions/upload-artifact@v7 - if: always() - continue-on-error: true - with: - name: unit-selection-review-attempt-${{ github.run_attempt }} - path: unit-evidence/selection-review.json - retention-days: 30 - relay_integration: name: relay integration node ${{ matrix.node }} strategy: diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 6054f026144..ffd3e1c9fc9 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -501,6 +501,22 @@ describe('PR workflow parallelism', () => { } }) + it('keeps advisory unit-selection evidence off the gate', () => { + // It is continue-on-error, so it can never fail a PR. Living inside unit-tests.yml made a + // caller's `needs: test` wait for it anyway, holding verify ~36s past the last shard. Pinned + // here so it cannot drift back onto the critical path. + const evidence = workflow.jobs.unit_selection_evidence + expect(evidence.uses).toBe('./.github/workflows/unit-selection-evidence.yml') + expect(evidence.needs).toEqual(['test']) + expect(workflow.jobs.verify.needs).not.toContain('unit_selection_evidence') + expect(unitTestWorkflow.jobs.selection_evidence).toBeUndefined() + const evidenceWorkflow = parse( + readFileSync('.github/workflows/unit-selection-evidence.yml', 'utf8') + ) + const job = evidenceWorkflow.jobs.selection_evidence + expect(job['continue-on-error']).toBe(true) + }) + it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', From f6324f242adb3eed6153f0714901b37541b08ea2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:36 -0700 Subject: [PATCH 342/387] chore(ci): stop auto-filing community PRs onto the project board (#23796) Removes the Track Community PRs workflow. Community pull requests will no longer be added to project stablyai/13 automatically. --- .github/workflows/homebrew-bump.yml | 9 +- .github/workflows/track-community-prs.yaml | 120 --------------------- 2 files changed, 4 insertions(+), 125 deletions(-) delete mode 100644 .github/workflows/track-community-prs.yaml diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 9b04d3205fc..a5f9b352f6e 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -132,11 +132,10 @@ jobs: print(src) PY - # Why: reuse the existing buf0-bot GitHub App (also used by - # track-community-prs.yaml) instead of minting a new PAT. The app is - # installed org-wide, so it has access to stablyai/homebrew-orca - # automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived - # installation token can. + # Why: reuse the existing buf0-bot GitHub App instead of minting a new + # PAT. The app is installed org-wide, so it has access to + # stablyai/homebrew-orca automatically. GITHUB_TOKEN cannot push + # cross-repo; a short-lived installation token can. - name: Generate buf0-bot token id: app-token uses: actions/create-github-app-token@v3 diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml deleted file mode 100644 index 7a588663fb2..00000000000 --- a/.github/workflows/track-community-prs.yaml +++ /dev/null @@ -1,120 +0,0 @@ -name: Track Community PRs - -on: - pull_request_target: - types: [opened, reopened, ready_for_review] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to backfill or retry' - required: true - type: number - -permissions: - contents: read - -jobs: - track-community-pr: - # API-only bookkeeping fits the free single-CPU container runner. - runs-on: ubuntu-slim - timeout-minutes: 5 - steps: - - name: Generate bufo-bot token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: 2590194 - private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }} - owner: stablyai - - - name: Add PR to project - uses: actions/github-script@v8 - env: - PROJECT_OWNER: stablyai - PROJECT_NUMBER: '13' - INTERNAL_TEAM_SLUG: stably-eng - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const projectOwner = process.env.PROJECT_OWNER; - const projectNumber = Number(process.env.PROJECT_NUMBER); - const internalTeamSlug = process.env.INTERNAL_TEAM_SLUG; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const prNumber = context.eventName === 'workflow_dispatch' - ? Number(context.payload.inputs.pr_number) - : context.payload.pull_request.number; - - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - - const author = pr.user.login; - - const skippedAuthors = new Set([ - 'github-actions[bot]', - 'dependabot[bot]', - ]); - - if (skippedAuthors.has(author)) { - core.info(`Skipping bot PR author ${author}.`); - return; - } - - let isInternalAuthor = false; - try { - const membership = await github.rest.teams.getMembershipForUserInOrg({ - org: projectOwner, - team_slug: internalTeamSlug, - username: author, - }); - isInternalAuthor = membership.data.state === 'active'; - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - if (isInternalAuthor) { - core.info(`Skipping internal PR author ${author}.`); - return; - } - - const projectResult = await github.graphql( - ` - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - } - } - } - `, - { owner: projectOwner, number: projectNumber }, - ); - - const projectId = projectResult.organization.projectV2.id; - - await github.graphql( - ` - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: { - projectId: $projectId, - contentId: $contentId - }) { - item { - id - } - } - } - `, - { - projectId, - contentId: pr.node_id, - }, - ); - - core.info(`Added PR #${pr.number} (${pr.html_url}) to project ${projectOwner}/${projectNumber}.`); From ceffecf12a0e98d3fabc4c2a632df2da283d58f3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:49:03 -0700 Subject: [PATCH 343/387] fix(setup): remove wait-for-setup helper text (#23799) --- .../new-workspace/NewWorkspaceComposerAdvancedSection.tsx | 8 +------- .../components/settings/RepositoryHookPolicySettings.tsx | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx index f7b18f6ebaa..55deb82b95b 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx @@ -272,7 +272,7 @@ export function NewWorkspaceComposerAdvancedSection({
@@ -282,12 +282,6 @@ export function NewWorkspaceComposerAdvancedSection({ 'Wait for setup to complete before starting agent' )} - - {translate( - 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp', - 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' - )} -
-
+
{translate( 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgent', 'Wait for setup to complete before starting agent' )}
-

- {translate( - 'auto.components.settings.RepositoryHooksSection.waitForSetupBeforeAgentHelp', - 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.' - )} -

Date: Mon, 28 Sep 2026 23:14:41 -0700 Subject: [PATCH 344/387] fix(native-chat): drop the Codex error path #23682 removed (#23801) #23682 established that only `turn/completed` ends a Codex turn and deleted `codex-structured-journal-provider-verdicts.ts`, but the child-turn reader kept importing `readCodexProviderVerdict` and branching on its `turn-failed` verdict. The module is gone, so the import resolves to nothing: typecheck and static analysis fail on every pull request, which skips the whole unit matrix behind them, and the esbuild pass behind Bun profile scope detection throws, so every run falls back to all six platforms. A closed thread is now the only child-turn end without `turn/completed`, which is what #23682 intended: Codex follows a turn-ending `error` with a failed `turn/completed` for the same turn 0-32 ms later, and that completion carries the duration and receipt time the error does not. --- .../codex/codex-background-task-frames.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 65f51846c24..bb3ee27b0fa 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -7,7 +7,6 @@ import { import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' -import { readCodexProviderVerdict } from './codex-structured-journal-provider-verdicts' import { readCodexTurnId } from './codex-structured-thread-facts' export type CodexBackgroundTaskFrame = @@ -29,12 +28,10 @@ export type CodexBackgroundTaskFrame = /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */ kind: 'turn-ended' threadId: string - turnId: string | null - state: CodexChildTurnEnding + turnId: null + state: Extract } -type CodexChildTurnEnding = Extract - export type CodexBackgroundTaskEvent = { method: string threadId: string @@ -42,19 +39,14 @@ export type CodexBackgroundTaskEvent = { } /** - * The two ways Codex ends a child's turn without `turn/completed`. An `error` it will not retry is - * that turn's own end: the verdict the transcript settles the same turn on. A closed thread ran - * its last turn, and Codex never said how it went. A `systemError` status is neither: Codex raises - * it for errors that leave the turn running too (a refused steer), and a turn one ends also - * carries the `error`. + * The one way Codex ends a child's turn without `turn/completed`: a closed thread ran its last + * turn and Codex never said how it went. A turn-ending `error` is not one — Codex follows it with + * a failed `turn/completed` for the same turn, which is that turn's end and carries the duration + * and receipt time the error does not. */ function readCodexChildTurnEnding( event: CodexBackgroundTaskEvent ): CodexBackgroundTaskFrame | null { - if (readCodexProviderVerdict(event.method, event.params) === 'turn-failed') { - const turnId = readCodexTurnId(event.params) - return { kind: 'turn-ended', threadId: event.threadId, turnId, state: 'failed' } - } return event.method === 'thread/closed' ? { kind: 'turn-ended', threadId: event.threadId, turnId: null, state: 'unverifiable' } : null From 2d358df8afdad61efa158b30dfc6308c35b8ab2d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:27:55 -0700 Subject: [PATCH 345/387] fix(i18n): prune the wait-for-setup help copy #23799 removed (#23802) #23799 removed both `waitForSetupBeforeAgentHelp` call sites but left the entry in all six locale catalogs. The runtime-required generator treats an entry with no literal-default call site as one only the catalog can serve, so the two dead entries had to ship in the boot bundle for the catalog check to pass, and `verify:localization-catalogs` failed on every pull request until they did. Deleting them is the resolution rather than regenerating `en-runtime-required.json`: no call site can reach either string, so shipping them would add dead weight to the boot bundle to satisfy a check about what the bundle must contain. The sibling `waitForSetupBeforeAgent` heading keys stay; #23799 removed only the help paragraphs. --- src/renderer/src/i18n/locales/en.json | 4 +--- src/renderer/src/i18n/locales/es.json | 4 +--- src/renderer/src/i18n/locales/fr.json | 4 +--- src/renderer/src/i18n/locales/ja.json | 4 +--- src/renderer/src/i18n/locales/ko.json | 4 +--- src/renderer/src/i18n/locales/zh.json | 4 +--- 6 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e44421a9732..9351961c28a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1728,7 +1728,6 @@ "notConnected": "Not connected", "notePasteTooLarge": "Paste is too large for the note field.", "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", - "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.", "destroyDisabled": "destroy disabled", "destroyConfigured": "destroy configured", "noDestroyConfigured": "no destroy", @@ -8055,8 +8054,7 @@ "81057d5f71": "Saving...", "da37d6f10e": "Copy", "3149964b66": "Copied", - "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent", - "waitForSetupBeforeAgentHelp": "Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup." + "waitForSetupBeforeAgent": "Wait for setup to complete before starting agent" }, "RepositoryIconPicker": { "2b7d27b93c": "Use {{value0}} repo color", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3e2132d6011..e0e77e574ec 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1379,7 +1379,6 @@ "reuseExistingBranchHint": "Haz checkout de la rama existente en lugar de crear una nueva a partir de ella.", "createMultiple": "Crear más", "waitForSetupBeforeAgent": "Esperar a que termine el setup antes de iniciar el agente", - "waitForSetupBeforeAgentHelp": "Activa esto cuando el setup instale dependencias, servidores MCP o archivos de configuración que el agente necesita al iniciar.", "destroyDisabled": "destroy deshabilitado", "destroyConfigured": "destroy configurado", "noDestroyConfigured": "sin destroy", @@ -6804,8 +6803,7 @@ "81057d5f71": "Guardando...", "da37d6f10e": "Copiar", "3149964b66": "Copiado", - "waitForSetupBeforeAgent": "Espera a que se complete la configuración antes de iniciar el agente", - "waitForSetupBeforeAgentHelp": "Activa esto cuando la configuración instale dependencias, servidores MCP o archivos de configuración que el agente necesite durante el inicio." + "waitForSetupBeforeAgent": "Espera a que se complete la configuración antes de iniciar el agente" }, "RepositoryIconPicker": { "2b7d27b93c": "Usar {{value0}} como color del repositorio", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 53ceaf15c39..9a0f0d437c9 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1706,7 +1706,6 @@ "notConnected": "Non connecté", "notePasteTooLarge": "Le contenu collé est trop volumineux pour le champ de note.", "waitForSetupBeforeAgent": "Attendre la fin du setup avant de démarrer l'agent", - "waitForSetupBeforeAgentHelp": "Activez cette option quand le setup installe des dépendances, des serveurs MCP ou des fichiers de configuration dont l'agent a besoin au démarrage.", "destroyDisabled": "destroy désactivé", "destroyConfigured": "destroy configuré", "noDestroyConfigured": "pas de destroy", @@ -7991,8 +7990,7 @@ "81057d5f71": "Enregistrement...", "da37d6f10e": "Copier", "3149964b66": "Copied", - "waitForSetupBeforeAgent": "Attendre la fin du setup avant de démarrer l'agent", - "waitForSetupBeforeAgentHelp": "Activez cette option quand le setup installe des dépendances, des serveurs MCP ou des fichiers de configuration dont l'agent a besoin au démarrage." + "waitForSetupBeforeAgent": "Attendre la fin du setup avant de démarrer l'agent" }, "RepositoryIconPicker": { "2b7d27b93c": "Utiliser la couleur de dépôt {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1b55d87552a..6ec0e9c65e7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1603,7 +1603,6 @@ "reuseExistingBranchHint": "既存のブランチから新規ブランチを作成せず、そのブランチをチェックアウトします。", "createMultiple": "さらに作成", "waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください", - "waitForSetupBeforeAgentHelp": "セットアップによって、起動時に Agent に必要な依存関係、MCP サーバー、または構成ファイルがインストールされるときに、これをオンにします。", "destroyDisabled": "破壊は無効です", "destroyConfigured": "設定されたものを破棄する", "noDestroyConfigured": "破壊しない", @@ -7872,8 +7871,7 @@ "81057d5f71": "保存中…", "da37d6f10e": "コピー", "3149964b66": "コピーしました", - "waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください", - "waitForSetupBeforeAgentHelp": "セットアップによって、起動時に Agent に必要な依存関係、MCP サーバー、または構成ファイルがインストールされるときに、これをオンにします。" + "waitForSetupBeforeAgent": "セットアップが完了するまで待ってから Agent を開始してください" }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} リポジトリカラーを使用する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a5aca8c17b6..e5d5cac60fc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1603,7 +1603,6 @@ "reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.", "createMultiple": "더 만들기", "waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요.", - "waitForSetupBeforeAgentHelp": "설치 프로그램이 시작 중에 agent에 필요한 종속성, MCP 서버 또는 구성 파일을 설치할 때 이 기능을 켜십시오.", "destroyDisabled": "비활성화된 파괴", "destroyConfigured": "구성된 파괴", "noDestroyConfigured": "파괴하지 마세요", @@ -7835,8 +7834,7 @@ "81057d5f71": "저장 중...", "da37d6f10e": "복사", "3149964b66": "복사됨", - "waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요.", - "waitForSetupBeforeAgentHelp": "설치 프로그램이 시작 중에 agent에 필요한 종속성, MCP 서버 또는 구성 파일을 설치할 때 이 기능을 켜십시오." + "waitForSetupBeforeAgent": "agent를 시작하기 전에 설정이 완료될 때까지 기다리세요." }, "RepositoryIconPicker": { "2b7d27b93c": "{{value0}} repo 색상 사용", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 47fefd2f0e3..d8a7ebf4561 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1603,7 +1603,6 @@ "reuseExistingBranchHint": "检出已有分支,而不是从它创建新分支。", "createMultiple": "创建更多", "waitForSetupBeforeAgent": "等待设置完成后再启动智能体", - "waitForSetupBeforeAgentHelp": "如果设置会安装依赖、MCP 服务器或智能体启动时需要的配置文件,请开启此选项。", "destroyDisabled": "已禁用销毁", "destroyConfigured": "已配置销毁", "noDestroyConfigured": "未配置销毁", @@ -7835,8 +7834,7 @@ "81057d5f71": "保存中...", "da37d6f10e": "复制", "3149964b66": "已复制", - "waitForSetupBeforeAgent": "等待设置完成后再启动智能体", - "waitForSetupBeforeAgentHelp": "如果设置会安装依赖、MCP 服务器或智能体启动时需要的配置文件,请开启此选项。" + "waitForSetupBeforeAgent": "等待设置完成后再启动智能体" }, "RepositoryIconPicker": { "2b7d27b93c": "使用 {{value0}} 仓库颜色", From 25d9c57e3a4f172b66d3d1fd91db23993dbc2688 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:42:01 -0700 Subject: [PATCH 346/387] fix(native-chat): keep a child turn settling on an error Codex will not retry (#23808) #23801 removed the child-path reading of Codex's turn-ending `error` along with the import of the module #23682 deleted. That was the wrong half to remove: the primary journal path can rely on Codex's failed `turn/completed` arriving within ~32 ms, which is what #23682 established, but a child turn has no such guarantee, and without the error as its end the child's lifecycle row latches on `working` for the life of the session. Three tests assert exactly that and could not run, because the unresolved import had been skipping the unit matrix since #23682 merged. The reading is restored inline against `readCodexErrorWillRetry`, itself restored to `codex-structured-thread-facts.ts`, rather than by reviving the deleted module: its `thread-stopped-running` arm lost its only consumer when #23682 rewrote the primary path, so restoring the file would re-add dead code. Also drops `pr-workflow-parallelism.test.mjs`'s read of `.github/workflows/track-community-prs.yaml`, which #23796 deleted while leaving the assertion behind. Same failure class, and it fails the same shard. --- .../scripts/pr-workflow-parallelism.test.mjs | 2 -- .../codex/codex-background-task-frames.ts | 24 +++++++++++++------ .../codex/codex-structured-thread-facts.ts | 8 +++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ffd3e1c9fc9..dde93505193 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -7,7 +7,6 @@ import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundl const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const prTestLocWorkflow = parse(readFileSync('.github/workflows/pr-test-loc.yml', 'utf8')) -const trackingWorkflow = parse(readFileSync('.github/workflows/track-community-prs.yaml', 'utf8')) const releasePolicyWorkflow = parse(readFileSync('.github/workflows/release-policy.yml', 'utf8')) const issueLabelWorkflow = parse(readFileSync('.github/workflows/issue-os-labeler.yaml', 'utf8')) const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) @@ -55,7 +54,6 @@ describe('PR workflow parallelism', () => { expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim') expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim') - expect(trackingWorkflow.jobs['track-community-pr']['runs-on']).toBe('ubuntu-slim') expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim') expect(issueLabelWorkflow.jobs['apply-os-label']['runs-on']).toBe('ubuntu-slim') }) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index bb3ee27b0fa..029cc7b50c7 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -7,7 +7,7 @@ import { import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' -import { readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexErrorWillRetry, readCodexTurnId } from './codex-structured-thread-facts' export type CodexBackgroundTaskFrame = | { @@ -28,10 +28,12 @@ export type CodexBackgroundTaskFrame = /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */ kind: 'turn-ended' threadId: string - turnId: null - state: Extract + turnId: string | null + state: CodexChildTurnEnding } +type CodexChildTurnEnding = Extract + export type CodexBackgroundTaskEvent = { method: string threadId: string @@ -39,14 +41,22 @@ export type CodexBackgroundTaskEvent = { } /** - * The one way Codex ends a child's turn without `turn/completed`: a closed thread ran its last - * turn and Codex never said how it went. A turn-ending `error` is not one — Codex follows it with - * a failed `turn/completed` for the same turn, which is that turn's end and carries the duration - * and receipt time the error does not. + * The two ways Codex ends a child's turn without `turn/completed`. An `error` it will not retry is + * that turn's own end: for a child, `turn/completed` may never follow, and without this end the + * child's lifecycle row latches on `working` for the life of the session. A closed thread ran its + * last turn, and Codex never said how it went. + * + * #23682 dropped the equivalent reading from the primary journal path, where Codex's own failed + * `turn/completed` always follows within ~32 ms and carries the duration the error lacks. A child + * has no such guarantee, which is why this path still settles on the error. */ function readCodexChildTurnEnding( event: CodexBackgroundTaskEvent ): CodexBackgroundTaskFrame | null { + if (event.method === 'error' && !readCodexErrorWillRetry(event.params)) { + const turnId = readCodexTurnId(event.params) + return { kind: 'turn-ended', threadId: event.threadId, turnId, state: 'failed' } + } return event.method === 'thread/closed' ? { kind: 'turn-ended', threadId: event.threadId, turnId: null, state: 'unverifiable' } : null diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index f2a42e6d3db..aaf65f93ec6 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -65,6 +65,14 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { /** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a * bare string. `idle` and `systemError` are the two arms that mean the thread * is not running; `active` and `notLoaded` are not. */ +/** + * Codex hardcodes `willRetry: false` on an `error` that affects turn status, and `true` on a + * stream error it is about to retry, which ends nothing. + */ +export function readCodexErrorWillRetry(payload: unknown): boolean { + return record(payload)?.willRetry === true +} + export function codexThreadStoppedRunning(payload: unknown): boolean { const type = record(record(payload)?.status)?.type return type === 'idle' || type === 'systemError' From 5560e534ff77657196eb3c0f44de59b998ad2c24 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:47:59 +0000 Subject: [PATCH 347/387] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 9fbda727d28..af45e38a72c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 82m + + downloads: 83m @@ -15,7 +15,7 @@ downloads downloads - 82m - 82m + 83m + 83m From f8f656ca191110d0aeb5e6183a70799fee78f120 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:13:33 -0700 Subject: [PATCH 348/387] perf(ci): spend fewer concurrency slots per pull request (#23810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A concurrency slot is charged per job, not per core, and the account's cap is the scarce resource: standard runner minutes are free and unlimited on a public repository. Two paths spent slots that bought nothing. The unit matrix ran eight fixed shards averaging 6.5 minutes each, 3384 job-slots a day and 68% of all slot demand, while the arm pool queued 10.5 minutes at p95 — the queue was the oversharding. Five shards run the same work in ~10.5 minutes each for three fewer slots per run. Bun profile persistence escalated to all six platforms on `config/`, `resources/` and `.github/` wholesale, which took 36.5% of the last 1100 commits through the full matrix where a platform-flavoured predicate takes 19%. A pull request now qualifies one platform unless the change is platform- flavoured, and the push to main re-qualifies all six, so an unescalated miss surfaces minutes after merge rather than at the next cron. Missing changed-file evidence and an unavailable dependency graph still fail closed to all six. --- .github/workflows/bun-profile-tests.yml | 24 ++++++++++- config/scripts/bun-profile-change-scope.mjs | 8 +--- .../scripts/bun-profile-change-scope.test.mjs | 7 ++++ config/scripts/bun-profile-qualification.mjs | 27 ++++++------ .../bun-profile-qualification.test.mjs | 41 +++++++++++-------- config/scripts/ci-unit-plan.mjs | 7 +++- config/scripts/ci-unit-plan.test.mjs | 3 +- config/scripts/ci-unit-selection.mjs | 10 ++++- config/scripts/ci-unit-selection.test.mjs | 18 +++++++- docs/reference/ci-demand-rollout.md | 5 ++- 10 files changed, 106 insertions(+), 44 deletions(-) diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index 5fae18f2f84..1a292292510 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -17,6 +17,24 @@ on: - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' - '.github/workflows/bun-profile-tests.yml' + # The pull request qualifies one platform for an unflavoured change; this is where all six + # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. + push: + branches: [main] + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/bun-profile-tests.yml' workflow_dispatch: schedule: - cron: '30 11 * * *' @@ -57,7 +75,11 @@ jobs: persistence: needs: changes # Missing/failed detection runs the full matrix; manual runs remain unconditional. - if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against + # the event name so the push and schedule paths do not rest on a null property comparison. + if: >- + ${{ !cancelled() && needs.changes.outputs.should_run != 'false' && + (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} strategy: fail-fast: false matrix: diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/bun-profile-change-scope.mjs index 7b8b47b69aa..dc464a5dc03 100644 --- a/config/scripts/bun-profile-change-scope.mjs +++ b/config/scripts/bun-profile-change-scope.mjs @@ -125,13 +125,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) const result = await classifyBunProfileChanges(changedFiles) console.log(result.reason) - let event = {} - try { - event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) - } catch { - // Missing event evidence retains full qualification. - } - const policy = bunProfileQualification(changedFiles, result, event) + const policy = bunProfileQualification(changedFiles, result) const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs index e813be93621..ef21fed5e34 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -143,6 +143,13 @@ it('keeps all ten platform jobs and runs them when detection is skipped or fails expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) + // A pull request may qualify one platform, so the merged commit must re-qualify all six. + expect(workflow.on.push.branches).toEqual(['main']) + expect(workflow.on.push.paths).toEqual(workflow.on.pull_request.paths) + // The push and schedule paths must not rest on a null property comparison. + expect(workflow.jobs.persistence.if).toContain( + "github.event_name != 'pull_request' || github.event.pull_request.draft != true" + ) expect(workflow.jobs.persistence.strategy.matrix.os).toContain('needs.changes.outputs.runners') for (const jobName of ['linux_glibc_floor', 'linux_musl']) { const job = workflow.jobs[jobName] diff --git a/config/scripts/bun-profile-qualification.mjs b/config/scripts/bun-profile-qualification.mjs index 799f459ff85..dafd137f95e 100644 --- a/config/scripts/bun-profile-qualification.mjs +++ b/config/scripts/bun-profile-qualification.mjs @@ -7,34 +7,35 @@ export const BUN_PERSISTENCE_RUNNERS = [ 'windows-11-arm' ] -const QUALIFICATION_PREFIXES = [ - 'config/', +// Only surfaces whose behaviour actually differs per platform. Escalating on `config/`, +// `resources/` and `.github/` wholesale took 36.5% of the last 1100 commits through all six +// platforms where a platform-flavoured predicate takes 19%. +const PLATFORM_PREFIXES = [ 'native/', - 'resources/', - '.github/', + 'config/patches/', + '.github/actions/install-node-dependencies/', 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', 'src/main/providers/', 'src/main/daemon/', 'src/main/ssh/', + 'src/main/wsl/', 'src/relay/', 'src/shared/child-process/' ] -export function bunProfileQualification(changedFiles, scope, event = {}) { - const sensitive = changedFiles.some( +export function bunProfileQualification(changedFiles, scope) { + const platformSpecific = changedFiles.some( (file) => + // A root manifest can move a native dependency on every platform at once. !file.includes('/') || - QUALIFICATION_PREFIXES.some((prefix) => file.startsWith(prefix)) || + PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) || /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun)(?:[/.-]|$)/i.test(file) ) - // Only a proven unrelated platform change in a draft may defer qualification. - const full = - event.pull_request?.draft !== true || - changedFiles.length === 0 || - scope.graphUnavailable === true || - sensitive + // A pull request qualifies one platform unless the change is platform-flavoured; the push to + // main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day. + const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific return { qualification: full, runners: full ? BUN_PERSISTENCE_RUNNERS : ['ubuntu-22.04'] diff --git a/config/scripts/bun-profile-qualification.test.mjs b/config/scripts/bun-profile-qualification.test.mjs index 9ebc5e437e1..520cd76fb21 100644 --- a/config/scripts/bun-profile-qualification.test.mjs +++ b/config/scripts/bun-profile-qualification.test.mjs @@ -1,22 +1,32 @@ import { expect, it } from 'vitest' import { BUN_PERSISTENCE_RUNNERS, bunProfileQualification } from './bun-profile-qualification.mjs' -const draft = { pull_request: { draft: true } } const scope = { shouldRun: true } -it('defers only platform qualification for ordinary draft runtime changes', () => { - expect( - bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], scope, draft) - ).toEqual({ +it('qualifies one platform for a change no platform can alter', () => { + expect(bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], scope)).toEqual({ qualification: false, runners: ['ubuntu-22.04'] }) }) it.each([ - 'package.json', - 'native/windows-registry/src/addon.cc', + 'src/renderer/src/components/TabStrip.tsx', 'config/vitest.config.ts', + 'config/scripts/ci-unit-plan.mjs', + 'resources/icons/tray.png', + '.github/workflows/pr.yml', + 'docs/reference/agent-status-store.md' +])('keeps one platform for unflavoured input %s', (file) => { + expect(bunProfileQualification([file], scope).runners).toEqual(['ubuntu-22.04']) +}) + +it.each([ + 'package.json', + 'pnpm-lock.yaml', + 'native/windows-registry/src/addon.cc', + 'config/patches/node-pty.patch', + '.github/actions/install-node-dependencies/action.yml', 'src/main/ssh/ssh-provider.ts', 'src/main/providers/local-pty-provider.ts', 'src/shared/child-process/run-process.ts', @@ -28,20 +38,19 @@ it.each([ 'src/main/daemon/entry.ts', 'src/relay/index.ts', 'src/main/wsl/runner.ts' -])('retains all platforms for sensitive input %s', (file) => { - expect(bunProfileQualification([file], scope, draft)).toEqual({ +])('retains all platforms for platform-flavoured input %s', (file) => { + expect(bunProfileQualification([file], scope)).toEqual({ qualification: true, runners: BUN_PERSISTENCE_RUNNERS }) }) -it('qualifies every ready commit, scheduled/manual runs and incomplete evidence', () => { - const paths = ['src/main/runtime/rpc/methods/example.ts'] - for (const event of [{}, { pull_request: { draft: false } }]) { - expect(bunProfileQualification(paths, scope, event).qualification).toBe(true) - } - expect(bunProfileQualification([], scope, draft).qualification).toBe(true) +it('fails closed to every platform when the evidence is incomplete', () => { + expect(bunProfileQualification([], scope).qualification).toBe(true) expect( - bunProfileQualification(paths, { ...scope, graphUnavailable: true }, draft).qualification + bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], { + ...scope, + graphUnavailable: true + }).qualification ).toBe(true) }) diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs index 28eed60f8e8..db7a13070d0 100644 --- a/config/scripts/ci-unit-plan.mjs +++ b/config/scripts/ci-unit-plan.mjs @@ -3,7 +3,7 @@ import { pathToFileURL } from 'node:url' import { runProcessSync } from './script-child-process.mjs' import { collectUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' import { discoverUnitFiles } from './ci-unit-files.mjs' -import { planUnitSelection } from './ci-unit-selection.mjs' +import { FULL_SHARD_COUNT, planUnitSelection } from './ci-unit-selection.mjs' import { readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' export function prepareUnitPlan(env = process.env) { @@ -39,7 +39,10 @@ export function prepareUnitPlan(env = process.env) { files, candidateFiles: files, executionFiles: files, - shards: Array.from({ length: 8 }, (_, index) => ({ index: index + 1, count: 8 })) + shards: Array.from({ length: FULL_SHARD_COUNT }, (_, index) => ({ + index: index + 1, + count: FULL_SHARD_COUNT + })) } } writeAssignment('ci-shards/unit-selection.json', plan) diff --git a/config/scripts/ci-unit-plan.test.mjs b/config/scripts/ci-unit-plan.test.mjs index 1711582b64f..eda7903ae1d 100644 --- a/config/scripts/ci-unit-plan.test.mjs +++ b/config/scripts/ci-unit-plan.test.mjs @@ -4,6 +4,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { expect, it } from 'vitest' import { runProcessSync } from './script-child-process.mjs' +import { FULL_SHARD_COUNT } from './ci-unit-selection.mjs' it.each([true, false])( 'plans from a real Git diff, with parent evidence available: %s', @@ -70,7 +71,7 @@ it.each([true, false])( ? 'Transitive imports plus indirect-input consumers' : 'Error: Changed paths unavailable' ) - expect(plan.shards).toHaveLength(withParent ? 1 : 8) + expect(plan.shards).toHaveLength(withParent ? 1 : FULL_SHARD_COUNT) } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/config/scripts/ci-unit-selection.mjs b/config/scripts/ci-unit-selection.mjs index 70894dde0a1..841bad422e7 100644 --- a/config/scripts/ci-unit-selection.mjs +++ b/config/scripts/ci-unit-selection.mjs @@ -27,14 +27,20 @@ export function selectUnitFiles(files, changed, graph) { } } +// A concurrency slot is charged per job, not per core, so eight 6.5-minute shards cost eight of +// the account's slots and made the unit matrix 68% of daily slot demand. Against the checked-in +// baseline, five shards each carry 24.7 test-minutes over four workers plus ~2.6 minutes of fixed +// setup, so ~8.8 minutes -- less than the 10.5-minute p95 queue the oversharding was causing. +export const FULL_SHARD_COUNT = 5 + export function planUnitSelection({ files, changed, graph, timings, event, mode = 'shadow' }) { const candidate = selectUnitFiles(files, changed, graph) const selected = mode === 'selected' && event?.pull_request?.draft === true && !candidate.full const executionFiles = selected ? candidate.files : files const totalMs = balanceFiles(executionFiles, 1, timings).shards[0].durationMs const count = selected - ? Math.max(1, Math.min(8, Math.ceil(totalMs / 900_000), executionFiles.length)) - : 8 + ? Math.max(1, Math.min(FULL_SHARD_COUNT, Math.ceil(totalMs / 900_000), executionFiles.length)) + : FULL_SHARD_COUNT return { version: 1, mode: selected ? 'selected' : 'shadow', diff --git a/config/scripts/ci-unit-selection.test.mjs b/config/scripts/ci-unit-selection.test.mjs index 37338cbfc6d..69370a675f6 100644 --- a/config/scripts/ci-unit-selection.test.mjs +++ b/config/scripts/ci-unit-selection.test.mjs @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' -import { auditUnitSelection, planUnitSelection, selectUnitFiles } from './ci-unit-selection.mjs' +import { + FULL_SHARD_COUNT, + auditUnitSelection, + planUnitSelection, + selectUnitFiles +} from './ci-unit-selection.mjs' const sources = new Map( Object.entries({ @@ -79,6 +84,17 @@ describe('conservative unit selection', () => { expect(selected.shards).toEqual([{ index: 1, count: 1 }]) }) + it('spends five concurrency slots on a full run', () => { + const plan = planUnitSelection({ files, changed: ['src/leaf.ts'], graph, timings: {} }) + expect(plan.shards).toEqual( + Array.from({ length: FULL_SHARD_COUNT }, (_, index) => ({ + index: index + 1, + count: FULL_SHARD_COUNT + })) + ) + expect(FULL_SHARD_COUNT).toBe(5) + }) + it('records failures that would have been missed while shadow runs remain full', () => { const plan = planUnitSelection({ files, changed: ['src/leaf.ts'], graph, timings: {} }) expect( diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md index 8219a3401cb..081d276c4bf 100644 --- a/docs/reference/ci-demand-rollout.md +++ b/docs/reference/ci-demand-rollout.md @@ -45,7 +45,10 @@ suite. A shard verifies the plan's source SHA and complete discovery list before using it. Missing/stale artifacts fall back to full coverage, even if that means running the suite on fewer shards. -The initial policy is **shadow**, with all eight shards retained. The first +The initial policy is **shadow**, with every shard retained. A full run spends +`FULL_SHARD_COUNT` (five) concurrency slots: the account is charged a slot per job +rather than per core, so eight 6.5-minute shards made this matrix 68% of daily slot +demand while the arm pool queued 10.5 minutes at p95. The first local inventory matched Vitest exactly (9,950 files at validation); representative source changes retained roughly 88% of files because of indirect input readers. That is evidence for conservative coverage, not evidence of the analysis's From 33f661b745ca1e138ed0421e7f6696bc5112306c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:31:51 -0700 Subject: [PATCH 349/387] fix(native-chat): an empty workspace opens the default agent as a chat when chat is the default view (#23693) * fix(native-chat): an empty workspace opens the default agent as a chat when chat is the default view With "open agent tabs in chat" on, clicking a workspace with no tabs still seeded a bare shell. The seeding path now opens the user's default agent through the shared launch funnel when that launch routes to a chat, and keeps the shell otherwise. A Blank Terminal pick at create time is now passed to activation as agent: null so it still gets a shell. * fix(native-chat): only a deliberate workspace open starts the default agent chat Round-1 review of the empty-workspace default chat: activation treated any call with no agent and no caller surface as user navigation, so CLI/phone creates, fallbacks after a failed agent launch, fork fallbacks, and the move to a neighbour after a delete all opened an agent chat nobody picked. - Opt in instead: activation options gain navigationIntent: 'user-open', set only by user navigation (sidebar row, keyboard cycling, Cmd+J, the workspace digit shortcut, back/forward, open-parent, jump-to-workspace, and the open-attached-workspace / space-manager actions). Every other activation keeps today's shell, so the Blank Terminal pass-through in the create flow is no longer needed and is reverted. - The Electron gated reseed now waits (bounded, 5 s) for the workspace host's agent list when it has not loaded, holding a per-workspace claim that the passive seed and other reseeds respect, then re-checks the active workspace, host, and emptiness. Host resolution reuses the detection target key, so an unresolved owner stays unknown. - Tests cover the gated path for worktrees and folders, non-opted activations, history navigation, the detection wait and its failure, and the passive seed deferring to the claim. * fix(native-chat): a reopen during the agent-list wait seeds for the latest open Round-2 review of the empty-workspace default chat: while a user open waited for the host's agent list, a second activation of the same workspace was dropped. Opening A from the sidebar, moving to B, then pressing Back to A during the wait left A active with no chat and no shell: the wait reseeded with the first open's host id, which no longer matched, and the passive seed had already marked A handled. - The pending wait now stores the latest activation's intent; a later activation (user open or plain) replaces it, and the wait seeds with it. - The passive seed no longer marks a workspace handled while a wait owns it, so leaving mid-wait and returning seeds a shell instead of nothing. - The wait and the open share one check, so a Blank Terminal default (or chat not being the default view) no longer waits up to 5 s before its shell. --- .../components/WorktreeJumpPalette.test.tsx | 3 + .../activity/activity-thread-actions.test.ts | 1 + .../editor/CheckRunDetailsPanel.copy.test.tsx | 1 + .../components/editor/IpynbRendering.test.tsx | 5 +- ...nPreview.link-routing.interaction.test.tsx | 5 +- ...rkdownPreview.toc-visibility-gate.test.tsx | 5 +- .../open-dialog/github-item-dialog.tsx | 4 +- .../pull-request-page/page/surface.tsx | 4 +- ...rktreeContextMenu.delete-shortcut.test.tsx | 1 + .../sleep-worktree-activation-race.test.ts | 2 + .../use-worktree-context-menu-commands.ts | 2 +- .../navigation/use-keyboard.focus.test.tsx | 5 +- .../use-keyboard.host-identity.test.tsx | 6 +- .../worktree-list/navigation/use-keyboard.ts | 8 +- ...rtsStatusSegment.render-stability.test.tsx | 1 + .../ResourceUsageStatusSegment.rows.test.tsx | 1 + ...pace-space-manager-source-boundary.test.ts | 2 +- .../workspace-space-manager-table.tsx | 6 +- ...k-page-workspace-composer-boundary.test.ts | 2 +- ...nitial-terminal-structured-launch.test.tsx | 36 ++ .../use-task-page-workspace-actions.ts | 4 +- .../use-terminal-watcher-effects.ts | 6 + ...worktree-jump-palette-selection-actions.ts | 8 +- .../workspace-shortcut-ipc-bridge.ts | 9 +- .../useIpcEvents-cmd-j-digit-chord.test.ts | 5 +- ...agent-launch-caller-profile-census.test.ts | 3 +- ...ent-launch-caller-profiles-test-harness.ts | 15 + ...agent-launch-routing-caller-census.test.ts | 1 + ...empty-workspace-default-agent-chat.test.ts | 154 ++++++++ .../lib/empty-workspace-default-agent-chat.ts | 113 ++++++ .../empty-workspace-default-surface-claims.ts | 29 ++ .../src/lib/launch-agent-in-new-tab.ts | 4 + .../lib/linear-issue-workspace-open.test.ts | 2 + .../src/lib/linear-issue-workspace-open.ts | 16 +- .../lib/sidebar-worktree-activation.test.ts | 7 +- .../src/lib/sidebar-worktree-activation.ts | 12 +- ...tree-activation-default-agent-chat.test.ts | 362 ++++++++++++++++++ .../worktree-activation-gated-empty-reseed.ts | 50 ++- .../worktree-activation-nav-registration.ts | 5 +- .../lib/worktree-activation-store-contract.ts | 3 + .../worktree-activation-surface-selection.ts | 13 + src/renderer/src/lib/worktree-activation.ts | 39 +- .../lib/worktree-initial-terminal-seeding.ts | 26 +- .../src/lib/worktree-jump-navigation.test.ts | 11 +- .../src/lib/worktree-jump-navigation.ts | 1 + 45 files changed, 918 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts create mode 100644 src/renderer/src/lib/empty-workspace-default-agent-chat.ts create mode 100644 src/renderer/src/lib/empty-workspace-default-surface-claims.ts create mode 100644 src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 0881dd10d78..cfe5de95832 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -421,6 +421,7 @@ describe('WorktreeJumpPalette', () => { // The first row names ITS OWN host — the wrong-host open is gone. await act(async () => fireEvent.click(rows[0]!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + navigationIntent: 'user-open', executionHostId: 'local' }) }) @@ -443,6 +444,7 @@ describe('WorktreeJumpPalette', () => { await act(async () => fireEvent.click(rows[1]!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared', { + navigationIntent: 'user-open', executionHostId: 'ssh:box' }) }) @@ -487,6 +489,7 @@ describe('WorktreeJumpPalette', () => { await act(async () => fireEvent.click(hubARow!)) expect(activateAndRevealWorktree).toHaveBeenLastCalledWith('shared-runtime', { + navigationIntent: 'user-open', executionHostId: 'runtime:hub-a' }) }) diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index f9baff7d60d..ce546f03697 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -258,6 +258,7 @@ describe('activity thread host routing', () => { expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey]) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { + navigationIntent: 'user-open', executionHostId: REMOTE_HOST }) }) diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx index 23bc3d51c59..826b63ef4af 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx @@ -9,6 +9,7 @@ import { CheckRunDetailsPanel } from './CheckRunDetailsPanel' const writeClipboardText = vi.fn<(text: string) => Promise>() vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, translate: (_key: string, fallback: string) => fallback })) diff --git a/src/renderer/src/components/editor/IpynbRendering.test.tsx b/src/renderer/src/components/editor/IpynbRendering.test.tsx index 2086e6b1878..593104e2300 100644 --- a/src/renderer/src/components/editor/IpynbRendering.test.tsx +++ b/src/renderer/src/components/editor/IpynbRendering.test.tsx @@ -7,7 +7,10 @@ import { IpynbRunPrompt } from './IpynbCellToolbar' import { TooltipProvider } from '@/components/ui/tooltip' import type { IpynbCell, IpynbOutput } from './ipynb-parse' -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) vi.mock('./use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) vi.mock('@/lib/monaco-setup', () => ({ monaco: {} })) vi.mock('./MonacoCodeExcerpt', () => ({ diff --git a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx index 9be38fdb1d2..e244a50865a 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx @@ -71,7 +71,10 @@ vi.mock('@/lib/connection-context', () => ({ vi.mock('@/lib/connection-owner-resolution', () => ({ createConnectionIdForFileSelector: () => () => connectionOwner.value })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) vi.mock('./CodeBlockCopyButton', () => ({ diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 23f66fa9ee5..645d1f556d6 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -55,7 +55,10 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null vi.mock('@/lib/connection-owner-resolution', () => ({ createConnectionIdForFileSelector: () => () => null })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) vi.mock('./CodeBlockCopyButton', () => ({ diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx index 71e580a21d5..f07fe236c70 100644 --- a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx @@ -58,7 +58,9 @@ export default function GitHubItemDialog({ return } - const result = activateAndRevealWorktree(currentAttached.id) + const result = activateAndRevealWorktree(currentAttached.id, { + navigationIntent: 'user-open' + }) if (result === false) { toast.error( translate( diff --git a/src/renderer/src/components/pull-request-page/page/surface.tsx b/src/renderer/src/components/pull-request-page/page/surface.tsx index a299a0caa87..ffa5f401e2b 100644 --- a/src/renderer/src/components/pull-request-page/page/surface.tsx +++ b/src/renderer/src/components/pull-request-page/page/surface.tsx @@ -132,7 +132,9 @@ export default function PullRequestPage({ return } - const result = activateAndRevealWorktree(currentAttached.id) + const result = activateAndRevealWorktree(currentAttached.id, { + navigationIntent: 'user-open' + }) if (result === false) { toast.error( translate( diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx index c6664b14e2c..8bdac0ca143 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx @@ -79,6 +79,7 @@ vi.mock('lucide-react', async () => ) vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, translate: (_key: string, fallback: string) => fallback })) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index bf7eb3c758e..5f3b588f93c 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -88,6 +88,7 @@ describe('sleep flow vs slept-workspace activation', () => { expect(mocks.resumeWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-parent' }) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-parent', { + navigationIntent: 'user-open', revealInSidebar: false }) @@ -102,6 +103,7 @@ describe('sleep flow vs slept-workspace activation', () => { await activateWorktreeFromSidebar('wt-parent') expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-parent', { + navigationIntent: 'user-open', revealInSidebar: false }) expect(mocks.toastError).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index ebef4544b23..30920343680 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -164,7 +164,7 @@ export function useWorktreeContextMenuCommands(args: { }, [args]) const handleOpenParent = useCallback(() => { if (args.validParentWorktreeId) { - activateAndRevealWorktree(args.validParentWorktreeId) + activateAndRevealWorktree(args.validParentWorktreeId, { navigationIntent: 'user-open' }) } }, [args.validParentWorktreeId]) return { diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx index 53783410ef4..c09637354f2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx @@ -146,7 +146,10 @@ describe('workspace list focus ownership', () => { expect(document.activeElement).toBe(list) press('ArrowUp') expect(activate.mock.calls.map(([id]) => id)).toEqual(['b', 'c', 'b']) - expect(activate).toHaveBeenLastCalledWith('b', { executionHostId: 'ssh:fixture' }) + expect(activate).toHaveBeenLastCalledWith('b', { + navigationIntent: 'user-open', + executionHostId: 'ssh:fixture' + }) expect(terminal.focus).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx index efac59a9c07..4944d69340a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx @@ -101,7 +101,7 @@ describe('worktree keyboard cycling with a resolved active host', () => { press('down') - expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', {}) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', { navigationIntent: 'user-open' }) }) it('steps to the previous row when the active host resolved to local', () => { @@ -109,7 +109,7 @@ describe('worktree keyboard cycling with a resolved active host', () => { press('up') - expect(activateAndRevealWorktree).toHaveBeenCalledWith('a', {}) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('a', { navigationIntent: 'user-open' }) }) it('still steps normally when the active host is unqualified', () => { @@ -117,6 +117,6 @@ describe('worktree keyboard cycling with a resolved active host', () => { press('down') - expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', {}) + expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', { navigationIntent: 'user-open' }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts index cff03054692..612bd82b505 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts @@ -89,10 +89,10 @@ export function useWorktreeListKeyboardNavigation(args: { } // Why: keyboard cycling is real navigation; route through the activation helper that records history. - activateAndRevealWorktree( - nextWorktree.id, - nextWorktree.hostId ? { executionHostId: nextWorktree.hostId } : {} - ) + activateAndRevealWorktree(nextWorktree.id, { + navigationIntent: 'user-open', + ...(nextWorktree.hostId ? { executionHostId: nextWorktree.hostId } : {}) + }) const rowIndex = findPreferredRenderRowIndexForWorktreeIdentity( renderRows, diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx index d6d4ad95184..3f518db6751 100644 --- a/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/lib/react-error-boundary-reporting', () => ({ })) vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, translate: (_key: string, fallback: string, options?: Record) => options ? fallback.replace(/{{(\w+)}}/g, (_match, name: string) => String(options[name] ?? '')) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx index 783202de0cb..580c7f02f13 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx @@ -23,6 +23,7 @@ vi.mock('@/components/ui/tooltip', () => ({ })) vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, translate: (_key: string, fallback: string, values?: Record) => values ? Object.entries(values).reduce( diff --git a/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts b/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts index 5c8c1975783..3bf7a94c1dd 100644 --- a/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts @@ -74,6 +74,6 @@ describe('workspace space manager source boundaries', () => { expect(controller).toContain( 'removeWorktree(\n { id: worktree.worktreeId, executionHostId: worktree.executionHostId ?? null },\n true,\n { allowUnverifiedPtyStop: true }\n )' ) - expect(table).toContain('activateAndRevealWorktree(worktree.worktreeId)') + expect(table).toContain('activateAndRevealWorktree(worktree.worktreeId, {') }) }) diff --git a/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx b/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx index d4a14655539..8b0ec305124 100644 --- a/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-manager-table.tsx @@ -140,7 +140,11 @@ export function WorkspaceSpaceManagerTable({ deleteState={getDeleteStateForWorktree(worktree)} onToggleSelected={() => toggleSelection(worktree)} onInspect={() => setInspectedWorktreeId(identity)} - onOpenWorkspace={() => activateAndRevealWorktree(worktree.worktreeId)} + onOpenWorkspace={() => + activateAndRevealWorktree(worktree.worktreeId, { + navigationIntent: 'user-open' + }) + } onDelete={() => deleteWorktrees([worktree])} onForceDelete={() => forceDeleteWorktree(worktree)} /> diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts index a16128eee76..4eedb629f2a 100644 --- a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts +++ b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts @@ -110,7 +110,7 @@ describe('TaskPage workspace creation source boundaries', () => { expect(section).toContain('findGithubWorkItemWorkspaceAttachment(') expect(section).toContain('if (!currentAttached)') expect(section).toContain('handleUseWorkItem(item)') - expect(section).toContain('activateAndRevealWorktree(currentAttached.id)') + expect(section).toContain('activateAndRevealWorktree(currentAttached.id, {') }) it('uses the shared composer handler from GitHub detail and start-new actions', () => { diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index 734cef0f795..ef45ae6cb3e 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -3,6 +3,10 @@ import { act, StrictMode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' +import { + claimEmptyWorkspaceDefaultSurface, + releaseEmptyWorkspaceDefaultSurface +} from '@/lib/empty-workspace-default-surface-claims' const mocks = vi.hoisted(() => { const storeTabsByWorktree: Record = {} @@ -45,6 +49,8 @@ vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({ disposeAllParkedTerminalWatchers: vi.fn() })) +const PENDING_INTENT = { callerProvidesSurface: false, seedUserDefaultSurface: true } + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) let root: Root | undefined afterEach(async () => { @@ -202,6 +208,36 @@ describe('passive terminal seeding retries until a decision applies', () => { expect(mocks.createTab).toHaveBeenCalledTimes(1) }) + it('leaves the seed to an activation reseed awaiting agent detection', async () => { + const finishGate = deferredGate() + root = createRoot(document.createElement('div')) + await act(async () => root?.render()) + claimEmptyWorkspaceDefaultSurface('wt-1', PENDING_INTENT) + try { + await finishGate('empty') + expect(mocks.createTab).not.toHaveBeenCalled() + } finally { + releaseEmptyWorkspaceDefaultSurface('wt-1') + } + }) + + // Why: that reseed bails once the user leaves, so a workspace left mid-wait must seed on return. + it('seeds on return to a workspace left while a reseed awaited agent detection', async () => { + mocks.gate.mockResolvedValue('empty') + claimEmptyWorkspaceDefaultSurface('wt-1', PENDING_INTENT) + root = createRoot(document.createElement('div')) + try { + await act(async () => root?.render()) + expect(mocks.createTab).not.toHaveBeenCalled() + } finally { + releaseEmptyWorkspaceDefaultSurface('wt-1') + } + + await act(async () => root?.render()) + await act(async () => root?.render()) + expect(mocks.createTab).toHaveBeenCalledTimes(1) + }) + it('seeds after leaving and returning to a blocked workspace', async () => { mocks.gate.mockResolvedValue('blocked') root = createRoot(document.createElement('div')) diff --git a/src/renderer/src/components/use-task-page-workspace-actions.ts b/src/renderer/src/components/use-task-page-workspace-actions.ts index 2a88981b675..5e2cb6f2aab 100644 --- a/src/renderer/src/components/use-task-page-workspace-actions.ts +++ b/src/renderer/src/components/use-task-page-workspace-actions.ts @@ -60,7 +60,9 @@ export function useTaskPageWorkspaceActions(model: TaskPageSearchActionsModel) { handleUseWorkItem(item) return } - const result = activateAndRevealWorktree(currentAttached.id) + const result = activateAndRevealWorktree(currentAttached.id, { + navigationIntent: 'user-open' + }) if (result === false) { toast.error( item.type === 'pr' diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index f91c06bd7d7..39764c87345 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -14,6 +14,7 @@ import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-term import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority' import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' @@ -223,6 +224,11 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) ) { return } + // A reseed awaiting agent detection owns the surface. Left unmarked: that reseed bails if the + // user leaves during the wait, so a later return must seed here. + if (outcome === 'empty' && isEmptyWorkspaceDefaultSurfacePending(activeWorktreeId)) { + return + } // Why mark only once a decision applies: a cancelled or blocked check must stay retryable, // and a rerun shares the gate's in-flight promise instead of repeating its work. startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId) diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts index e0da6c158fe..822a23f9e62 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts @@ -65,10 +65,10 @@ export function useWorktreeJumpPaletteSelectionActions({ ) return } - const activation = activateAndRevealWorktree( - worktree.id, - executionHostId ? { executionHostId } : {} - ) + const activation = activateAndRevealWorktree(worktree.id, { + navigationIntent: 'user-open', + ...(executionHostId ? { executionHostId } : {}) + }) recordFeatureInteraction('cmd-j-workspace-open') skipRestoreFocusRef.current = true closeModal() diff --git a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts index 9e78916fef3..496f885ebbb 100644 --- a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts @@ -93,11 +93,10 @@ export function registerWorkspaceShortcutIpcBridge(unsubs: (() => void)[]): void const visibleTargets = getVisibleWorktreeShortcutTargets() const target = visibleTargets[index] if (target) { - if (target.executionHostId) { - activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) - } else { - activateAndRevealWorkspace(target.id) - } + activateAndRevealWorkspace(target.id, { + navigationIntent: 'user-open', + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}) + }) } }) ) diff --git a/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts b/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts index a0ff1ea24dc..ae61b9adcb9 100644 --- a/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts @@ -52,7 +52,9 @@ describe('useIpcEvents digit-chord routing while Cmd+J is open', () => { expect(rowJumps).toEqual([]) // Why assert the activation and not just the silent bus: a premature return would also emit nothing. - expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-c') + expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-c', { + navigationIntent: 'user-open' + }) }) it('routes duplicate ids at different positions to their rendered hosts', async () => { @@ -67,6 +69,7 @@ describe('useIpcEvents digit-chord routing while Cmd+J is open', () => { harness.jumpToWorktreeIndex(1) expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::path', { + navigationIntent: 'user-open', executionHostId: 'ssh:box' }) }) diff --git a/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts b/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts index 5a3dadedf83..0b168e66ef4 100644 --- a/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts @@ -33,7 +33,7 @@ describe('agent launch caller profiles', () => { const ids = AGENT_LAUNCH_CALLER_PROFILES.map((profile) => profile.id) expect(new Set(ids).size).toBe(ids.length) - expect(ids.length).toBe(12) + expect(ids.length).toBe(13) }) it.each(AGENT_LAUNCH_CALLER_PROFILES.map((profile) => [profile.id, profile] as const))( @@ -70,6 +70,7 @@ describe('agent launch caller profiles', () => { // Why: this is the coupling a move to an async launch RPC threatens most directly — these // call sites cannot tolerate a tab id that only exists after the launch settles. expect(readers.sort()).toEqual([ + 'empty-workspace-default-chat', 'fix-checks', 'floating-default-agent', 'quick-command', diff --git a/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts b/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts index 39d7c77a13f..5aaa913044f 100644 --- a/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts @@ -58,6 +58,21 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = passesOnPromptDelivered: false, readsBack: ['null-only'] }, + { + id: 'empty-workspace-default-chat', + caller: 'src/renderer/src/lib/empty-workspace-default-agent-chat.ts', + sourceMarkers: ["launchSource: 'unknown'", 'pendingActivationSpawn: true'], + args: { + agent: 'codex', + worktreeId: 'wt-1', + launchSource: 'unknown', + pendingActivationSpawn: true + }, + passesBeforeSurfaceOpen: false, + passesLaunchPlan: true, + passesOnPromptDelivered: false, + readsBack: ['surface-tab-id'] + }, { id: 'floating-default-agent', caller: 'src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx', diff --git a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts index 3e91c5c8ec0..2c9524c9995 100644 --- a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts +++ b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts @@ -18,6 +18,7 @@ const LAUNCH_AGENT_IN_NEW_TAB_CALLERS = [ 'src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts', 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', 'src/renderer/src/components/use-terminal-create-actions.ts', + 'src/renderer/src/lib/empty-workspace-default-agent-chat.ts', 'src/renderer/src/lib/fix-checks-agent-launch.ts', 'src/renderer/src/lib/launch-agent-session-continuation.ts', 'src/renderer/src/lib/run-quick-command-in-new-tab.ts' diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts new file mode 100644 index 00000000000..e3ad93eb3db --- /dev/null +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../shared/constants' +import { useAppStore } from '@/store' +import { + emptyWorkspaceDefaultChatAwaitsDetection, + loadEmptyWorkspaceDefaultChatDetection, + openDefaultAgentChatInEmptyWorkspace +} from './empty-workspace-default-agent-chat' + +const mocks = vi.hoisted(() => ({ + launchAgentInNewTab: vi.fn(), + planAgentSessionLaunch: vi.fn(), + detectionTargetKey: vi.fn<() => string | undefined>() +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mocks.launchAgentInNewTab +})) +vi.mock('@/lib/agent-session-launch-plan', () => ({ + planAgentSessionLaunch: mocks.planAgentSessionLaunch +})) +vi.mock('@/hooks/useAgentDetectionTarget', async (importOriginal) => ({ + ...(await importOriginal>()), + getAgentDetectionTargetKeyForWorktree: mocks.detectionTargetKey +})) + +const initialAppStoreState = useAppStore.getState() + +function seedSettings(settings: Partial): void { + useAppStore.setState({ + detectedAgentIds: ['claude', 'codex'], + settings: { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + defaultTuiAgent: 'codex', + disabledTuiAgents: [], + ...settings + } + }) +} + +beforeEach(() => { + mocks.detectionTargetKey.mockReturnValue('local') + mocks.planAgentSessionLaunch.mockReturnValue({ route: 'structured-native-chat' }) + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'chat-tab', sessionId: 's-1' } + }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + useAppStore.setState(initialAppStoreState, true) +}) + +describe('openDefaultAgentChatInEmptyWorkspace', () => { + it('launches the default agent as a chat', () => { + seedSettings({}) + + expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toEqual({ primaryTabId: 'chat-tab' }) + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ agent: 'codex', worktreeId: 'wt-1', pendingActivationSpawn: true }) + ) + }) + + it('does nothing unless new agent tabs open as chat', () => { + seedSettings({ openAgentTabsInChatByDefault: false }) + + expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + }) + + it('respects a Blank Terminal default agent', () => { + seedSettings({ defaultTuiAgent: 'blank' }) + + expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + }) + + it('does not start an agent that could only open as a terminal here', () => { + seedSettings({}) + mocks.planAgentSessionLaunch.mockReturnValue({ route: 'terminal-tui' }) + + expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + }) + + it('reads the agents detected on the workspace SSH host', () => { + seedSettings({}) + mocks.detectionTargetKey.mockReturnValue('ssh:conn-1') + useAppStore.setState({ remoteDetectedAgentIds: { 'conn-1': ['claude'] } }) + + openDefaultAgentChatInEmptyWorkspace('wt-1') + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ agent: 'claude' }) + ) + }) + + it('treats a workspace whose host is unresolved as unknown', () => { + seedSettings({}) + mocks.detectionTargetKey.mockReturnValue(undefined) + + expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) + expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + }) +}) + +describe('agent detection for the default chat', () => { + it('waits only while chat is the default and the host list has not loaded', () => { + seedSettings({}) + mocks.detectionTargetKey.mockReturnValue('ssh:conn-1') + useAppStore.setState({ remoteDetectedAgentIds: {} }) + expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(true) + + useAppStore.setState({ remoteDetectedAgentIds: { 'conn-1': [] } }) + expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) + + seedSettings({ openAgentTabsInChatByDefault: false }) + useAppStore.setState({ remoteDetectedAgentIds: {} }) + expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) + }) + + // Why: Blank Terminal opens a shell whatever the host has, so waiting only delays that shell. + it('does not wait when the default agent is Blank Terminal', () => { + seedSettings({ defaultTuiAgent: 'blank' }) + mocks.detectionTargetKey.mockReturnValue('ssh:conn-1') + useAppStore.setState({ remoteDetectedAgentIds: {} }) + + expect(emptyWorkspaceDefaultChatAwaitsDetection('wt-1')).toBe(false) + }) + + it('probes the workspace host and gives up after a bounded wait', async () => { + vi.useFakeTimers() + seedSettings({}) + mocks.detectionTargetKey.mockReturnValue('ssh:conn-1') + const ensureRemoteDetectedAgents = vi.fn(() => new Promise(() => {})) + useAppStore.setState({ ensureRemoteDetectedAgents }) + let settled = false + + void loadEmptyWorkspaceDefaultChatDetection('wt-1').then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + + expect(ensureRemoteDetectedAgents).toHaveBeenCalledWith('conn-1') + expect(settled).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/empty-workspace-default-agent-chat.ts b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts new file mode 100644 index 00000000000..84156f99927 --- /dev/null +++ b/src/renderer/src/lib/empty-workspace-default-agent-chat.ts @@ -0,0 +1,113 @@ +import { agentTabsDefaultToNativeChat } from '../../../shared/structured-native-chat-launch-route' +import { pickTuiAgent } from '../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../shared/tui-agent' +import { withTimeout } from '../../../shared/promise-timeout-fallback' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { + getAgentDetectionTargetKeyForWorktree, + parseAgentDetectionTargetKey +} from '@/hooks/useAgentDetectionTarget' +import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' +import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' + +// Why bounded: detection only decides chat vs shell, so a slow host must not hold the workspace empty. +const DEFAULT_CHAT_DETECTION_TIMEOUT_MS = 5_000 + +/** + * The host whose agent list picks the default chat; undefined when the workspace opens a shell + * whatever that list says. Shared by the wait and the open so they cannot drift. + */ +function defaultChatDetectionTarget( + state: AppState, + worktreeId: string +): AgentDetectionTarget | undefined { + // Why: a 'blank' default means the user wants workspaces to open without an agent. + if ( + !agentTabsDefaultToNativeChat(state.settings) || + state.settings?.defaultTuiAgent === 'blank' + ) { + return undefined + } + // Why: an unresolved owner is an unknown host, not the local machine. + return parseAgentDetectionTargetKey(getAgentDetectionTargetKeyForWorktree(state, worktreeId)) +} + +/** Same host lists the tab bar reads; null means that host's detection has not loaded. */ +function readDetectedAgents(state: AppState, target: AgentDetectionTarget): TuiAgent[] | null { + if (target.kind === 'ssh') { + return state.remoteDetectedAgentIds[target.connectionId] ?? null + } + if (target.kind === 'runtime') { + return state.runtimeDetectedAgentIds[target.environmentId] ?? null + } + return target.contextKey + ? (state.localDetectedAgentIdsByContext[target.contextKey] ?? null) + : state.detectedAgentIds +} + +/** True when the default chat could open here but the workspace host's agent list has not loaded. */ +export function emptyWorkspaceDefaultChatAwaitsDetection(worktreeId: string): boolean { + const state = useAppStore.getState() + const target = defaultChatDetectionTarget(state, worktreeId) + return target !== undefined && readDetectedAgents(state, target) === null +} + +/** Loads the workspace host's agent list; resolves on success, failure, or timeout. */ +export async function loadEmptyWorkspaceDefaultChatDetection(worktreeId: string): Promise { + const state = useAppStore.getState() + const target = defaultChatDetectionTarget(state, worktreeId) + if (!target) { + return + } + const detection = + target.kind === 'ssh' + ? state.ensureRemoteDetectedAgents(target.connectionId) + : target.kind === 'runtime' + ? state.ensureRuntimeDetectedAgents(target.environmentId) + : state.ensureDetectedAgents(target.worktreeId) + await withTimeout(detection, DEFAULT_CHAT_DETECTION_TIMEOUT_MS, []) +} + +/** + * When the user's new agent tabs open as chat, an empty workspace opens their default agent as a + * chat instead of a bare shell. Null means nothing opened and the caller seeds the shell. + */ +export function openDefaultAgentChatInEmptyWorkspace( + worktreeId: string +): { primaryTabId: string | null } | null { + const state = useAppStore.getState() + const target = defaultChatDetectionTarget(state, worktreeId) + if (!target) { + return null + } + const agent = pickTuiAgent( + state.settings?.defaultTuiAgent, + readDetectedAgents(state, target) ?? [], + state.settings?.disabledTuiAgents + ) + if (!agent) { + return null + } + const agentSessionLaunchPlan = planAgentSessionLaunch(state, { + agent, + workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId } + }) + // Why: an agent that can only open as a TUI here would replace the shell with a process nobody asked for. + if (agentSessionLaunchPlan.route === 'terminal-tui') { + return null + } + const result = launchAgentInNewTab({ + agent, + worktreeId, + launchSource: 'unknown', + agentSessionLaunchPlan, + pendingActivationSpawn: true + }) + if (!result) { + return null + } + return { primaryTabId: result.surface.kind === 'host-published' ? null : result.surface.tabId } +} diff --git a/src/renderer/src/lib/empty-workspace-default-surface-claims.ts b/src/renderer/src/lib/empty-workspace-default-surface-claims.ts new file mode 100644 index 00000000000..61a989e6bfc --- /dev/null +++ b/src/renderer/src/lib/empty-workspace-default-surface-claims.ts @@ -0,0 +1,29 @@ +import type { GatedEmptyWorkspaceReseedIntent } from './worktree-initial-terminal-seeding' + +// Why: while a gated reseed waits on agent detection, it owns the workspace's first surface; +// any other seeder landing in that wait would put a shell beside the chat it is about to open. +// Why the intent: the wait seeds for the latest activation, so a later one must not be dropped. +const pendingIntentByWorkspace = new Map() + +/** Records this activation's intent; false when a wait already pending now carries it instead. */ +export function claimEmptyWorkspaceDefaultSurface( + workspaceKey: string, + intent: GatedEmptyWorkspaceReseedIntent +): boolean { + const alreadyPending = pendingIntentByWorkspace.has(workspaceKey) + pendingIntentByWorkspace.set(workspaceKey, intent) + return !alreadyPending +} + +/** Ends the wait and returns the latest activation's intent. */ +export function releaseEmptyWorkspaceDefaultSurface( + workspaceKey: string +): GatedEmptyWorkspaceReseedIntent | undefined { + const intent = pendingIntentByWorkspace.get(workspaceKey) + pendingIntentByWorkspace.delete(workspaceKey) + return intent +} + +export function isEmptyWorkspaceDefaultSurfacePending(workspaceKey: string): boolean { + return pendingIntentByWorkspace.has(workspaceKey) +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index f4923c79b5e..ce28d3c8ad1 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -60,6 +60,8 @@ export type LaunchAgentInNewTabArgs = { onPromptDeliveryUnconfirmed?: () => void /** Keeps a preflighted route authoritative across workspace creation. */ agentSessionLaunchPlan?: AgentSessionLaunchPlan + /** The launch seeds a workspace being opened, so its PTY spawn must not reshuffle Recent. */ + pendingActivationSpawn?: boolean /** Lets a workspace reveal itself before the selected surface opens. */ beforeSurfaceOpen?: ( surface: @@ -114,6 +116,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent onPromptDelivered, onPromptDeliveryUnconfirmed, agentSessionLaunchPlan, + pendingActivationSpawn, beforeSurfaceOpen } = args const store = useAppStore.getState() @@ -245,6 +248,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, + ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), ...initialViewModeProps }) seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) diff --git a/src/renderer/src/lib/linear-issue-workspace-open.test.ts b/src/renderer/src/lib/linear-issue-workspace-open.test.ts index 86a72ab4df9..e455c52308a 100644 --- a/src/renderer/src/lib/linear-issue-workspace-open.test.ts +++ b/src/renderer/src/lib/linear-issue-workspace-open.test.ts @@ -87,6 +87,7 @@ describe('openLinearIssueWorkspaceOrStart', () => { expect(openLinearIssueWorkspaceOrStart({ identifier: 'ENG-1' }, vi.fn())).toBe('opened') expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { + navigationIntent: 'user-open', executionHostId: 'ssh:builder' }) }) @@ -97,6 +98,7 @@ describe('openLinearIssueWorkspaceOrStart', () => { expect(openLinearIssueWorkspaceOrStart({ identifier: 'ENG-1' }, startWorkspace)).toBe('opened') expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-1', { + navigationIntent: 'user-open', executionHostId: 'local' }) expect(startWorkspace).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/linear-issue-workspace-open.ts b/src/renderer/src/lib/linear-issue-workspace-open.ts index 4b838176f44..63d2944c180 100644 --- a/src/renderer/src/lib/linear-issue-workspace-open.ts +++ b/src/renderer/src/lib/linear-issue-workspace-open.ts @@ -35,14 +35,14 @@ export function openLinearIssueWorkspaceOrStart( const workspaceScope = parseWorkspaceKey(attached.id) const activation = workspaceScope?.type === 'folder' - ? activateAndRevealFolderWorkspace( - workspaceScope.folderWorkspaceId, - attached.hostId ? { executionHostId: attached.hostId } : undefined - ) - : activateAndRevealWorktree( - attached.id, - attached.hostId ? { executionHostId: attached.hostId } : {} - ) + ? activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId, { + navigationIntent: 'user-open', + ...(attached.hostId ? { executionHostId: attached.hostId } : {}) + }) + : activateAndRevealWorktree(attached.id, { + navigationIntent: 'user-open', + ...(attached.hostId ? { executionHostId: attached.hostId } : {}) + }) if (activation === false) { toast.error( translate( diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index a33f91565a2..a12ca8affa8 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -26,6 +26,7 @@ describe('sidebar worktree activation', () => { await activateWorktreeFromSidebar('wt-live') expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live', { + navigationIntent: 'user-open', revealInSidebar: false }) expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled() @@ -38,6 +39,7 @@ describe('sidebar worktree activation', () => { // sidebar click itself must switch app state immediately. expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-slept', { + navigationIntent: 'user-open', revealInSidebar: false }) }) @@ -57,6 +59,7 @@ describe('sidebar worktree activation', () => { const activation = activateWorktreeFromSidebar('wt-vm') expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-vm', { + navigationIntent: 'user-open', revealInSidebar: false }) expect(resumeWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-vm' }) @@ -68,7 +71,9 @@ describe('sidebar worktree activation', () => { it('routes folder workspace activation through the guarded folder path', async () => { await activateWorktreeFromSidebar('folder:folder-workspace-1') - expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1') + expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + navigationIntent: 'user-open' + }) expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index dd4eec3f0df..01bb94ad11b 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -13,17 +13,15 @@ export async function activateWorktreeFromSidebar( ): Promise { const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { - if (executionHostId) { - activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId, { - executionHostId - }) - } else { - activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) - } + activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId, { + navigationIntent: 'user-open', + ...(executionHostId ? { executionHostId } : {}) + }) return } // Keep navigation independent from an optional runtime wake IPC. activateAndRevealWorktree(worktreeId, { + navigationIntent: 'user-open', revealInSidebar: false, ...(executionHostId ? { executionHostId } : {}) }) diff --git a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts new file mode 100644 index 00000000000..ae956cabd21 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' +import * as activationGate from './worktree-agent-activation-gate' +import { + ensureWorktreeHasInitialTerminal, + reseedGatedEmptyWorkspace +} from './worktree-initial-terminal-seeding' +import { + isEmptyWorkspaceDefaultSurfacePending, + releaseEmptyWorkspaceDefaultSurface +} from './empty-workspace-default-surface-claims' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + makeCreatedAgentWorktree as makeWorktree, + seedEmptyActivatableWorktree +} from '@/lib/worktree-activation-created-agent-test-state' +import { + createMockStore, + registerWorktreeActivationReset +} from './worktree-activation-test-harness' + +const defaultChat = vi.hoisted(() => ({ + open: vi.fn(), + awaitsDetection: vi.fn(), + loadDetection: vi.fn() +})) + +vi.mock('@/lib/empty-workspace-default-agent-chat', () => ({ + openDefaultAgentChatInEmptyWorkspace: defaultChat.open, + emptyWorkspaceDefaultChatAwaitsDetection: defaultChat.awaitsDetection, + loadEmptyWorkspaceDefaultChatDetection: defaultChat.loadDetection +})) + +const USER_OPEN = { navigationIntent: 'user-open' } as const +const FOLDER_ID = 'folder-1' +const FOLDER_KEY = folderWorkspaceKey(FOLDER_ID) +const initialAppStoreState = useAppStore.getState() + +registerWorktreeActivationReset() + +beforeEach(() => { + defaultChat.open.mockReset() + defaultChat.awaitsDetection.mockReset().mockReturnValue(false) + defaultChat.loadDetection.mockReset().mockResolvedValue(undefined) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + releaseEmptyWorkspaceDefaultSurface(makeWorktree().id) + releaseEmptyWorkspaceDefaultSurface(FOLDER_KEY) + useAppStore.setState(initialAppStoreState, true) +}) + +/** Electron exposes the PTY inventory, so an empty workspace is seeded by the gated reseed. */ +function useElectronActivationGate(): ReturnType { + vi.stubGlobal('window', { api: { runtime: { call: vi.fn() }, pty: { listSessions: vi.fn() } } }) + return vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockResolvedValue('empty') +} + +function seedEmptyFolderWorkspace(): void { + useAppStore.setState({ + folderWorkspaces: [ + { + id: FOLDER_ID, + projectGroupId: 'group-1', + name: 'notes', + folderPath: '/local/notes', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0 + } + ], + activeView: 'terminal', + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + getFreshFolderWorkspacePathStatus: () => ({ exists: true }), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as unknown as Partial>) +} + +function tabCount(workspaceKey: string): number { + return (useAppStore.getState().tabsByWorktree[workspaceKey] ?? []).length +} + +async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +// Why: with chat as the default view, clicking an empty workspace used to open a bare shell the +// user then had to turn into a chat by hand. +describe('empty workspace seeding with a default agent chat', () => { + it('opens the default agent chat instead of a shell', () => { + defaultChat.open.mockReturnValue({ primaryTabId: 'chat-tab' }) + const createTab = vi.fn(() => ({ id: 'shell-tab' })) + const store = createMockStore({ createTab }) + + const primaryTabId = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + { seedUserDefaultSurface: true } + ) + + expect(defaultChat.open).toHaveBeenCalledWith('wt-1') + expect(primaryTabId).toBe('chat-tab') + expect(createTab).not.toHaveBeenCalled() + }) + + it('falls back to a shell when no chat can open', () => { + defaultChat.open.mockReturnValue(null) + const createTab = vi.fn(() => ({ id: 'shell-tab' })) + const store = createMockStore({ createTab }) + + const primaryTabId = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + { seedUserDefaultSurface: true } + ) + + expect(primaryTabId).toBe('shell-tab') + expect(createTab).toHaveBeenCalledTimes(1) + }) + + it('keeps the shell for seeds that did not ask for the default surface', () => { + const store = createMockStore({ createTab: vi.fn(() => ({ id: 'shell-tab' })) }) + + ensureWorktreeHasInitialTerminal(store, 'wt-1') + ensureWorktreeHasInitialTerminal(store, 'wt-2', undefined, undefined, undefined, undefined, { + seedUserDefaultSurface: true, + activateCreatedTabs: false + }) + + expect(defaultChat.open).not.toHaveBeenCalled() + }) + + it('a user open of an empty workspace asks for the default surface', () => { + defaultChat.open.mockReturnValue({ primaryTabId: null }) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) + + expect(defaultChat.open).toHaveBeenCalledWith(worktree.id) + }) + + it('back/forward navigation asks for the default surface', () => { + defaultChat.open.mockReturnValue({ primaryTabId: null }) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + useAppStore.setState({ + worktreeNavHistory: [worktree.id, 'repo-1::/elsewhere'], + worktreeNavHistoryIndex: 1 + }) + + useAppStore.getState().goBackWorktree() + + expect(defaultChat.open).toHaveBeenCalledWith(worktree.id) + }) + + // Why: CLI/phone creates, fallbacks after a failed agent launch, and the move after a delete + // activate with no intent; none of them may start an agent nobody picked. + it.each([ + ['no navigation intent', {}], + ['a Blank Terminal pick', { ...USER_OPEN, agent: null }] + ])('keeps the shell for %s', (_label, selection) => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + activateAndRevealWorktree(worktree.id, { ...selection, notifyHostRuntime: false }) + + expect(defaultChat.open).not.toHaveBeenCalled() + expect(tabCount(worktree.id)).toBe(1) + }) +}) + +describe('the gated reseed that seeds an empty workspace in Electron', () => { + it('opens the default agent chat for a user open', async () => { + defaultChat.open.mockReturnValue({ primaryTabId: null }) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + const result = activateAndRevealWorktree(worktree.id, { + ...USER_OPEN, + notifyHostRuntime: false + }) + expect(result === false ? 'failed' : result.primaryTabId).toBeNull() + await gate.mock.results[0]?.value + + expect(defaultChat.open).toHaveBeenCalledWith(worktree.id) + expect(tabCount(worktree.id)).toBe(0) + }) + + it('seeds a shell for an activation without a user open', async () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[0]?.value + + expect(defaultChat.open).not.toHaveBeenCalled() + expect(tabCount(worktree.id)).toBe(1) + }) + + it('opens the default agent chat for a user-opened folder workspace', async () => { + defaultChat.open.mockReturnValue({ primaryTabId: null }) + seedEmptyFolderWorkspace() + const gate = useElectronActivationGate() + + activateAndRevealFolderWorkspace(FOLDER_ID, { ...USER_OPEN, executionHostId: 'local' }) + await gate.mock.results[0]?.value + + expect(defaultChat.open).toHaveBeenCalledWith(FOLDER_KEY) + expect(tabCount(FOLDER_KEY)).toBe(0) + }) + + it('waits for the host agent list and lets no other reseed seed a shell meanwhile', async () => { + defaultChat.open.mockReturnValue({ primaryTabId: null }) + defaultChat.awaitsDetection.mockReturnValue(true) + let finishDetection!: () => void + defaultChat.loadDetection.mockReturnValue( + new Promise((resolve) => { + finishDetection = resolve + }) + ) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) + await gate.mock.results[0]?.value + + expect(defaultChat.loadDetection).toHaveBeenCalledWith(worktree.id) + expect(isEmptyWorkspaceDefaultSurfacePending(worktree.id)).toBe(true) + reseedGatedEmptyWorkspace(worktree.id, { + callerProvidesSurface: false, + seedUserDefaultSurface: false + }) + expect(tabCount(worktree.id)).toBe(0) + expect(defaultChat.open).not.toHaveBeenCalled() + + finishDetection() + await flushPromises() + + expect(isEmptyWorkspaceDefaultSurfacePending(worktree.id)).toBe(false) + expect(defaultChat.open).toHaveBeenCalledTimes(1) + expect(tabCount(worktree.id)).toBe(0) + }) + + // Why: the wait used to keep the first activation's intent, so returning to the workspace through + // history (no host id) failed the first intent's host check and left it with no surface at all. + it('seeds for the latest activation when the workspace is reopened during the wait', async () => { + defaultChat.open.mockReturnValue({ primaryTabId: null }) + defaultChat.awaitsDetection.mockReturnValue(true) + let finishDetection!: () => void + defaultChat.loadDetection.mockReturnValue( + new Promise((resolve) => { + finishDetection = resolve + }) + ) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + activateAndRevealWorktree(worktree.id, { + ...USER_OPEN, + executionHostId: 'local', + notifyHostRuntime: false + }) + await gate.mock.results[0]?.value + useAppStore.setState({ activeWorktreeId: 'repo-1::/elsewhere' }) + activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) + await gate.mock.results[1]?.value + expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBeNull() + finishDetection() + await flushPromises() + + expect(defaultChat.loadDetection).toHaveBeenCalledTimes(1) + expect(isEmptyWorkspaceDefaultSurfacePending(worktree.id)).toBe(false) + expect(defaultChat.open).toHaveBeenCalledTimes(1) + }) + + it('seeds a shell when a plain activation follows the user open during the wait', async () => { + defaultChat.awaitsDetection.mockReturnValue(true) + let finishDetection!: () => void + defaultChat.loadDetection.mockReturnValue( + new Promise((resolve) => { + finishDetection = resolve + }) + ) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) + await gate.mock.results[0]?.value + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await gate.mock.results[1]?.value + expect(tabCount(worktree.id)).toBe(0) + finishDetection() + await flushPromises() + + expect(defaultChat.open).not.toHaveBeenCalled() + expect(tabCount(worktree.id)).toBe(1) + }) + + it('re-checks the active workspace after the wait', async () => { + defaultChat.awaitsDetection.mockReturnValue(true) + let finishDetection!: () => void + defaultChat.loadDetection.mockReturnValue( + new Promise((resolve) => { + finishDetection = resolve + }) + ) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) + await gate.mock.results[0]?.value + useAppStore.setState({ activeWorktreeId: 'repo-1::/elsewhere' }) + finishDetection() + await flushPromises() + + expect(isEmptyWorkspaceDefaultSurfacePending(worktree.id)).toBe(false) + expect(defaultChat.open).not.toHaveBeenCalled() + expect(tabCount(worktree.id)).toBe(0) + }) + + it('seeds the shell when detection fails', async () => { + defaultChat.open.mockReturnValue(null) + defaultChat.awaitsDetection.mockReturnValue(true) + defaultChat.loadDetection.mockRejectedValue(new Error('ssh probe failed')) + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + const gate = useElectronActivationGate() + + activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false }) + await gate.mock.results[0]?.value + await flushPromises() + + expect(isEmptyWorkspaceDefaultSurfacePending(worktree.id)).toBe(false) + expect(tabCount(worktree.id)).toBe(1) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts b/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts index bb597d10e6e..2ab11bb1452 100644 --- a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts +++ b/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts @@ -1,14 +1,20 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' import { gateWorktreeAgentActivation, type WorktreeAgentActivationOutcome } from './worktree-agent-activation-gate' -import { reseedGatedEmptyWorkspace } from './worktree-initial-terminal-seeding' - -type GatedEmptyWorkspaceReseedIntent = { - callerProvidesSurface: boolean - executionHostId?: ExecutionHostId -} +import { + reseedGatedEmptyWorkspace, + type GatedEmptyWorkspaceReseedIntent +} from './worktree-initial-terminal-seeding' +import { + emptyWorkspaceDefaultChatAwaitsDetection, + loadEmptyWorkspaceDefaultChatDetection +} from './empty-workspace-default-agent-chat' +import { + claimEmptyWorkspaceDefaultSurface, + isEmptyWorkspaceDefaultSurfacePending, + releaseEmptyWorkspaceDefaultSurface +} from './empty-workspace-default-surface-claims' const latestReseedIntentByGate = new WeakMap< Promise, @@ -17,22 +23,36 @@ const latestReseedIntentByGate = new WeakMap< export function gateAndReseedEmptyWorkspace( workspaceKey: string, - callerProvidesSurface: boolean, - executionHostId?: ExecutionHostId + intent: GatedEmptyWorkspaceReseedIntent ): void { const gate = gateWorktreeAgentActivation(workspaceKey) - const intent: GatedEmptyWorkspaceReseedIntent = { - callerProvidesSurface, - ...(executionHostId ? { executionHostId } : {}) - } latestReseedIntentByGate.set(gate, intent) void gate.then((outcome) => { if (latestReseedIntentByGate.get(gate) !== intent) { return } latestReseedIntentByGate.delete(gate) - if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, intent.callerProvidesSurface, intent.executionHostId) + if (outcome !== 'empty') { + return } + const awaitsDetection = + intent.seedUserDefaultSurface && emptyWorkspaceDefaultChatAwaitsDetection(workspaceKey) + if (!awaitsDetection && !isEmptyWorkspaceDefaultSurfacePending(workspaceKey)) { + reseedGatedEmptyWorkspace(workspaceKey, intent) + return + } + // Why: the default agent depends on the host's list; seeding before it loads locks in a shell. + // A wait already pending takes this later intent instead, so the latest activation wins. + if (!claimEmptyWorkspaceDefaultSurface(workspaceKey, intent)) { + return + } + const settle = (): void => { + const latestIntent = releaseEmptyWorkspaceDefaultSurface(workspaceKey) + // Re-checks the active workspace, host, and emptiness the wait may have changed. + if (latestIntent) { + reseedGatedEmptyWorkspace(workspaceKey, latestIntent) + } + } + void loadEmptyWorkspaceDefaultChatDetection(workspaceKey).then(settle, settle) }) } diff --git a/src/renderer/src/lib/worktree-activation-nav-registration.ts b/src/renderer/src/lib/worktree-activation-nav-registration.ts index 11a0af53329..46ab7f30c63 100644 --- a/src/renderer/src/lib/worktree-activation-nav-registration.ts +++ b/src/renderer/src/lib/worktree-activation-nav-registration.ts @@ -4,13 +4,14 @@ import { } from '@/store/slices/worktree-nav-history' import type { WorktreeNavHistoryViewEntry } from '@/store/slices/worktree-nav-history' -type ActivateFn = (worktreeId: string) => unknown +type ActivateFn = (worktreeId: string, opts: { navigationIntent: 'user-open' }) => unknown type ViewActivateFn = (entry: WorktreeNavHistoryViewEntry) => void export function registerWorktreeActivation( activate: ActivateFn, activateView: ViewActivateFn ): void { - setWorktreeNavActivator(activate) + // Why: back/forward is the user reopening a workspace they chose, like a sidebar click. + setWorktreeNavActivator((worktreeId) => activate(worktreeId, { navigationIntent: 'user-open' })) setWorktreeNavViewActivator(activateView) } diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index 3608c9def1c..66de89e85e3 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -77,4 +77,7 @@ export type InitialTerminalOptions = { * Setup/issue work still runs, but work that needs no host terminal must not seed a shell * beside the chat the caller is about to create. */ callerProvidesSurface?: boolean + /** The user deliberately opened this workspace with no surface choice, so it may open their + * default agent chat instead of a bare shell. */ + seedUserDefaultSurface?: boolean } diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts index 4385994ff94..9b78a23cd09 100644 --- a/src/renderer/src/lib/worktree-activation-surface-selection.ts +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -13,6 +13,8 @@ export type WorktreeActivationSurfaceSelection = { agent?: TuiAgent | null /** A navigation caller is about to open its own editor, diff, or other non-terminal surface. */ providesInitialSurface?: boolean + /** Set only where the user deliberately opened this existing workspace; absent keeps the shell seed. */ + navigationIntent?: 'user-open' } export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { @@ -38,3 +40,14 @@ export function activationProvidesInitialSurface( ): boolean { return selection?.providesInitialSurface === true || selection?.agent != null } + +/** A deliberate user open with no surface choice: an empty workspace gets the user's default surface. */ +export function activationSeedsUserDefaultSurface( + selection?: WorktreeActivationSurfaceSelection +): boolean { + return ( + selection?.navigationIntent === 'user-open' && + selection.agent === undefined && + selection.providesInitialSurface !== true + ) +} diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index f2e2ac8f85a..f63b1189097 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -28,6 +28,7 @@ import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-wor import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay' import { activationProvidesInitialSurface, + activationSeedsUserDefaultSurface, type WorktreeActivationOptions, type WorktreeActivationSurfaceSelection } from './worktree-activation-surface-selection' @@ -47,7 +48,8 @@ export type ActivateAndRevealResult = { function ensureFolderWorkspaceInitialTerminal( folderWorkspace: FolderWorkspace, startup?: WorktreeStartupPayload, - providesInitialSurface?: boolean + providesInitialSurface?: boolean, + seedUserDefaultSurface?: boolean ): string | null { if (providesInitialSurface === true && startup === undefined) { return null @@ -61,7 +63,10 @@ function ensureFolderWorkspaceInitialTerminal( undefined, undefined, undefined, - { reseedEmptiedWorkspace: providesInitialSurface !== true } + { + reseedEmptiedWorkspace: providesInitialSurface !== true, + ...(seedUserDefaultSurface ? { seedUserDefaultSurface: true } : {}) + } ) return primaryTabId } @@ -128,6 +133,7 @@ export function activateAndRevealFolderWorkspace( const workspaceKey = folderWorkspaceKey(folderWorkspaceId) const providesInitialSurface = activationProvidesInitialSurface(opts) + const seedUserDefaultSurface = activationSeedsUserDefaultSurface(opts) state.markWorktreeVisited(workspaceKey) if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) @@ -144,15 +150,20 @@ export function activateAndRevealFolderWorkspace( resumeSleepingAgentSessionsForWorktree(workspaceKey) } if (shouldGateAgentActivation) { - gateAndReseedEmptyWorkspace( - workspaceKey, - opts?.providesInitialSurface === true, - opts?.executionHostId - ) + gateAndReseedEmptyWorkspace(workspaceKey, { + callerProvidesSurface: opts?.providesInitialSurface === true, + seedUserDefaultSurface, + ...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}) + }) } const primaryTabId = shouldGateAgentActivation ? null - : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface) + : ensureFolderWorkspaceInitialTerminal( + folderWorkspace, + opts?.startup, + providesInitialSurface, + seedUserDefaultSurface + ) if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( @@ -185,6 +196,7 @@ export function activateAndRevealWorktree( opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand ) const providesInitialSurface = activationProvidesInitialSurface(opts) + const seedUserDefaultSurface = activationSeedsUserDefaultSurface(opts) // Why: a plain reselect should still reveal the sidebar row but must not restamp focus recency or wake persistence. const isPlainAlreadyActiveTerminal = !hasActivationWork && @@ -243,11 +255,11 @@ export function activateAndRevealWorktree( resumeSleepingAgentSessionsForWorktree(worktreeId) } if (shouldGateAgentActivation) { - gateAndReseedEmptyWorkspace( - worktreeId, - opts?.providesInitialSurface === true, - opts?.executionHostId - ) + gateAndReseedEmptyWorkspace(worktreeId, { + callerProvidesSurface: opts?.providesInitialSurface === true, + seedUserDefaultSurface, + ...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}) + }) } // 4. Ensure a focusable surface exists for externally-created worktrees @@ -266,6 +278,7 @@ export function activateAndRevealWorktree( ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), ...(providesInitialSurface ? { callerProvidesSurface: true } : {}), + ...(seedUserDefaultSurface ? { seedUserDefaultSurface: true } : {}), reseedEmptiedWorkspace: !providesInitialSurface } ) diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index b0a6296b30e..feb01ef0b9b 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -29,6 +29,8 @@ import { type IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' import { applyDefaultTerminalTabs } from '@/lib/worktree-default-terminal-tabs' +import { openDefaultAgentChatInEmptyWorkspace } from '@/lib/empty-workspace-default-agent-chat' +import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' { return getSetupRunnerCommandPlatformForPath( @@ -37,15 +39,22 @@ function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'wi ) } +export type GatedEmptyWorkspaceReseedIntent = { + callerProvidesSurface: boolean + seedUserDefaultSurface: boolean + executionHostId?: ExecutionHostId +} + /** Re-seed after an empty gate unless its activation owns the surface or no longer owns the host. */ export function reseedGatedEmptyWorkspace( workspaceKey: string, - callerProvidesSurface: boolean, - executionHostId?: ExecutionHostId + intent: GatedEmptyWorkspaceReseedIntent ): void { + const { callerProvidesSurface, seedUserDefaultSurface, executionHostId } = intent const state = useAppStore.getState() if ( callerProvidesSurface === true || + isEmptyWorkspaceDefaultSurfacePending(workspaceKey) || state.activeWorktreeId !== workspaceKey || (executionHostId !== undefined && state.activeWorkspaceExecutionHostId !== executionHostId) ) { @@ -59,7 +68,8 @@ export function reseedGatedEmptyWorkspace( undefined, undefined, { - reseedEmptiedWorkspace: true + reseedEmptiedWorkspace: true, + ...(seedUserDefaultSurface ? { seedUserDefaultSurface: true } : {}) } ) } @@ -216,6 +226,16 @@ export function ensureWorktreeHasInitialTerminal( if (templatedTabId) { return templatedTabId } + if ( + opts?.seedUserDefaultSurface === true && + !hasExplicitLaunchWork && + opts.activateCreatedTabs !== false + ) { + const defaultChat = openDefaultAgentChatInEmptyWorkspace(worktreeId) + if (defaultChat) { + return defaultChat.primaryTabId + } + } // Why: tag this activation-created tab so its PTY spawn doesn't count as activity and reshuffle the Recent sort. // Why: stamp the seeded agent before hooks arrive so native chat and provider chrome can resolve it immediately. diff --git a/src/renderer/src/lib/worktree-jump-navigation.test.ts b/src/renderer/src/lib/worktree-jump-navigation.test.ts index 4c76316556c..af8072ac9c4 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.test.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.test.ts @@ -57,7 +57,9 @@ describe('worktree jump navigation', () => { expect(mocks.worktreePassesSidebarFilters).not.toHaveBeenCalled() expect(mocks.warning).not.toHaveBeenCalled() - expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/deleted', {}) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/deleted', { + navigationIntent: 'user-open' + }) }) it('switches the left sidebar to Spaces and warns when filters hide the target', () => { @@ -67,6 +69,7 @@ describe('worktree jump navigation', () => { expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces') expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/target', { + navigationIntent: 'user-open', revealInSidebar: false, clearSidebarFilters: false }) @@ -98,7 +101,9 @@ describe('worktree jump navigation', () => { expect(jumpToWorktreeFromSidebar('wt-collapsed')).toBe(true) - expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-collapsed', {}) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-collapsed', { + navigationIntent: 'user-open' + }) expect(mocks.warning).not.toHaveBeenCalled() }) @@ -121,6 +126,7 @@ describe('worktree jump navigation', () => { expect(jumpToWorktreeFromSidebar('repo::/target', { executionHostId: 'ssh:beta' })).toBe(true) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/target', { + navigationIntent: 'user-open', revealInSidebar: false, clearSidebarFilters: false, executionHostId: 'ssh:beta' @@ -134,6 +140,7 @@ describe('worktree jump navigation', () => { expect(jumpToWorktreeFromSidebar('folder:folder-1', { executionHostId: 'local' })).toBe(true) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('folder:folder-1', { + navigationIntent: 'user-open', executionHostId: 'local' }) // Folder workspaces never get the filter-hidden treatment. diff --git a/src/renderer/src/lib/worktree-jump-navigation.ts b/src/renderer/src/lib/worktree-jump-navigation.ts index bfc0d6dd936..38721b3337b 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.ts @@ -60,6 +60,7 @@ export function jumpToWorktreeFromSidebar( // Why the workspace dispatcher: it owns the folder-vs-worktree split and the folder path-status gate. const activated = activateAndRevealWorkspace(worktreeId, { + navigationIntent: 'user-open', ...(hiddenBeforeActivation ? { revealInSidebar: false, clearSidebarFilters: false } : {}), ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}) }) From f4829f03639f1c1b681bd585d108caac1f579248 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:34:29 -0700 Subject: [PATCH 350/387] fix(renderer): stop the modal toast rule from freezing large diffs (#23721) * fix(renderer): stop the modal toast rule from freezing large diffs The rule that lifts toasts above a dialog or sheet backdrop matched body:has( anywhere). Chromium re-evaluates a descendant :has() on body for DOM changes anywhere in the page, so with large Monaco diffs open every editor mutation re-scanned the document and the renderer stopped responding. Dialog and sheet overlays portal straight into body, so matching them as direct children keeps the same behaviour while only changes to body's own children can affect the rule. * fix(renderer): raise modal toasts through a body variable, not a descendant :has() The child-combinator form still ran a whole-page walk after unrelated DOM changes (about 3x cheaper than the original, but still proportional to page size). Setting a custom property from a :has() on body alone takes the rule off that path; only opening or closing a backdrop restyles. * test(renderer): fail the toaster layering guard when the child combinator is dropped The guard only rejected a :has() inside the toaster's selector. Dropping the `>` from the body :has() (or removing the lift rule) still passed, and the unscoped form re-runs on every data-slot element change anywhere in the page, the same freeze the PR fixes. Assert the exact child-only rule shape. --- src/renderer/src/assets/main.css | 10 ++++++---- .../native-chat/native-chat-stop-layering.test.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 7f555bf724d..2d999cc48db 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -409,12 +409,14 @@ [data-sonner-toaster] { font-family: var(--font-sans); - z-index: 40 !important; + z-index: var(--toaster-z-index, 40) !important; } -/* Keep errors above standard modal backdrops at the existing popover tier. */ -body:has([data-slot='dialog-overlay'], [data-slot='sheet-overlay']) [data-sonner-toaster] { - z-index: 60 !important; +/* Keep errors above standard modal backdrops at the existing popover tier. + Why: overlays portal straight into body, and `>` plus a :has() on body alone (not in the toaster's + selector) keeps unrelated DOM changes from rescanning the page; the old form froze large diffs. */ +body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { + --toaster-z-index: 60; } [data-sonner-toaster] [data-sonner-toast][data-styled='true'] { diff --git a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts index 26b4009caaa..4a1f7a7863e 100644 --- a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts @@ -16,7 +16,14 @@ describe('native chat layering', () => { expect(source(path)).toContain('\s*\[data-slot='dialog-overlay'\],\s*>\s*\[data-slot='sheet-overlay'\]\)\s*\{\s*--toaster-z-index:\s*60;/ + ) + expect(css).not.toMatch(/:has\([^{]*\)\s+\[data-sonner-toaster\]/) }) it('publishes working state from both structured and bridge chat roots', () => { From b8a7dafafc95945bf389dbba1f01fdc9adb92ee9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:36:07 -0700 Subject: [PATCH 351/387] fix(codex): guard the daemon socket in the home a resumed Codex pane launches in (#23724) --- .../codex-session-resume-daemon-guard.test.ts | 145 ++++++++++++++++++ .../startup/codex-session-resume-launch.ts | 5 + 2 files changed, 150 insertions(+) create mode 100644 src/main/startup/codex-session-resume-daemon-guard.test.ts diff --git a/src/main/startup/codex-session-resume-daemon-guard.test.ts b/src/main/startup/codex-session-resume-daemon-guard.test.ts new file mode 100644 index 00000000000..aecaadcf8b6 --- /dev/null +++ b/src/main/startup/codex-session-resume-daemon-guard.test.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' +import { + CODEX_DAEMON_OVERRIDE_MARKER, + codexDaemonSocketPathExceedsLimit +} from '../codex/codex-daemon-socket-path-guard' + +const mocks = vi.hoisted(() => ({ + hooksEnabled: false, + systemHomePath: '', + sharedHomePath: '', + installForLaunchPrep: vi.fn(), + refreshRuntimeUserHooksForLaunchPrep: vi.fn(), + ensureRealHomeCodexHookState: vi.fn(async () => {}), + prepareCodexSessionResume: vi.fn(), + prepareLegacySharedCodexSessionResume: vi.fn() +})) + +vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) +vi.mock('../agent-trust-presets', () => ({ markCodexProjectTrusted: async () => {} })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: mocks.installForLaunchPrep, + refreshRuntimeUserHooksForLaunchPrep: mocks.refreshRuntimeUserHooksForLaunchPrep + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabled: () => mocks.hooksEnabled +})) +vi.mock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + getSystemCodexHomePath: () => mocks.systemHomePath, + getOrcaManagedCodexHomePath: () => mocks.sharedHomePath +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: mocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: mocks.prepareLegacySharedCodexSessionResume +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: () => null + }, + store: { getSettings: () => ({}) } + } +})) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +// Why: long enough that the daemon socket overflows sun_path on every host OS. +const LONG_SEGMENT = 'a'.repeat(60) + +describe('Codex session resume daemon socket guard', () => { + let root: string + let accountHome: string + let warn: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + root = mkdtempSync(join(tmpdir(), 'orca-resume-guard-')) + accountHome = join(root, 'codex-accounts', LONG_SEGMENT, 'home') + mocks.systemHomePath = join(root, 'system', LONG_SEGMENT, '.codex') + mocks.sharedHomePath = join(root, 'codex-runtime-home', 'home') + for (const home of [accountHome, mocks.systemHomePath, mocks.sharedHomePath]) { + mkdirSync(home, { recursive: true }) + } + mocks.hooksEnabled = false + mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: false }) + mocks.prepareCodexSessionResume.mockImplementation( + async (args: { + resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise + }) => ({ + outcome: 'resume' as const, + codexHomePath: await args.resolveVerifiedResumeHome({ + homePath: accountHome, + transcriptPath: join(accountHome, 'sessions', 'abc.jsonl') + }) + }) + ) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + rmSync(root, { recursive: true, force: true }) + }) + + function resume(): ReturnType { + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' }, + workspacePath: join(root, 'workspace') + }) + } + + it('guards the resumed account home when hook repair fails before mirroring config', async () => { + expect(codexDaemonSocketPathExceedsLimit(accountHome)).toBe(true) + writeFileSync(join(accountHome, 'config.toml'), 'model = "gpt-5"\n', 'utf-8') + mocks.hooksEnabled = true + mocks.installForLaunchPrep.mockRejectedValue(new Error('Could not parse Codex hooks.json')) + + const preparation = await resume() + + expect(preparation).toMatchObject({ outcome: 'resume', codexHomePath: accountHome }) + const config = readFileSync(join(accountHome, 'config.toml'), 'utf-8') + expect(config).toContain('model = "gpt-5"') + expect(config).toContain(`daemon_auto_start = false ${CODEX_DAEMON_OVERRIDE_MARKER}`) + }) + + it('guards the resumed account home when hooks are off and the refresh returns early', async () => { + mocks.refreshRuntimeUserHooksForLaunchPrep.mockResolvedValue({ + agent: 'codex', + state: 'error', + detail: 'Could not read system Codex hooks.json' + }) + + await resume() + + expect(mocks.refreshRuntimeUserHooksForLaunchPrep).toHaveBeenCalledWith(accountHome) + expect(readFileSync(join(accountHome, 'config.toml'), 'utf-8')).toContain( + `daemon_auto_start = false ${CODEX_DAEMON_OVERRIDE_MARKER}` + ) + }) + + it('never writes the guard into the real Codex home a migrated resume runs in', async () => { + expect(codexDaemonSocketPathExceedsLimit(mocks.systemHomePath)).toBe(true) + mocks.prepareLegacySharedCodexSessionResume.mockResolvedValue({ useRealCodexHome: true }) + + const preparation = await resume() + + expect(preparation).toMatchObject({ codexHomePath: mocks.systemHomePath }) + expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) + expect(existsSync(join(mocks.systemHomePath, 'config.toml'))).toBe(false) + }) +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index 0c5e4598de1..1ce7f8c3a15 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -7,6 +7,7 @@ import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-ses import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { markCodexProjectTrusted } from '../agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline' @@ -111,6 +112,10 @@ export async function prepareCodexSessionResumeForLaunch(args: { // Why: hook repair is best-effort; session provenance must still win over the currently selected home. console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) } + if (!isSystemHome) { + // Why: this pins the resumed pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard. + ensureCodexDaemonSocketGuard(resumeHome) + } return resumeHome } }) From b7e3bbf3d1302254d00ff0fcd9b072a34bddc617 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:41:51 +0000 Subject: [PATCH 352/387] fix(browser): restore hover after leaving a mobile viewport preset (#22846) Turning touch emulation off sent maxTouchPoints: 0, which Chromium rejects (it only accepts 1-16, even when disabling). Touch emulation stayed on, so pages kept reporting no hover and a coarse pointer, and the desktop user agent was never restored. Omit maxTouchPoints when disabling so Chromium restores the original value. --- .../browser-manager-viewport-override.test.ts | 3 +- .../browser-manager-viewport-test-fixtures.ts | 18 ++- ...owser-manager-viewport-touch-reset.test.ts | 109 ++++++++++++++++++ src/main/browser/browser-manager-viewport.ts | 12 +- 4 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 src/main/browser/browser-manager-viewport-touch-reset.test.ts diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index a8a4f3bacd4..03dd699aa0c 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -840,8 +840,7 @@ describe('browserManager', () => { expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride', {}) expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setTouchEmulationEnabled', { - enabled: false, - maxTouchPoints: 0 + enabled: false }) // Nothing overrode the identity, so there is nothing to clear. expect(lastUserAgentOverride(debuggerSendCommand)).toBeUndefined() diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 4d6437cf5d9..959e958491c 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -14,6 +14,22 @@ export async function flushViewportOps(): Promise { } } +// Why: mirrors Chromium, which rejects an out-of-range maxTouchPoints even when disabling touch. +async function rejectInvalidTouchPoints( + method: string, + params?: { maxTouchPoints?: number } +): Promise { + const points = params?.maxTouchPoints + if ( + method === 'Emulation.setTouchEmulationEnabled' && + points !== undefined && + (points < 1 || points > 16) + ) { + throw new Error('Touch points must be between 1 and 16') + } + return undefined +} + export type ViewportGuestHandle = { guest: Record debuggerSendCommand: ReturnType @@ -35,7 +51,7 @@ export function createViewportGuestFactory( mocks: BrowserManagerMocks & { processUserAgent?: string } ): (id: number, url?: string) => ViewportGuestHandle { return function makeGuest(id: number, url = 'https://example.com/'): ViewportGuestHandle { - const debuggerSendCommand = vi.fn().mockResolvedValue(undefined) + const debuggerSendCommand = vi.fn(rejectInvalidTouchPoints) const debuggerIsAttached = vi.fn(() => true) const debuggerAttach = vi.fn() let currentUa = mocks.processUserAgent ?? GUEST_ELECTRON_UA diff --git a/src/main/browser/browser-manager-viewport-touch-reset.test.ts b/src/main/browser/browser-manager-viewport-touch-reset.test.ts new file mode 100644 index 00000000000..ebebd7dad7d --- /dev/null +++ b/src/main/browser/browser-manager-viewport-touch-reset.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: mocks.appGetPathMock }, + BrowserWindow: { fromWebContents: mocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: mocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: mocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: mocks.screenGetCursorScreenPointMock }, + webContents: { fromId: mocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: mocks.openPopupWithOriginBarMock +})) +vi.mock('./browser-process-user-agent', () => ({ + getBrowserProcessUserAgentIdentity: () => ({ mode: 'clean', userAgent: GUEST_CLEAN_UA }) +})) + +import { browserManager } from './browser-manager' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps, + GUEST_CLEAN_UA, + type ViewportGuestHandle +} from './browser-manager-viewport-test-fixtures' + +const makeGuest = createViewportGuestFactory(mocks) +const MOBILE = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } as const +const LAPTOP = { width: 1024, height: 768, deviceScaleFactor: 1, mobile: false } as const + +function registerGuest(id: number, browserPageId: string): ViewportGuestHandle { + const handle = makeGuest(id) + mocks.webContentsFromIdMock.mockReturnValue(handle.guest) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the viewport fixture implements every WebContents member these paths touch. + browserManager.attachGuestPolicies(handle.guest as never) + browserManager.registerGuest({ browserPageId, webContentsId: id, rendererWebContentsId }) + return handle +} + +function userAgentWrites( + debuggerSendCommand: ViewportGuestHandle['debuggerSendCommand'] +): string[] { + return debuggerSendCommand.mock.calls.flatMap(([method, params]) => + method === 'Emulation.setUserAgentOverride' && typeof params?.userAgent === 'string' + ? [params.userAgent] + : [] + ) +} + +function startNavigation(url: string): void { + const didStartNavigation = mocks.guestOnMock.mock.calls.findLast( + ([event]) => event === 'did-start-navigation' + )?.[1] + expect(didStartNavigation).toBeTypeOf('function') + didStartNavigation(null, url, false, true) +} + +// Regression for #22749: leaving a mobile preset must actually turn touch emulation off, or the +// guest keeps (hover: none) / coarse pointer and loses every hover style. +describe('browserManager viewport touch reset', () => { + beforeEach(() => { + resetBrowserManagerMocks(mocks) + resetBrowserManagerState() + }) + + it.each([ + ['a desktop preset', LAPTOP], + ['no preset', null] + ])('disables touch emulation when switching from mobile to %s', async (_label, next) => { + const { debuggerSendCommand } = registerGuest(52001, 'tab-touch-reset') + await expect(browserManager.setViewportOverride('tab-touch-reset', MOBILE)).resolves.toBe(true) + debuggerSendCommand.mockClear() + + await expect(browserManager.setViewportOverride('tab-touch-reset', next)).resolves.toBe(true) + + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setTouchEmulationEnabled', { + enabled: false + }) + const writes = userAgentWrites(debuggerSendCommand) + expect(writes.length).toBeGreaterThan(0) + expect(writes.at(-1)).not.toContain('iPhone') + + // A later navigation must not re-install the mobile UA behind the switch. + debuggerSendCommand.mockClear() + startNavigation('https://example.com/next') + await flushViewportOps() + expect(userAgentWrites(debuggerSendCommand).some((ua) => ua.includes('iPhone'))).toBe(false) + }) +}) diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts index b85e6d0424f..addd6dc82ed 100644 --- a/src/main/browser/browser-manager-viewport.ts +++ b/src/main/browser/browser-manager-viewport.ts @@ -7,6 +7,10 @@ import { import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' +// Why no maxTouchPoints: Chromium rejects values outside 1..16 even when disabling, which left +// touch emulation (and no-hover media features) on after leaving a mobile preset (#22749). +const TOUCH_EMULATION_DISABLED = { enabled: false } as const + export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. async openDevTools(browserTabId: string): Promise { @@ -158,10 +162,10 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec : dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) ) const touchApplied = await this.runViewportEmulationStep(browserTabId, 'touch emulation', () => - dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: override?.mobile ?? false, - maxTouchPoints: override?.mobile ? 5 : 0 - }) + dbg.sendCommand( + 'Emulation.setTouchEmulationEnabled', + override?.mobile ? { enabled: true, maxTouchPoints: 5 } : TOUCH_EMULATION_DISABLED + ) ) if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { return false From 357a2fed08339dccd87ec01fd4559f503adde9d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:55:45 -0700 Subject: [PATCH 353/387] fix(native-chat): group chat rows by the turn that produced them (#23671) * fix(native-chat): keep a turn's bar on the prompt that opened it A message sent while a structured turn runs appears in the transcript at once, so "the newest user message" is not the running turn's owner. The live "Working for" bar moved to the mid-turn message and counted from the earlier prompt's start, and a send queued behind the running turn counted its wait twice: once in the previous turn and again from its own send. Derive both from the host's turn records in one ordered pass: - The running turn's bar belongs to the user message its lifecycle row names (resolved exactly as settled timing resolves it). A message sent mid-turn gets no bar until its own turn opens; a send folded into the running turn never gets one. Surfaces fall back to the latest user message only when the host names no opener. - A turn counts from its send, but never before the previous turn in the journal ended (its recorded end, else its row's last host revision), capped at the turn's own start. The same origin feeds the live counter and the settled duration. Desktop and mobile share the derivation; no wire, host, or storage change. * feat(native-chat): derive each transcript row's owning turn from the journal Rows between a turn record and the next belong to that record's turn, so a message the provider folds into a running turn no longer captures the rows produced after it. A turn whose opener the host cannot name in the loaded window anchors to its own record instead of a bystander prompt, and shared nativeChatRowTurnKeys keeps positional preceding-user grouping for anything the host does not attribute (older hosts stay pixel-identical). * fix(native-chat): fold and time transcript rows by their owning turn on desktop A settled turn's bar now folds every row the turn produced, including rows after a mid-turn send; the steered bubble stays visible and carries no bar. A provider-opened turn renders its bar above its first row instead of borrowing the newest prompt, and row liveness follows the owning turn rather than the newest user message, so a running turn's rows stay live while a send waits. * fix(mobile): group phone transcript rows and bars by their owning turn Same shared derivation as desktop: the opener's bar owns every row of its turn across a mid-turn send, a steered bubble never grows a bar, a provider-opened turn's bar sits above its first row, and a running turn's tool rows stay live while a newer message waits behind it. * fix(mobile): declare the turn ownership map on the chat controller contract * fix(native-chat): one diff rollup per turn, and no wake-turn clock on a later prompt A turn's rows are no longer contiguous once rows are grouped by owning turn: a prompt sent before the running turn's last row lands among its rows. The diff rollup was drawn at every run boundary, so such a turn showed its rollup twice and the later prompt's rollup appeared under its bubble. It now renders once, under the turn's last row. On the phone, a turn keyed to its own record is not a user message, so when it ended its clock was treated as a replaced optimistic echo and handed to the newest prompt - a message sent during a wake turn got a bar with the wake turn's duration. Host-attributed turn keys now count as live turn keys. * fix(native-chat): keep a Codex turn's bar on its send until the echo lands Codex reports turn/started before it runs hooks and prewarm and before it echoes the send, so for that gap the turn names a provider key no alias resolves yet. Anchoring it to its own record left the running turn with no bar at all; treat the send still in flight ahead of the record as its opener. * fix(codex): restore each turn's record ahead of that turn's items Rows are grouped by the nearest turn record before them in journal order, which holds on the live path because a turn's record is written when the turn opens. Full-history restore (the fallback for Codex app-servers that reject excludeTurns) wrote each turn's items first and its record after, so every restored turn's rows were credited to the previous turn and turn 1's answer folded away. The restore now appends the record before the turn's items. The record itself is unchanged: same identity, state, outcome, opener key, endpoints and duration, one append each. The restore-order test now expects the record first, because that order is what keeps grouping correct; its old order was incidental, not a contract any reader relied on. Readers that key turns by id or opener key, or that scan for the newest record (all restored records are settled), read the same result in either order. Journals already imported in the old order stay as written; no migration. --- .../src/session/MobileNativeChatMessage.tsx | 24 +-- .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 6 +- .../mobile-native-chat-controller-contract.ts | 2 + .../use-mobile-native-chat-controller.ts | 1 + ...obile-native-chat-turn-disclosure.test.tsx | 147 +++++++++++++++++- .../use-mobile-native-chat-turn-disclosure.ts | 74 +++++---- .../use-mobile-native-chat-turn-status.ts | 9 +- ...use-mobile-structured-agent-turn-timing.ts | 13 +- ...ured-journal-restore-turn-grouping.test.ts | 98 ++++++++++++ ...-structured-journal-translation-restore.ts | 20 +-- ...journal-translation-turn-lifecycle.test.ts | 3 +- .../native-chat/NativeChatMessageList.tsx | 25 ++- .../NativeChatMessageList.turn-fold.test.tsx | 38 +++++ .../NativeChatStructuredSession.tsx | 1 + .../native-chat/NativeChatTranscriptRow.tsx | 23 +-- .../native-chat-message-rail-items.test.ts | 1 - .../native-chat-rail-outline-parity.test.ts | 1 - .../native-chat-transcript-slots.test.ts | 108 ++++++++++++- .../native-chat-transcript-slots.ts | 43 ++++- .../use-native-chat-message-rail.test.ts | 1 - .../use-structured-agent-session.ts | 1 + .../use-structured-agent-turn-timing.ts | 13 +- src/shared/native-chat-turn-grouping.test.ts | 76 +++++++++ src/shared/native-chat-turn-grouping.ts | 49 ++++++ ...structured-agent-session-turn-bars.test.ts | 135 +++++++++++++++- ...ructured-agent-session-turn-timing.test.ts | 8 +- .../structured-agent-session-turn-timing.ts | 96 ++++++++++-- 28 files changed, 910 insertions(+), 107 deletions(-) create mode 100644 src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts create mode 100644 src/shared/native-chat-turn-grouping.test.ts create mode 100644 src/shared/native-chat-turn-grouping.ts diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 08feb9bc7ce..a2f14196e36 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -73,6 +73,7 @@ function MobileNativeChatMessageImpl({ fontScale = 1, onOpenFile, turnStatus, + turnStatusAbove = false, turnExpanded, turnKey, onToggleTurn, @@ -85,8 +86,10 @@ function MobileNativeChatMessageImpl({ /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number onOpenFile?: (relativePath: string) => void - /** This settled turn's status row, rendered under its user message. */ + /** This turn's status row, rendered under its opening user message. */ turnStatus?: NativeChatTurnStatus | null + /** Render the status above the row: its turn has no user bubble of its own. */ + turnStatusAbove?: boolean /** Whether the turn caret has disclosed this turn's activity. */ turnExpanded?: boolean /** Set only when this row's turn has settled and can disclose its activity. */ @@ -127,8 +130,18 @@ function MobileNativeChatMessageImpl({ ? null : (subagentLabel ?? NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed) + const statusRow = turnStatus ? ( + onToggleTurn(turnKey) : undefined} + /> + ) : null return ( <> + {/* A turn with no user bubble carries its bar above its first row. */} + {turnStatusAbove ? statusRow : null} - {turnStatus ? ( - onToggleTurn(turnKey) : undefined} - /> - ) : null} + {turnStatusAbove ? null : statusRow} ) } diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 31ce1e22550..00598e6a967 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -77,6 +77,7 @@ export function MobileNativeChatOverlay({ workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy} + turnKeysByItemId={controller.nativeChatTurnKeysByItemId} streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 1184b126c55..d0a6c39cba6 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -66,8 +66,10 @@ type Props = { /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null - /** Structured lane: the user message the host says opened the running turn. */ + /** Structured lane: the key the host says anchors the running turn's bar. */ activeTurnOpenedBy?: string | null + /** Structured lane: host-attributed turn ownership per journal item id. */ + turnKeysByItemId?: ReadonlyMap | null /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ /** Interrupt a provider turn. */ onStop?: () => void @@ -154,6 +156,7 @@ export function MobileNativeChatView({ workingStartedAt, settledTurns, activeTurnOpenedBy = null, + turnKeysByItemId = null, onStop, streaming, hasMore, @@ -274,6 +277,7 @@ export function MobileNativeChatView({ workingStartedAt, settledTurns, activeTurnOpenedBy, + turnKeysByItemId, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 686dac407e7..30bf33d7d51 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -39,6 +39,8 @@ export type MobileNativeChatController = { nativeChatSettledTurns: NativeChatSettledTurns | null /** Structured lane: the user message the host says opened the running turn. */ nativeChatActiveTurnOpenedBy: string | null + /** Structured lane: the turn that owns each journal item's row. */ + nativeChatTurnKeysByItemId: ReadonlyMap | null nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 7a1fa7c468e..c14c3e1251c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -291,6 +291,7 @@ export function useMobileNativeChatController(args: { nativeChatActiveTurnOpenedBy: activeChatStructured ? structuredNativeChat.activeTurnOpenedBy : null, + nativeChatTurnKeysByItemId: activeChatStructured ? structuredNativeChat.turnKeysByItemId : null, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index 1757a8ce65b..ed0b22dfc1f 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -22,6 +22,7 @@ function Harness({ settledTurns, workingStartedAt, activeTurnOpenedBy, + turnKeysByItemId, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] @@ -30,6 +31,7 @@ function Harness({ settledTurns?: NativeChatSettledTurns workingStartedAt?: number | null activeTurnOpenedBy?: string | null + turnKeysByItemId?: ReadonlyMap | null scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -39,6 +41,7 @@ function Harness({ settledTurns, workingStartedAt, activeTurnOpenedBy, + turnKeysByItemId, scopeKey }) return createElement('result', { disclosure }) @@ -240,9 +243,9 @@ describe('useMobileNativeChatTurnDisclosure', () => { let [rowA, rowTool, rowB] = rows() expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) expect(rowB.turnStatus).toBeNull() - // Row liveness still follows the transcript's grouping under the newest prompt. - expect(rowTool.activeTurnIsWorking).toBe(false) - expect(rowB.activeTurnIsWorking).toBe(true) + // Liveness follows the owning turn: A's tool row stays live while B waits. + expect(rowTool.activeTurnIsWorking).toBe(true) + expect(rowB.activeTurnIsWorking).toBe(false) // B's own turn opens: A takes the host's settled duration, B counts from A's end. act(() => { @@ -264,6 +267,144 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) + it("keeps a running turn's rows live across a mid-turn send the host folded in", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const tool = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + }) + // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. + const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] + const owned = new Map([ + ['A', 'A'], + ['t1', 'A'], + ['B', 'A'], + ['t2', 'A'] + ]) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + activeTurnOpenedBy: 'A', + turnKeysByItemId: owned + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + // The steered bubble shares A's turn but never carries a bar of its own. + expect(rowB.turnStatus).toBeNull() + expect(rowT1.activeTurnIsWorking).toBe(true) + expect(rowT2.activeTurnIsWorking).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("anchors a provider-opened turn's bar above its first row", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const woke: NativeChatMessage = { + id: 'woke', + role: 'assistant', + blocks: [{ type: 'text', text: 'Woke up.' }], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('u1'), woke] + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), + turnKeysByItemId: new Map([ + ['u1', 'u1'], + ['woke', 'wake'] + ]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowU1, rowWoke] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowU1.turnStatus).toBeNull() + expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) + expect(rowWoke.turnStatusAbove).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("never hands a provider-opened turn's clock to a message sent during it", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const row = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + // A wake turn runs; B is sent during it and folded in, so B opened nothing. + const messages = [row('w1'), userMessage('B'), row('w2')] + const owned = new Map([ + ['w1', 'wake'], + ['B', 'wake'], + ['w2', 'wake'] + ]) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + activeTurnOpenedBy: 'wake', + turnKeysByItemId: owned, + settledTurns: new Map([['wake', null]]) + }) + ) + }) + // The wake turn settles: the host no longer names a running turn. + vi.setSystemTime(20_000) + act(() => { + renderer!.update( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + workingStartedAt: null, + activeTurnOpenedBy: null, + turnKeysByItemId: owned, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowW1, rowB] = messages.map((message, index) => disclosure.resolveRow(index, message)) + expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) + expect(rowB.turnStatus).toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('keeps at most the latest 128 turns expanded', () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 6cbb31c723a..cc61ba63d89 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,6 +1,11 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { NATIVE_CHAT_UNANCHORED_TURN_KEY } from '../../../src/shared/native-chat-turn-status' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import { + nativeChatRowTurnKeys, + nativeChatSelfAnchoredTurnRows +} from '../../../src/shared/native-chat-turn-grouping' import { useMobileNativeChatTurnStatus, type NativeChatTurnStatus @@ -8,10 +13,13 @@ import { const EMPTY_TURN_IDS: ReadonlySet = new Set() const EMPTY_TURN_KEYS: readonly undefined[] = [] +const EMPTY_TURN_ANCHORS: ReadonlyMap = new Map() const MAX_EXPANDED_TURNS = 128 export type MobileNativeChatTurnRow = { turnStatus: NativeChatTurnStatus | null + /** The bar renders above the row: its turn has no user bubble of its own. */ + turnStatusAbove?: boolean turnExpanded: boolean /** Set only on a settled turn — the one row that has activity to disclose. */ turnKey?: string @@ -28,6 +36,7 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt, settledTurns, activeTurnOpenedBy = null, + turnKeysByItemId = null, thinking = false, activityText = null, scopeKey @@ -38,8 +47,11 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The user message the host says opened the running turn; absent, the latest one. */ + /** The key the host says anchors the running turn's bar; absent, the latest user message. */ activeTurnOpenedBy?: string | null + /** Host-attributed turn ownership per journal item id; unnamed rows keep + * positional preceding-user grouping. */ + turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ @@ -60,6 +72,7 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt, settledTurns, activeTurnOpenedBy, + turnKeysByItemId, thinking, scopeKey }) @@ -87,52 +100,61 @@ export function useMobileNativeChatTurnDisclosure({ }, [scopeKey] ) - // Resolve each row's turn boundary once — a findLast per row is quadratic on a + // Resolve each row's owning turn once — a findLast per row is quadratic on a // long transcript. - const turnKeys = useMemo(() => { - if (!enabled) { - return EMPTY_TURN_KEYS - } - let turnKey: string | undefined - return messages.map((message) => { - if (message.role === 'user') { - turnKey = message.id - } - return turnKey - }) - }, [enabled, messages]) + const turnKeys = useMemo( + () => (enabled ? nativeChatRowTurnKeys(messages, turnKeysByItemId) : EMPTY_TURN_KEYS), + [enabled, messages, turnKeysByItemId] + ) + // A turn with no user bubble (provider-opened) anchors its bar at its first row. + const selfAnchors = useMemo( + () => (enabled ? nativeChatSelfAnchoredTurnRows(messages, turnKeys) : EMPTY_TURN_ANCHORS), + [enabled, messages, turnKeys] + ) const { active, activeTurnKey, completedByTurn } = turnStatuses - // Liveness follows the transcript's own grouping (the latest prompt's rows), not the bar's owner. - const latestTurnKey = turnKeys.at(-1) const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const turnKey = turnKeys[index] - // The live turn's bar carries its running clock; it settles in place. - const turnStatus = - !enabled || message.role !== 'user' || !turnKey - ? null - : turnKey === activeTurnKey - ? active - : (completedByTurn[turnKey] ?? null) + const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index + // The live turn's bar carries its running clock; it settles in place. Only + // the bubble that opened a turn carries its bar: a message the provider + // folded into a running turn shares the turn's key but not its bar. + const turnStatus = !enabled + ? null + : message.role === 'user' && message.id === activeTurnKey + ? active + : message.role === 'user' && turnKey === message.id + ? (completedByTurn[turnKey] ?? null) + : anchorsTurnHere + ? turnKey === activeTurnKey + ? (active ?? completedByTurn[turnKey] ?? null) + : (completedByTurn[turnKey] ?? null) + : null return { turnStatus, + ...(anchorsTurnHere && turnStatus !== null ? { turnStatusAbove: true } : {}), turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, // Why: the key travels and the row calls one stable handler with it. A // closure per row would be a new identity every render of a streaming // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // With no user boundary at all, the session's working state stays authoritative. - activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey + // Liveness is the owning turn's, not the newest prompt's: a running turn's + // rows stay live while a newer message waits behind it. + activeTurnIsWorking: + enabled && + isWorking && + (turnKey === activeTurnKey || + (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) } }, [ turnKeys, + selfAnchors, enabled, activeTurnKey, - latestTurnKey, active, completedByTurn, expandedTurnIds, diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 3dac27e1b25..62f6ada8d5e 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -28,6 +28,7 @@ export function useMobileNativeChatTurnStatus({ workingStartedAt, settledTurns, activeTurnOpenedBy = null, + turnKeysByItemId = null, thinking = false, scopeKey }: { @@ -39,6 +40,9 @@ export function useMobileNativeChatTurnStatus({ settledTurns?: NativeChatSettledTurns | null /** The user message the host says opened the running turn; absent, the latest one. */ activeTurnOpenedBy?: string | null + /** Host-attributed turn ownership; a turn keyed to its own record is still in + * the transcript, so its clock is not an echo to hand to the next prompt. */ + turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ @@ -72,6 +76,9 @@ export function useMobileNativeChatTurnStatus({ const validTurnKeys = new Set( messages.filter((message) => message.role === 'user').map((message) => message.id) ) + for (const turnKey of turnKeysByItemId?.values() ?? []) { + validTurnKeys.add(turnKey) + } const previousActiveTurnKey = previousActiveTurn.current?.scopeKey === scopeKey ? previousActiveTurn.current.turnKey @@ -92,7 +99,7 @@ export function useMobileNativeChatTurnStatus({ ? current : { scopeKey, timingByTurn: nextTiming } }) - }, [activeTurnKey, enabled, isWorking, messages, scopeKey, workingStartedAt]) + }, [activeTurnKey, enabled, isWorking, messages, scopeKey, turnKeysByItemId, workingStartedAt]) // Why: the selection rebuilds its status objects on every call, and a streaming // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 3559f6925ed..9181856cc0d 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -12,7 +12,8 @@ import { } from '../../../src/shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the user message that owns the running turn's bar, and a + * off the journal, the transcript key that owns the running turn's bar, each + * row's owning turn, and a * skew-free start for the live counter whose host-to-local conversion is latched * once per turn. */ export function useMobileStructuredAgentTurnTiming( @@ -30,8 +31,9 @@ export function useMobileStructuredAgentTurnTiming( settledTurns: NativeChatSettledTurns workingStartedAt: number | null activeTurnOpenedBy: string | null + turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo( + const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) @@ -48,5 +50,10 @@ export function useMobileStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy } + return { + settledTurns, + workingStartedAt: step.workingStartedAt, + activeTurnOpenedBy, + turnKeysByItemId + } } diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts new file mode 100644 index 00000000000..8ce2591f57c --- /dev/null +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold' +import { nativeChatRowTurnKeys } from '../../shared/native-chat-turn-grouping' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection' +import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' + +const THREAD_ID = 'thread-abc' + +function historicalTurn(index: number): Record { + const id = `turn-${index}` + return { + id, + status: 'completed', + startedAt: 1_700_000_000 + index * 100, + completedAt: 1_700_000_050 + index * 100, + items: [ + { + type: 'userMessage', + id: `user-${index}`, + content: [{ type: 'text', text: `ask ${index}` }] + }, + { type: 'agentMessage', id: `interim-${index}`, text: `looking ${index}` }, + { type: 'agentMessage', id: `answer-${index}`, text: `answer ${index}` } + ] + } +} + +/** What the journal hands a reader after a restore: rows in append order. */ +function restoredJournal(turnCount: number): AgentJournalRenderItem[] { + const items: AgentJournalRenderItem[] = [] + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => { + const sequence = items.length + 1 + items.push({ + itemId: agentJournalItemKey(identity), + revision: 1, + body, + sequence, + observedAt: sequence + }) + }, + appendTombstone: () => {}, + publish: () => {} + }, + sessionId: 'session-1', + primaryThreadId: () => THREAD_ID + }) + const turns = Array.from({ length: turnCount }, (_, index) => historicalTurn(index + 1)) + expect(translator.restoreThread(THREAD_ID, { turns })).toEqual({ accepted: true }) + return items +} + +describe('grouping a Codex thread restored from full history', () => { + it('keeps each turn with its own rows and folds each to its own answer', () => { + const items = restoredJournal(3) + const bars = selectStructuredAgentTurnBars(items, [], null) + const messages = items + .map(projectStructuredItemToNativeChat) + .filter((message): message is NativeChatMessage => message !== null) + const turnKeys = nativeChatRowTurnKeys(messages, bars.turnKeysByItemId) + const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` + + expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ + ['user', opener(1)], + ['assistant', opener(1)], + ['assistant', opener(1)], + ['user', opener(2)], + ['assistant', opener(2)], + ['assistant', opener(2)], + ['user', opener(3)], + ['assistant', opener(3)], + ['assistant', opener(3)] + ]) + expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) + + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => ({ + turnKey: turnKeys[index], + role: message.role, + rendersProse: true, + outlivesTurn: false + })), + settledTurnKeys: new Set(bars.settledTurns.keys()), + expandedTurnKeys: new Set() + }) + const visible = messages.filter((_, index) => !foldedRows.has(index)) + expect(visible.map((message) => message.blocks)).toEqual( + ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ + { type: 'text', text } + ]) + ) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 7005c10a43b..5dc68d6f764 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -59,6 +59,17 @@ export function restoreCodexJournalThread(input: { if (!turnId) { continue } + // Ahead of the turn's items, as the live path writes it: readers credit every + // row to the nearest turn record before it. + const lifecycle = input.restoreTurnLifecycle + ? historicalTurnLifecycle(input.threadId, turn) + : null + if (lifecycle) { + const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } + if (!admission.accepted) { + return admission + } + } input.currentTurnIds.set(input.threadId, new Set([turnId])) for (const item of Array.isArray(turn.items) ? turn.items : []) { const admission = input.handleItem({ @@ -72,15 +83,6 @@ export function restoreCodexJournalThread(input: { } input.currentTurnIds.delete(input.threadId) input.ordinals.forgetTurn(input.threadId, turnId) - const lifecycle = input.restoreTurnLifecycle - ? historicalTurnLifecycle(input.threadId, turn) - : null - if (lifecycle) { - const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } - if (!admission.accepted) { - return admission - } - } } input.flush() return { accepted: true } diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 62aa5f828a1..6fb589caf4e 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -533,8 +533,8 @@ describe('codex turn lifecycle rows', () => { }) ).toEqual({ accepted: true }) + // Each record precedes its turn's items, the order the live path writes. expect(tap.rows).toEqual([ - expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-done', body: { @@ -548,6 +548,7 @@ describe('codex turn lifecycle rows', () => { durationMs: 41_900 } }, + expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-cut', body: { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index d739e0b8d61..ee697732671 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -47,6 +47,7 @@ import { selectNativeChatActiveTurnKey, type NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' +import { nativeChatRowTurnKeys } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnDiffs, type NativeChatDiffReveal, @@ -75,6 +76,7 @@ export function NativeChatMessageList({ workingStartedAt, settledTurns, activeTurnOpenedBy, + turnKeysByItemId = null, deliveryNotices, showTurnStatus = true, showLiveTurnActivity = true, @@ -94,8 +96,11 @@ export function NativeChatMessageList({ workingStartedAt?: number | null /** Host-recorded turn durations keyed by user message id (structured lane). */ settledTurns?: NativeChatSettledTurns - /** The user message the host says opened the running turn (structured lane). */ + /** The key the host says anchors the running turn's bar (structured lane). */ activeTurnOpenedBy?: string | null + /** Host-attributed turn ownership per journal item id (structured lane). + * Rows it does not name keep positional preceding-user grouping. */ + turnKeysByItemId?: ReadonlyMap | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap @@ -160,18 +165,12 @@ export function NativeChatMessageList({ const showTypingIndicator = showTurnStatus ? isWorking : shouldShowNativeChatTypingIndicator({ messages, isWorking }) - const currentTurnKey = messages.findLast((message) => message.role === 'user')?.id ?? undefined - // Resolve each row's turn boundary once. Prefix slice/findLast in the render + // Resolve each row's owning turn once. Prefix slice/findLast in the render // loop becomes quadratic for long transcripts. - const turnKeys = useMemo(() => { - let currentTurnKey: string | undefined - return messages.map((message) => { - if (message.role === 'user') { - currentTurnKey = message.id - } - return currentTurnKey - }) - }, [messages]) + const turnKeys = useMemo( + () => nativeChatRowTurnKeys(messages, showTurnStatus ? turnKeysByItemId : null), + [messages, showTurnStatus, turnKeysByItemId] + ) const turnDiffs = useMemo( () => journalItems @@ -201,7 +200,6 @@ export function NativeChatMessageList({ messages, turnKeys, activeTurnKey, - currentTurnKey, receipts, turnStatuses, turnDiffs, @@ -213,7 +211,6 @@ export function NativeChatMessageList({ }), [ activeTurnKey, - currentTurnKey, expandedTurnIds, isWorking, lifecycleWorking, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx index b80d963bb25..635eef94c35 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx @@ -140,6 +140,44 @@ describe('NativeChatMessageList settled turn fold', () => { expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() }) + // The #23621 shape: the host folded a mid-turn send into the running turn, so + // the rows after it are still the opener's. One bar under the opener folds + // them all; the steered bubble stays visible and never grows a bar or group. + it("folds the rows after a mid-turn send behind the opener's bar", () => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + const steer: (typeof base.messages)[number] = { + id: 'mid-b', + role: 'user', + blocks: [{ type: 'text', text: 'Also check the tests.' }], + timestamp: startedAt + 2, + source: 'transcript' + } + const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] + const turnKeysByItemId = new Map(messages.map((message) => [message.id, 'user-1'])) + render( + + ) + + expect(screen.getByText('Also check the tests.')).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) + expect(toggles).toHaveLength(1) + + fireEvent.click(toggles[0]!) + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() + }) + // The answer is the LAST prose the agent produced. A turn whose final output // is a tool run still answers with the prose before it. it('keeps the last prose row when the turn ends on tool activity', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2de5591b779..26f04ae2e3c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -280,6 +280,7 @@ export function NativeChatStructuredSession( workingStartedAt={controller.workingStartedAt} settledTurns={controller.settledTurns} activeTurnOpenedBy={controller.activeTurnOpenedBy} + turnKeysByItemId={controller.turnKeysByItemId} showTurnStatus showLiveTurnActivity={prompt === null} turnActivity={controller.turnActivity} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index e5313d24d87..54499e3dc31 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -43,8 +43,20 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ const { message, turnKey, status, receipt, turnDiff } = slot const predecessors = context.taskListPredecessors.get(message.id) const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined + const statusRow = status ? ( + context.onToggleExpandedTurn(turnKey) : undefined + } + /> + ) : null return (
+ {/* A turn with no user bubble carries its bar above its first row. */} + {slot.statusAbove ? statusRow : null} {receipt ? ( ) : ( @@ -68,16 +80,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ runtimeContext={context.runtimeContext} /> )} - {status ? ( - context.onToggleExpandedTurn(turnKey) : undefined - } - /> - ) : null} + {slot.statusAbove ? null : statusRow} {turnDiff ? ( ) : null} diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index 3e760a787ca..fa4995b4755 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -44,7 +44,6 @@ function slotsOf(messages: NativeChatMessage[]) { messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 5ee40345e1b..ba8e6fa8f21 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -83,7 +83,6 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: turn, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 5f1fc09b3a1..21e48166e9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -40,7 +40,6 @@ function build( messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: undefined, receipts: new Map(), turnStatuses: NO_STATUSES, turnDiffs: new Map(), @@ -208,6 +207,113 @@ describe('a send the host rejected', () => { }) }) +// Rows belong to the turn the host says owns them, not to the nearest preceding +// user bubble. The owned turn keys reshape the fold, the bar and liveness. +describe('turn-owned grouping', () => { + const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 70 } + // The #23621 shape: B lands mid-turn, three tool calls follow, one turn. + const midTurn = [ + text('A', 'go', 'user'), + toolRun('t1'), + text('B', 'and also this', 'user'), + toolRun('t2'), + toolRun('t3'), + toolRun('t4'), + text('answer', 'Done.') + ] + const ownedKeys = midTurn.map(() => 'A') + + it("folds every row of a settled turn behind its opener's bar, across a mid-turn send", () => { + const slots = build(midTurn, { + turnKeys: ownedKeys, + turnStatuses: { active: null, completedByTurn: { A: settled } } + }) + // All four tool rows fold; the steered bubble stays visible with no bar of its own. + expect(slots.map((slot) => [slot.message.id, slot.status ?? null])).toEqual([ + ['A', settled], + ['B', null], + ['answer', null] + ]) + expect(slots[0]?.turnFolds).toBe(true) + }) + + it('returns every row of the turn when the reader opens it', () => { + const slots = build(midTurn, { + turnKeys: ownedKeys, + turnStatuses: { active: null, completedByTurn: { A: settled } }, + expandedTurnKeys: new Set(['A']) + }) + expect(slots.map((slot) => slot.message.id)).toEqual([ + 'A', + 't1', + 'B', + 't2', + 't3', + 't4', + 'answer' + ]) + }) + + it("anchors a provider-opened turn's bar above its first row", () => { + const messages = [text('u1', 'earlier', 'user'), toolRun('w1'), text('done', 'Woke up.')] + const slots = build(messages, { + turnKeys: ['u1', 'wake', 'wake'], + activeTurnKey: 'wake', + turnStatuses: { active: settled, completedByTurn: {} }, + isWorking: true + }) + expect(slots.map((slot) => [slot.message.id, slot.status ?? null, slot.statusAbove])).toEqual([ + ['u1', null, false], + ['w1', settled, true], + ['done', null, false] + ]) + }) + + it("rolls a turn's diff up once, under its last row, when another prompt lands among its rows", () => { + // B opens its own turn but was sent before A's last row was written. + const messages = [ + text('A', 'go', 'user'), + toolRun('t1'), + text('B', 'next', 'user'), + text('a-answer', 'Done with A.'), + toolRun('b1') + ] + const diff = (added: number): NativeChatTurnDiff => ({ + files: [], + added, + removed: 0, + truncated: false + }) + const slots = build(messages, { + turnKeys: ['A', 'A', 'B', 'A', 'B'], + turnDiffs: new Map([ + ['A', diff(1)], + ['B', diff(2)] + ]) + }) + expect( + slots.filter((slot) => slot.turnDiff).map((slot) => [slot.message.id, slot.turnDiff?.added]) + ).toEqual([ + ['a-answer', 1], + ['b1', 2] + ]) + }) + + it("keeps a running turn's rows live while a newer message waits behind it", () => { + const messages = [text('A', 'go', 'user'), toolRun('t1'), text('C', 'next up', 'user')] + const slots = build(messages, { + turnKeys: ['A', 'A', 'C'], + activeTurnKey: 'A', + isWorking: true + }) + expect(slots.map((slot) => [slot.message.id, slot.activeTurnIsWorking])).toEqual([ + ['A', true], + ['t1', true], + ['C', false] + ]) + }) +}) + describe("a subagent's rows speak as that subagent", () => { const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 5 } const roster: NativeChatMessage = { diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 6e0dc5bff4b..1b20671ce24 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -15,7 +15,11 @@ import { } from '../../../../shared/native-chat-types' import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' import { nativeChatSubagentLabel } from '../../../../shared/native-chat-subagent-attribution' -import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import { + NATIVE_CHAT_UNANCHORED_TURN_KEY, + type NativeChatTurnStatus +} from '../../../../shared/native-chat-turn-status' +import { nativeChatSelfAnchoredTurnRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, type NativeChatTurnFoldRow @@ -44,6 +48,9 @@ export type NativeChatTranscriptSlot = { receipt: NativeChatResolvedPrompt | undefined /** Turn timing shown under this row, already filtered to "should render". */ status: NativeChatTurnStatus | undefined + /** The bar renders above the row: this turn has no user bubble of its own + * (provider-opened), so its bar sits at the turn's position instead. */ + statusAbove?: boolean /** This row is behind its turn's folded status row: it draws no prose and no * tool activity, only work that outlives the turn. */ folded: boolean @@ -60,9 +67,8 @@ export type NativeChatTranscriptSlot = { export type NativeChatTranscriptSlotsInput = { messages: readonly NativeChatMessage[] turnKeys: readonly (string | undefined)[] - /** The user message whose bar carries the live turn's status. */ + /** The transcript key whose bar carries the live turn's status. */ activeTurnKey: string - currentTurnKey: string | undefined receipts: ReadonlyMap turnStatuses: { active: NativeChatTurnStatus | null @@ -86,7 +92,6 @@ export function buildNativeChatTranscriptSlots( messages, turnKeys, activeTurnKey, - currentTurnKey, receipts, turnStatuses, turnDiffs, @@ -147,19 +152,37 @@ export function buildNativeChatTranscriptSlots( settledTurnKeys, expandedTurnKeys }) + // A turn with no user bubble (provider-opened) anchors its bar at its first row. + const selfAnchors = nativeChatSelfAnchoredTurnRows(messages, turnKeys) + // A turn's rows need not be contiguous (another turn's prompt can land among + // them), so its rollup goes under its last row, not every run boundary. + const lastRowByTurn = new Map() + turnKeys.forEach((turnKey, index) => { + if (turnKey !== undefined) { + lastRowByTurn.set(turnKey, index) + } + }) const slots: NativeChatTranscriptSlot[] = [] for (const [index, message] of messages.entries()) { const turnKey = turnKeys[index] const receipt = receipts.get(message.id) + const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index + // Only the bubble that opened a turn carries its bar: a message the provider + // folded into a running turn shares the turn's key but not its bar. const candidateStatus = message.role === 'user' && message.id === activeTurnKey ? turnStatuses.active - : message.role === 'user' && turnKey + : message.role === 'user' && turnKey === message.id ? turnStatuses.completedByTurn[turnKey] - : undefined + : anchorsTurnHere + ? turnKey === activeTurnKey + ? (turnStatuses.active ?? turnStatuses.completedByTurn[turnKey]) + : turnStatuses.completedByTurn[turnKey] + : undefined // The live turn's bar carries its running clock; it settles in place. const status = showTurnStatus ? (candidateStatus ?? undefined) : undefined - const turnDiff = turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined + const turnDiff = + turnKey && lastRowByTurn.get(turnKey) === index ? turnDiffs.get(turnKey) : undefined const folded = foldedRows.has(index) // Skipping a folded row entirely is what keeps windowing honest: a counted // index the row declines to draw reserves estimated height for nothing and @@ -173,12 +196,16 @@ export function buildNativeChatTranscriptSlots( slots.push({ message, turnKey, + // Liveness is the owning turn's, not the newest prompt's: a running turn's + // rows stay live while a newer message waits behind it. activeTurnIsWorking: - (currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) && + (turnKey === activeTurnKey || + (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) && (isWorking || lifecycleWorking), trailingRun: trailingRunIndexes.has(index), receipt, status: status ?? undefined, + statusAbove: anchorsTurnHere && status !== undefined, folded, turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index b48025533f4..cbbd819ce22 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -33,7 +33,6 @@ function slotsOf(messages: NativeChatMessage[]) { messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index d9ca4995b13..620844881f9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -157,6 +157,7 @@ export function useStructuredAgentSession(args: { workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, activeTurnOpenedBy: transportState.turnTiming.activeTurnOpenedBy, + turnKeysByItemId: transportState.turnTiming.turnKeysByItemId, turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts index 1c26fd9750c..dd6b1662824 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts @@ -12,7 +12,8 @@ import { } from '../../../../shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the user message that owns the running turn's bar, and a + * off the journal, the transcript key that owns the running turn's bar, each + * row's owning turn, and a * skew-free start for the live counter whose host-to-local conversion is latched * once per turn. */ export function useStructuredAgentTurnTiming( @@ -30,8 +31,9 @@ export function useStructuredAgentTurnTiming( settledTurns: NativeChatSettledTurns workingStartedAt: number | null activeTurnOpenedBy: string | null + turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo( + const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) @@ -48,5 +50,10 @@ export function useStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy } + return { + settledTurns, + workingStartedAt: step.workingStartedAt, + activeTurnOpenedBy, + turnKeysByItemId + } } diff --git a/src/shared/native-chat-turn-grouping.test.ts b/src/shared/native-chat-turn-grouping.test.ts new file mode 100644 index 00000000000..5c6425dd7ed --- /dev/null +++ b/src/shared/native-chat-turn-grouping.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { nativeChatRowTurnKeys, nativeChatSelfAnchoredTurnRows } from './native-chat-turn-grouping' + +function message(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } +} + +describe('nativeChatRowTurnKeys', () => { + it('keeps rows after a mid-turn send with the turn that produced them', () => { + // The #23621 shape: B lands mid-turn, three tool rows follow, all one turn. + const messages = [ + message('A', 'user'), + message('t1'), + message('B', 'user'), + message('t2'), + message('t3'), + message('t4') + ] + const owned = new Map([ + ['A', 'A'], + ['t1', 'A'], + ['B', 'A'], + ['t2', 'A'], + ['t3', 'A'], + ['t4', 'A'] + ]) + expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'A', 'A', 'A', 'A']) + }) + + it('lets an unmapped user row key itself and unmapped rows inherit it', () => { + // C is an optimistic echo the journal has not admitted yet: positional rules. + const messages = [message('A', 'user'), message('t1'), message('C', 'user'), message('t2')] + const owned = new Map([ + ['A', 'A'], + ['t1', 'A'] + ]) + expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'C', 'C']) + }) + + it('reproduces preceding-user grouping exactly when the host attributes nothing', () => { + const messages = [ + message('lead'), + message('A', 'user'), + message('t1'), + message('B', 'user'), + message('t2') + ] + const positional = [undefined, 'A', 'A', 'B', 'B'] + expect(nativeChatRowTurnKeys(messages, null)).toEqual(positional) + expect(nativeChatRowTurnKeys(messages, new Map())).toEqual(positional) + }) + + it('keys a provider-opened turn to a record no message carries', () => { + const messages = [message('A', 'user'), message('t1'), message('t2')] + const owned = new Map([ + ['A', 'A'], + ['t1', 'wake-turn'], + ['t2', 'wake-turn'] + ]) + expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'wake-turn', 'wake-turn']) + }) +}) + +describe('nativeChatSelfAnchoredTurnRows', () => { + it('anchors a turn with no user bubble at its first rendered row', () => { + const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')] + const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn'] + expect(nativeChatSelfAnchoredTurnRows(messages, turnKeys)).toEqual(new Map([['wake-turn', 2]])) + }) + + it('never anchors a turn whose key is a rendered message', () => { + const messages = [message('A', 'user'), message('t1')] + expect(nativeChatSelfAnchoredTurnRows(messages, ['A', 'A']).size).toBe(0) + }) +}) diff --git a/src/shared/native-chat-turn-grouping.ts b/src/shared/native-chat-turn-grouping.ts new file mode 100644 index 00000000000..7c181352db5 --- /dev/null +++ b/src/shared/native-chat-turn-grouping.ts @@ -0,0 +1,49 @@ +// Which turn each transcript row belongs to, and where a turn with no user +// bubble anchors its bar. Shared because desktop and mobile both group rows and +// place bars from these keys, and a row grouped differently on each surface is +// the same bug twice. + +import type { NativeChatMessage } from './native-chat-types' + +/** + * Resolve each row's turn key. The host's attribution (`turnKeysByItemId`, from + * `selectStructuredAgentTurnBars`) wins, so rows after a mid-turn send stay with + * the turn that produced them. Rows it cannot name keep positional grouping — + * an unmapped user row keys itself, anything else inherits the previous row's + * key — which on a host without turn attribution is exactly today's + * preceding-user-message grouping. + */ +export function nativeChatRowTurnKeys( + messages: readonly NativeChatMessage[], + turnKeysByItemId?: ReadonlyMap | null +): (string | undefined)[] { + let currentTurnKey: string | undefined + return messages.map((message) => { + const owned = turnKeysByItemId?.get(message.id) + if (owned !== undefined) { + currentTurnKey = owned + return owned + } + if (message.role === 'user') { + currentTurnKey = message.id + } + return currentTurnKey + }) +} + +/** For each turn key with no message of its own — a turn the provider opened, or + * whose opener is outside the loaded window — the row index that anchors its + * bar: the turn's first rendered row, matching the turn's own position. */ +export function nativeChatSelfAnchoredTurnRows( + messages: readonly NativeChatMessage[], + turnKeys: readonly (string | undefined)[] +): ReadonlyMap { + const messageIds = new Set(messages.map((message) => message.id)) + const anchors = new Map() + turnKeys.forEach((turnKey, index) => { + if (turnKey !== undefined && !messageIds.has(turnKey) && !anchors.has(turnKey)) { + anchors.set(turnKey, index) + } + }) + return anchors +} diff --git a/src/shared/structured-agent-session-turn-bars.test.ts b/src/shared/structured-agent-session-turn-bars.test.ts index 0f4732a3aac..219939d00e6 100644 --- a/src/shared/structured-agent-session-turn-bars.test.ts +++ b/src/shared/structured-agent-session-turn-bars.test.ts @@ -333,7 +333,7 @@ describe('turns whose host names no opener', () => { expect([...selectStructuredAgentSettledTurns(items).keys()]).toEqual(['orca:u1']) }) - it('falls back for a turn the provider opened and keyed to itself', () => { + it('anchors a turn the provider opened to its own record, never a bystander prompt', () => { const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed' const items = [ user(1, 'u1', 1_000), @@ -344,7 +344,37 @@ describe('turns whose host names no opener', () => { userItemId: self }) ] - expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBeNull() + expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBe(self) + }) + + it('keeps the running bar on the send Codex opened a turn for before it echoes it', () => { + // Codex reports turn/started before hooks and prewarm run, so the turn names its + // provider key while the send that opened it is still pending (no alias yet). + const key = 'codex:thread:t2:0' + const items: AgentJournalRenderItem[] = [ + user(1, 'first', 1_000), + turn(2, 1_100, { + turnId: 't1', + state: 'completed', + userItemId: 'orca:first', + startedAt: 1_100, + completedAt: 2_000 + }), + tool(3, 1_500), + user(4, 'second', 3_000), + turn(5, 3_100, { turnId: 't2', state: 'running', startedAt: 3_100, userItemId: key }) + ] + const submissions = [accepted('first', 'claude:first'), accepted('second', null)] + const bars = selectStructuredAgentTurnBars(items, submissions, 't2') + expect(bars.activeTurnOpenedBy).toBe('orca:second') + expect(bars.turnKeysByItemId.get('orca:second')).toBe('orca:second') + // A turn with no send in flight still anchors to its own record. + const selfOpened = selectStructuredAgentTurnBars( + items, + [accepted('first', 'claude:first'), accepted('second', 'claude:second')], + 't2' + ) + expect(selfOpened.activeTurnOpenedBy).toBe('legacy:claude:55368cfb:turn-lifecycle%3At2') }) it('names nothing for the unanchored transcript', () => { @@ -399,3 +429,104 @@ describe('the previous turn has no recorded end', () => { expect(structuredAgentTurnOrigin(queued(12_000))).toBe(9_000) }) }) + +describe('which turn owns each transcript row', () => { + const keysOf = (snapshot: { + turnId: string | null + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + }) => + selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, snapshot.turnId) + .turnKeysByItemId + + it('keeps the rows Claude produces after a mid-turn send with the turn that ran them', () => { + // The #23621 shape: B lands mid-turn and three tool calls follow, one turn. + const midTurn = { + turnId: turn1.turnId, + items: [ + user(54, 'A', A.sent), + turn(57, T + 77_224, { + ...turn1, + state: 'running', + startedAt: A.started, + requestedAt: A.sent + }), + tool(58, T + 77_300), + user(60, 'B', B.sent), + tool(61, T + 80_100), + tool(62, T + 80_200), + tool(63, T + 80_300) + ], + submissions: [accepted('A', turn1.userItemId), accepted('B', null)] + } + const keys = keysOf(midTurn) + expect(keys.get('orca:A')).toBe('orca:A') + expect(keys.get('orca:B')).toBe('orca:A') + for (const sequence of [58, 61, 62, 63]) { + expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A') + } + }) + + it('leaves a fresh tail send unowned until the turn proves it continued past it', () => { + // B is the newest row: nothing after it says the running turn absorbed it. + expect(keysOf(whileQueued).has('orca:B')).toBe(false) + // B's own turn opened: B is an opener and keys itself. + expect(keysOf(whileB).get('orca:B')).toBe('orca:B') + expect(keysOf(whileB).get(`orca:claude-tool%3A58`)).toBe('orca:A') + }) + + it('folds a Codex-coalesced send into the turn its provider key names', () => { + const key = 'codex:thread:t1:0' + const items: AgentJournalRenderItem[] = [ + user(1, 'first', 1_000), + { + itemId: 'legacy:codex:s:turn-lifecycle%3At1', + revision: 1, + sequence: 2, + observedAt: 1_200, + body: { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: key, + requestedAt: 1_000, + startedAt: 1_200 + } + }, + user(3, 'second', 4_000), + tool(4, 5_000) + ] + const submissions = [accepted('first', key), accepted('second', key)] + const keys = keysOf({ turnId: 't1', items, submissions }) + expect(keys.get('orca:first')).toBe('orca:first') + expect(keys.get('orca:second')).toBe('orca:first') + expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first') + }) + + it('keys a provider-opened turn and its rows to the turn record itself', () => { + const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed' + const items = [ + user(1, 'u1', 1_000), + turn(2, 5_000, { + turnId: 'resumed', + state: 'running', + startedAt: 5_000, + userItemId: self + }), + tool(3, 6_000) + ] + const keys = keysOf({ turnId: 'resumed', items, submissions: [] }) + // u1 predates the wake turn and never opened one: positional grouping keeps it. + expect(keys.has('orca:u1')).toBe(false) + expect(keys.get('orca:claude-tool%3A3')).toBe(self) + }) + + it('attributes nothing for an older host that names no opener', () => { + const items = [ + user(1, 'u1', 1_000), + turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), + tool(3, 1_200) + ] + expect(keysOf({ turnId: 't1', items, submissions: [] }).size).toBe(0) + }) +}) diff --git a/src/shared/structured-agent-session-turn-timing.test.ts b/src/shared/structured-agent-session-turn-timing.test.ts index ba9af94258f..1cf2d32db91 100644 --- a/src/shared/structured-agent-session-turn-timing.test.ts +++ b/src/shared/structured-agent-session-turn-timing.test.ts @@ -226,7 +226,7 @@ describe('explicit user-item attribution', () => { expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual(['claude:s:u1']) }) - it('attributes nothing when a keyed row names a user item nobody journaled', () => { + it('anchors to the record itself when a keyed row names a user item nobody journaled', () => { const items = [ user('orca:first'), lifecycle('auto', { @@ -236,7 +236,11 @@ describe('explicit user-item attribution', () => { completedAt: 2_000 }) ] - expect(selectStructuredAgentTurnTimings(items).size).toBe(0) + // Never the preceding prompt, which did not open this turn; the record's own + // key gives the turn a bar at its own position instead. + expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual([ + 'legacy:codex:s:turn-lifecycle%3Aauto' + ]) }) it('falls back to journal order only for rows without a key (older hosts)', () => { diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 9d93639e181..e760796618c 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -75,9 +75,12 @@ function readTiming( /** One turn record, read in journal order. */ type StructuredAgentJournalTurn = { timing: StructuredAgentTurnTiming | null - /** The user message the host names as the turn's opener; null when it names - * none (an older host, or a turn the provider opened and keyed to itself). */ - openedBy: string | null + /** The transcript key that anchors this turn's bar and owns its rows: the + * opener user item when the host names one it can resolve (or the send still + * in flight ahead of the record), else the turn record's own item (a turn the + * provider opened, or an opener outside the loaded window). Null only for an + * older host that names nothing. */ + key: string | null } type StructuredAgentJournalTurns = { @@ -105,13 +108,24 @@ function readStructuredAgentJournalTurns( aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) } } + // Sends not yet matched to a provider item. Codex reports a turn open before it + // echoes the send, so for that gap the turn names a key no alias resolves yet. + const inFlight = new Set( + submissions + .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) const byUserItem = new Map() const byTurnId = new Map() let precedingUserItemId: string | null = null + let inFlightSinceLastTurn: string | null = null let precedingTurnEndedAt: number | undefined for (const item of items) { if (item.body.kind === 'message' && item.body.role === 'user') { precedingUserItemId = item.itemId + if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { + inFlightSinceLastTurn = item.itemId + } continue } const turn = readAgentJournalTurn(item.body) @@ -122,14 +136,16 @@ function readStructuredAgentJournalTurns( precedingTurnEndedAt = timing?.completedAt ?? item.observedAt const key = turn.userItemId const named = key === undefined ? null : itemIds.has(key) ? key : (aliases.get(key) ?? null) - byTurnId.set(turn.turnId, { - timing, - openedBy: named === item.itemId ? null : named - }) + // An unresolved opener is the send still in flight ahead of this record; with none, + // it is outside the window, and the turn anchors to its own record like a + // provider-opened one — never to a preceding prompt that did not open it. + const turnKey = key === undefined ? null : (named ?? inFlightSinceLastTurn ?? item.itemId) + inFlightSinceLastTurn = null + byTurnId.set(turn.turnId, { timing, key: turnKey }) if (!timing && turn.state !== 'unverifiable') { continue } - const userItemId = key === undefined ? precedingUserItemId : named + const userItemId = key === undefined ? precedingUserItemId : turnKey if (userItemId !== null) { byUserItem.set(userItemId, timing) } @@ -137,6 +153,58 @@ function readStructuredAgentJournalTurns( return { byUserItem, byTurnId } } +/** + * Which turn owns each journal item, as the transcript groups rows: every item + * between a turn record and the next belongs to that record's turn (the record + * is appended when the turn opens and revised in place, and an opener's user + * item is written ahead of dispatch, so journal order is turn order). A user + * item that opened any turn — even a later one it queued for — keys itself; one + * the provider folded into a running turn (a steer) takes that turn's key, but + * only once the turn produces more rows after it, so a fresh tail send is not + * pulled into the turn it is merely waiting behind. Items before the first turn + * record stay absent, and the surface keeps its positional grouping for them. + */ +function turnKeysByItemIdOf( + items: readonly AgentJournalRenderItem[], + byTurnId: ReadonlyMap +): ReadonlyMap { + const openers = new Set() + for (const turn of byTurnId.values()) { + if (turn.key !== null) { + openers.add(turn.key) + } + } + const keys = new Map() + let currentKey: string | null = null + // User items folded into the current turn, held until a later row proves the + // turn continued past them. + let pendingUserItemIds: string[] = [] + for (const item of items) { + const turn = readAgentJournalTurn(item.body) + if (turn) { + currentKey = byTurnId.get(turn.turnId)?.key ?? null + pendingUserItemIds = [] + continue + } + if (item.body.kind === 'message' && item.body.role === 'user') { + if (openers.has(item.itemId)) { + keys.set(item.itemId, item.itemId) + } else if (currentKey !== null) { + pendingUserItemIds.push(item.itemId) + } + continue + } + if (currentKey !== null) { + for (const userItemId of pendingUserItemIds) { + keys.set(userItemId, currentKey) + } + pendingUserItemIds = [] + keys.set(item.itemId, currentKey) + } + } + return keys +} + export function selectStructuredAgentTurnTimings( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] @@ -237,8 +305,9 @@ function settledTurnsOf( } /** Everything a chat surface reads off the journal for its turn bars, from one - * pass: settled durations, the running turn's timing, and the user message that - * owns its bar — which a message sent while the turn runs is not. */ + * pass: settled durations, the running turn's timing, the item that anchors its + * bar — which a message sent while the turn runs is not — and which turn owns + * each row of the transcript. */ export function selectStructuredAgentTurnBars( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], @@ -246,13 +315,18 @@ export function selectStructuredAgentTurnBars( ): { settledTurns: NativeChatSettledTurns runningTiming: StructuredAgentTurnTiming | null + /** The transcript key the running turn's bar anchors to: its opening user + * message, or the turn record itself when the provider opened the turn. + * Null on an older host, where the newest user message stays the anchor. */ activeTurnOpenedBy: string | null + turnKeysByItemId: ReadonlyMap } { const turns = readStructuredAgentJournalTurns(items, submissions) const running = turnId === null ? undefined : turns.byTurnId.get(turnId) return { settledTurns: settledTurnsOf(turns, submissions), runningTiming: running?.timing ?? null, - activeTurnOpenedBy: running?.openedBy ?? null + activeTurnOpenedBy: running?.key ?? null, + turnKeysByItemId: turnKeysByItemIdOf(items, turns.byTurnId) } } From bd133058a933af1ed80a42f84c05ef1cb7592936 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:57:55 -0700 Subject: [PATCH 354/387] refactor(sidebar): one subagent row for CLI and structured children, shared with the chat strip (#22565) * test(sidebar): pin today's subagent rows and chat strip rows from legacy shapes Captured on the unmodified renderer: the compact and full sidebar child rows built from a legacy subagents snapshot, and the expanded chat strip built from a legacy background-task roster. A host that sends only these shapes must keep rendering exactly these rows. * refactor(sidebar): one subagent row for CLI and structured children, shared with the chat strip A child row's dot, name and detail are now decided once, by a shared row model (src/shared/agent-child-row-model.ts), and rendered by one piece (AgentChildRowContent) that both the sidebar's child rows and the chat strip use. The model reads the host's child views when a session publishes them and today's legacy shapes otherwise (the subagents snapshot for the sidebar, the task roster for the strip), so old hosts and CLI panes render exactly as before. From views it keeps what the legacy path lost: a finished subagent whose shell still runs reads monitoring through the shared child fold, a settled child reads by its outcome, the tool it runs shows as the CLI row shows it, and each row keeps its own clock. In the strip, work a child owns renders nested beneath it. * i18n: add the child row's Ended and ended-ago strings to every catalog * test(sidebar): a child reads the same in the sidebar and the chat strip A row-model table for every display state, and a parity table that renders the same child views through the compact sidebar row and the chat strip and asserts both show the same dot, name and detail. Covers a finished subagent whose shell still runs (monitoring on both, no stale tool text), sibling rows with their own clocks, a settled row timed from when it ended, and owned shells nested under their owner in the strip. The strip's view input is named childViews so it cannot be mistaken for React children. * fix(sidebar): keep the child display derivation loadable in the renderer The row model imported deriveAgentChildDisplayState from the view module, whose owner resolution reaches status subjects and, through them, agent-hook-relay's node:crypto. The renderer cannot evaluate that chunk, so the app booted blank (the renderer node-builtin boundary test fails on the previous commit). The display derivation (agentChildWorkOwnedLiveness, deriveAgentChildDisplayState, AgentChildDisplayState) now lives in agent-status-child-work-display.ts, which imports only the fold, liveness and the one-pass grouping both modules share (grouped-by.ts); the view module keeps the host-side projection. * fix(sidebar): the strip reads its parent's verdict, and the full row says Ended Review fixes: - The strip's view path took no freshness input, so once views are wired the same lost child would read unverifiable in the sidebar and working in the strip. agentChildRowContextForParent builds the one context a parent row gives its children; the sidebar uses it, and buildBackgroundTaskGroupsFromViews / the strip's new optional childRowContext prop accept it (absent: claims stand as reported, as before). - The full sidebar row showed no word for a child that ended with no outcome; its message line now reads the row model's (the message, or Ended). An unlabeled child falls back to its display state there too. - The summary order now includes failed, so a failed row is never dropped from the counts. - Parity now covers the full row for every state, a live parked child, an unlabeled child, and a lost or stale parent on both surfaces. * refactor(agent-status): the subagent snapshot, its normalization and equality get their own module agent-status-types.ts crossed the file-size limit once the row gained child views beside the main agent fact. The subagent snapshot shape, its admission normalization and the array equality move into agent-status-subagent-snapshot.ts (re-exported, so importers are unchanged); the three field caps it shares with the row move to the field normalization. * refactor(sidebar): one legacy builder family, one reader clock, frozen settled rows - The chat strip's task-roster rows are built in the shared row model beside the other two builders, with one placeholder set and the one detail rule. The module header states when each legacy builder is deleted. - A child's "No update" duration reads the parent's reader clock (receipt time for a mirrored parent); a view child's own stamp is moved onto that clock. The full sidebar row reads the same value as the compact row. - The failed->blocked / interrupted->idle collapse has one owner, shared by the sidebar row state and the strip header. - A settled strip row shows its run frozen at settledAt instead of a growing "ended N ago", so a strip of only finished work never wakes the 1 Hz tick. * test(sidebar): the sidebar row state and the strip header share one lifecycle word * test(sidebar): a child running a shell in its turn shows its Bash line with the shell nested beneath it While a child's turn runs its shell, the host records the shell both as the child's operation and as a live command the child owns. The strip shows the child working its Bash line with the shell row nested beneath it, the header counts only the agent, and the sidebar shows the child alone with the same text. * test(sidebar): re-pin the legacy chat strip golden to main's scoped goal-dock selector Main's #23725 rewrote the strip's goal-dock variants from `group-has-[...]/tasks:` to an ancestor-scoped `[[data-native-chat-background-tasks]:has(+[data-native-chat-thread-goal])_&]:` selector. The merged head renders byte-identically to main for this fixture; only the golden was captured before that change. --- .../codex/codex-child-work-evidence.test.ts | 6 +- ...dex-structured-child-work-producer.test.ts | 6 +- .../src/components/AgentChildRowContent.tsx | 58 ++ .../agent-child-row-parity.test.tsx | 631 ++++++++++++++++++ .../src/components/agent-child-row-text.ts | 69 ++ .../dashboard/DashboardAgentRow.tsx | 36 +- .../dashboard/agent-finished-timestamp.ts | 8 +- .../components/dashboard/useDashboardData.ts | 3 + .../NativeChatBackgroundTasksStatus.test.tsx | 44 ++ .../NativeChatBackgroundTasksStatus.tsx | 164 +++-- .../background-task-header-content.ts | 4 +- .../background-task-roster.test.ts | 20 +- .../native-chat/background-task-roster.ts | 133 ++-- ...nt-child-row-fallback-pixels.test.tsx.snap | 46 ++ ...ubagent-child-row-fallback-pixels.test.tsx | 237 +++++++ .../worktree-card-agent-summary.test.ts | 30 +- .../sidebar/worktree-card-agent-summary.ts | 7 +- .../worktree-card-compact-agent-row.tsx | 77 ++- .../sidebar/worktree-subagent-child-rows.ts | 136 ++-- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + src/renderer/src/lib/agent-row-dot-state.ts | 21 +- src/shared/agent-child-row-model.test.ts | 384 +++++++++++ src/shared/agent-child-row-model.ts | 376 +++++++++++ src/shared/agent-status-child-work-display.ts | 114 ++++ .../agent-status-child-work-view.test.ts | 4 +- src/shared/agent-status-child-work-view.ts | 99 +-- .../agent-status-field-normalization.ts | 7 +- src/shared/agent-status-subagent-snapshot.ts | 108 +++ src/shared/agent-status-types.ts | 130 +--- src/shared/grouped-by.ts | 14 + 35 files changed, 2542 insertions(+), 448 deletions(-) create mode 100644 src/renderer/src/components/AgentChildRowContent.tsx create mode 100644 src/renderer/src/components/agent-child-row-parity.test.tsx create mode 100644 src/renderer/src/components/agent-child-row-text.ts create mode 100644 src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap create mode 100644 src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx create mode 100644 src/shared/agent-child-row-model.test.ts create mode 100644 src/shared/agent-child-row-model.ts create mode 100644 src/shared/agent-status-child-work-display.ts create mode 100644 src/shared/agent-status-subagent-snapshot.ts create mode 100644 src/shared/grouped-by.ts diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 0f3605bee22..9013b07a674 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -5,9 +5,9 @@ import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-wor import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' import { agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState, - projectAgentChildWorkViews -} from '../../shared/agent-status-child-work-view' + deriveAgentChildDisplayState +} from '../../shared/agent-status-child-work-display' +import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' import { createAgentStatusStore } from '../../shared/agent-status-store' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 44d4463a30d..fc011d48bb9 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -12,9 +12,9 @@ import { agentChildWorkLiveness } from '../../shared/agent-status-child-work-liv import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' import { agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState, - projectAgentChildWorkViews -} from '../../shared/agent-status-child-work-view' + deriveAgentChildDisplayState +} from '../../shared/agent-status-child-work-display' +import { projectAgentChildWorkViews } from '../../shared/agent-status-child-work-view' import { createAgentStatusStore } from '../../shared/agent-status-store' import { agentJournalLinkageFields } from '../../shared/agent-session-journal-producer' import type { diff --git a/src/renderer/src/components/AgentChildRowContent.tsx b/src/renderer/src/components/AgentChildRowContent.tsx new file mode 100644 index 00000000000..717b8438a0b --- /dev/null +++ b/src/renderer/src/components/AgentChildRowContent.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { AgentStateDot } from '@/components/AgentStateDot' +import type { StateIndicatorTooltipSide } from '@/components/StateIndicatorTooltip' +import { agentChildRowText } from './agent-child-row-text' + +type AgentChildRowContentProps = { + row: AgentChildRowModel + now: number + /** Surface tone for the name and the detail; the words themselves are the row's. */ + leadClassName: string + trailClassName: string + separator: string + /** Overrides the dot's hover label; null suppresses it for an existing tooltip. */ + dotTitle?: string | null + tooltipSide?: StateIndicatorTooltipSide + /** Name the whole line on hover, for surfaces that truncate it tightly. */ + lineTitle?: boolean +} + +/** + * A child row's state dot, name and detail: the one piece the sidebar's child rows and the chat + * strip's rows both render, so the same child reads the same on both. + */ +export const AgentChildRowContent = React.memo(function AgentChildRowContent({ + row, + now, + leadClassName, + trailClassName, + separator, + dotTitle, + tooltipSide, + lineTitle = false +}: AgentChildRowContentProps): React.JSX.Element { + const { lead, trail } = agentChildRowText(row, now) + return ( + <> + + + {lead} + {trail ? ( + + {separator} + {trail} + + ) : null} + + + ) +}) diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx new file mode 100644 index 00000000000..c8e3b4bd190 --- /dev/null +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -0,0 +1,631 @@ +/** @vitest-environment happy-dom */ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + agentChildRowContextForParent, + type AgentChildRowContext +} from '../../../shared/agent-child-row-model' +import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' +import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' +import { buildBackgroundTaskGroupsFromViews } from '@/components/native-chat/background-task-roster' +import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row' +import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' +import { TooltipProvider } from '@/components/ui/tooltip' + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('@/components/sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +const NOW = 1_000_000 +const MINUTE = 60_000 + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'wt-1', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: 'Audit the parser', + agentType: 'general-purpose', + state: 'working', + membership: 'live', + firstObservedAt: NOW - 5 * MINUTE, + observedAt: NOW - 2 * MINUTE, + stoppable: true, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...overrides + } +} + +function settled( + outcome: NonNullable, + overrides: Partial = {} +): AgentChildWorkView { + return view('child', { + state: 'done', + membership: 'settled', + outcome, + settledAt: NOW - 3 * MINUTE, + ...overrides + }) +} + +const OWNED_SHELL = view('shell', { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: 'child' +}) + +function parentWith(children: AgentChildWorkView[], updatedAt = NOW): AgentStatusEntry { + return { + paneKey: 'parent-tab:leaf-1', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'done', + prompt: 'parent prompt', + updatedAt, + stateStartedAt: NOW - 20 * MINUTE, + stateHistory: [], + children + } +} + +type RenderedRow = { dot: string; lead: string; trail: string } + +function readRow(root: Element, separator: string): RenderedRow { + const text = root.querySelector('span.truncate') + const [lead, trail] = [...(text?.children ?? [])].map((span) => span.textContent ?? '') + return { + dot: root.querySelector('[aria-label]')?.getAttribute('aria-label') ?? '', + lead: lead ?? '', + trail: trail?.startsWith(separator) ? trail.slice(separator.length) : (trail ?? '') + } +} + +function mount(markup: string): HTMLElement { + const container = document.createElement('div') + container.innerHTML = markup + return container +} + +function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRow[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + readRow( + mount( + renderToStaticMarkup( + + {}} /> + + ) + ), + ' - ' + ) + ) +} + +function stripRows( + children: AgentChildWorkView[] | undefined, + childRowContext?: AgentChildRowContext, + tasks: AgentSessionBackgroundTask[] = [] +): RenderedRow[] { + const root = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + return [...root.querySelectorAll('li')] + .filter((row) => row.querySelector(':scope > span.truncate')) + .map((row) => readRow(row, ' · ')) +} + +/** The full sidebar row: every dot label it carries, and its whole text. */ +function fullRow(parent: AgentStatusEntry): { labels: string[]; text: string } { + const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: true }) + const root = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + return { + labels: [...root.querySelectorAll('[aria-label]')].map( + (element) => element.getAttribute('aria-label') ?? '' + ), + text: root.textContent ?? '' + } +} + +const SCENARIOS: [string, AgentChildWorkView[], RenderedRow][] = [ + [ + 'working, no known operation', + [view('child')], + { dot: 'Working', lead: 'Audit the parser', trail: 'general-purpose' } + ], + [ + 'working with a tool', + [ + view('child', { + operation: { toolName: 'Read', input: 'src/parser.ts', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Working', lead: 'Audit the parser', trail: 'Read: src/parser.ts' } + ], + [ + 'running a shell in the foreground', + [ + view('child', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Working', lead: 'Audit the parser', trail: 'Bash: npm test' } + ], + [ + 'finished, while a shell it launched still runs', + [settled('succeeded', { lastMessage: 'All green' }), OWNED_SHELL], + { + dot: 'Monitoring background tasks', + lead: 'Monitoring background tasks', + trail: 'Audit the parser' + } + ], + [ + 'waiting on an approval', + [ + view('child', { + state: 'waiting', + operation: { toolName: 'Edit', input: 'src/parser.ts', basis: 'open', observedAt: NOW } + }) + ], + { dot: 'Waiting for input', lead: 'Audit the parser', trail: 'Edit: src/parser.ts' } + ], + [ + 'blocked', + [view('child', { state: 'blocked', lastMessage: 'Rate limited, retrying' })], + { dot: 'Blocked', lead: 'Audit the parser', trail: 'Rate limited, retrying' } + ], + [ + 'finished', + [settled('succeeded', { lastMessage: 'Found 3 call sites' })], + { dot: 'Done', lead: 'Audit the parser', trail: 'Found 3 call sites' } + ], + [ + 'failed', + [settled('failed', { lastMessage: 'Exit code 1' })], + { dot: 'Failed', lead: 'Audit the parser', trail: 'Exit code 1' } + ], + [ + 'cancelled', + [settled('cancelled')], + { dot: 'Interrupted', lead: 'Audit the parser', trail: 'general-purpose' } + ], + [ + 'ended, outcome unknown', + [settled('unknown')], + { dot: 'Idle', lead: 'Audit the parser', trail: 'Ended' } + ], + [ + 'unverifiable', + [view('child', { state: 'unverifiable' })], + { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' } + ], + [ + 'parked, still live', + [view('child', { state: 'idle' })], + { dot: 'Idle', lead: 'Audit the parser', trail: 'general-purpose' } + ] +] + +/** What the full sidebar row, the CLI row's own layout, shows of the same detail. */ +const FULL_ROW_DETAIL: Record = { + 'working, no known operation': { shows: [] }, + 'working with a tool': { shows: ['Read', 'src/parser.ts'] }, + 'running a shell in the foreground': { shows: ['Bash', 'npm test'] }, + 'finished, while a shell it launched still runs': { shows: [], hides: ['All green'] }, + 'waiting on an approval': { shows: ['Edit', 'src/parser.ts'] }, + blocked: { shows: ['Rate limited, retrying'] }, + finished: { shows: ['Found 3 call sites'] }, + failed: { shows: ['Exit code 1'] }, + cancelled: { shows: [] }, + 'ended, outcome unknown': { shows: ['Ended'] }, + unverifiable: { shows: ['No update in 2m'] }, + 'parked, still live': { shows: [] } +} + +describe('a child reads the same in the sidebar and the chat strip', () => { + it.each(SCENARIOS)('%s', (name, children, expected) => { + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + expect(sidebar).toEqual(expected) + expect(strip).toEqual(expected) + const full = fullRow(parentWith(children)) + expect(full.labels).toContain(expected.dot) + expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead) + for (const text of FULL_ROW_DETAIL[name].shows) { + expect(full.text).toContain(text) + } + for (const text of FULL_ROW_DETAIL[name].hides ?? []) { + expect(full.text).not.toContain(text) + } + }) + + it('names an unlabeled child by the same state on every surface', () => { + const children = [ + settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' }) + ] + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + expect(sidebar.lead).toBe('Failed') + expect(strip.lead).toBe('Failed') + expect(fullRow(parentWith(children)).text).toContain('Failed') + }) + + it('shows the monitoring icon on the full sidebar row too', () => { + const [agent] = buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded'), OWNED_SHELL]), + tab, + parentIsFresh: true + }) + const root = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + expect(root.querySelector('[aria-label]')?.getAttribute('aria-label')).toBe( + 'Monitoring background tasks' + ) + // The finished child's last tool line is stale; neither row names it. + expect(root.textContent).not.toContain('Bash') + }) + + it('never shows a monitoring child tool text on either surface', () => { + const children = [ + settled('succeeded', { lastMessage: 'Bash: npm test finished' }), + { ...OWNED_SHELL, description: 'tail -f server.log' } + ] + const [sidebar] = sidebarRows(parentWith(children)) + const [strip] = stripRows(children) + for (const row of [sidebar, strip]) { + expect(row.dot).toBe('Monitoring background tasks') + expect(`${row.lead} ${row.trail}`).not.toContain('npm test') + } + }) +}) + +describe('a child running a shell in its turn', () => { + // The host records the running shell both as the child's operation and as a live command it owns. + const children = [ + view('child', { + operation: { toolName: 'Bash', input: 'npm run dev', basis: 'open', observedAt: NOW } + }), + { + ...OWNED_SHELL, + state: 'working' as const, + stoppable: false, + firstObservedAt: NOW - MINUTE + } + ] + const childRow: RenderedRow = { + dot: 'Working', + lead: 'Audit the parser', + trail: 'Bash: npm run dev' + } + + it('reads as the child working its shell, with the shell nested beneath it in the strip', () => { + const groups = buildBackgroundTaskGroupsFromViews(children) + expect(groups.map((group) => group.kind)).toEqual(['agent']) + expect(groups[0].tasks.map((entry) => entry.row.owned.map((owned) => owned.id))).toEqual([ + ['shell'] + ]) + expect(stripRows(children)).toEqual([ + childRow, + { dot: 'Working', lead: 'npm run dev', trail: '' } + ]) + }) + + it('shows the sidebar only the child, saying what the strip says of it', () => { + expect(sidebarRows(parentWith(children))).toEqual([childRow]) + }) +}) + +describe('one child reads the same from every shape a host publishes', () => { + it('names and details it identically from views, the subagents snapshot and the task roster', () => { + // A placeholder description falls through to the child's real label on every path. + const children = [view('child', { description: 'task', agentType: 'Explore' })] + const snapshotParent: AgentStatusEntry = { + ...parentWith([]), + children: undefined, + subagents: [ + { + id: 'child', + state: 'working', + startedAt: NOW - 5 * MINUTE, + description: 'task', + agentType: 'Explore' + } + ] + } + const roster: AgentSessionBackgroundTask[] = [ + { id: 'child', kind: 'agent', description: 'task', name: 'Explore', state: 'working' } + ] + const expected: RenderedRow = { dot: 'Working', lead: 'Explore', trail: '' } + expect(sidebarRows(parentWith(children))).toEqual([expected]) + expect(sidebarRows(snapshotParent)).toEqual([expected]) + expect(stripRows(children)).toEqual([expected]) + expect(stripRows(undefined, undefined, roster)).toEqual([expected]) + }) +}) + +describe('a mirrored parent and its children read one silence', () => { + const SKEW = 20 * MINUTE + // The host's clock runs 20 minutes ahead; this machine received its last word 3 minutes ago. + function mirroredParent(overrides: Partial): AgentStatusEntry { + return { + ...parentWith([]), + state: 'working', + updatedAt: NOW - 3 * MINUTE + SKEW, + mirroredEvidenceReceivedAt: NOW - 3 * MINUTE, + ...overrides + } + } + + it('times a snapshot child on the receipt clock the parent decays on', () => { + const parent = mirroredParent({ + children: undefined, + subagents: [ + { id: 'child', state: 'working', startedAt: NOW - 9 * MINUTE, description: 'Audit' } + ] + }) + expect(sidebarRows(parent, false)[0].trail).toBe('No update in 3m') + }) + + it('times a view child by its own host-clock age, never across machines', () => { + const children = [view('child', { observedAt: NOW - 5 * MINUTE + SKEW })] + const parent = mirroredParent({ children }) + expect(sidebarRows(parent, false)[0].trail).toBe('No update in 5m') + expect(stripRows(children, agentChildRowContextForParent(parent, false))[0].trail).toBe( + 'No update in 5m' + ) + const [agent] = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false }) + const full = mount( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + /> + + ) + ) + expect(full.textContent).toContain('No update in 5m') + }) +}) + +describe('a lost or stale parent reads the same on both surfaces', () => { + const children = [ + view('child', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: NOW } + }) + ] + const lost: RenderedRow = { + dot: 'No recent update', + lead: 'Audit the parser', + trail: 'No update in 2m' + } + + it('when the transport to the host is lost', () => { + const parent = { ...parentWith(children), subagentObservation: 'unverifiable' as const } + const [sidebar] = sidebarRows(parent) + const [strip] = stripRows(children, agentChildRowContextForParent(parent, true)) + expect(sidebar).toEqual(lost) + expect(strip).toEqual(lost) + }) + + it('when the parent row has gone stale', () => { + const parent = parentWith([settled('succeeded'), OWNED_SHELL], NOW - 40 * MINUTE) + const [sidebar] = sidebarRows(parent, false) + const [strip] = stripRows(parent.children ?? [], agentChildRowContextForParent(parent, false)) + const stale = { dot: 'No recent update', lead: 'Audit the parser', trail: 'No update in 2m' } + expect(sidebar).toEqual(stale) + expect(strip).toEqual(stale) + }) + + it('without a context the strip reports what the host last said', () => { + expect(stripRows(children)[0].dot).toBe('Working') + }) +}) + +describe('sibling child rows keep their own clocks', () => { + it('reads each sibling from its own evidence, not the parent clock', () => { + const parent = parentWith( + [ + view('busy', { description: 'Busy child', observedAt: NOW - 30_000 }), + view('quiet', { + description: 'Quiet child', + firstObservedAt: NOW - 15 * MINUTE, + observedAt: NOW - 10 * MINUTE + }) + ], + NOW - 40 * MINUTE + ) + const rows = buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh: false }) + expect(rows.map((row) => row.entry.evidenceObservedAt)).toEqual([ + NOW - 30_000, + NOW - 10 * MINUTE + ]) + expect(rows.map((row) => row.startedAt)).toEqual([NOW - 5 * MINUTE, NOW - 15 * MINUTE]) + expect(sidebarRows(parent, false).map((row) => row.trail)).toEqual([ + 'No update in 0m', + 'No update in 10m' + ]) + }) + + it('times a settled child from when it ended in the sidebar, and freezes its run in the strip', () => { + const [agent] = buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded')]), + tab, + parentIsFresh: true + }) + const text = mount( + renderToStaticMarkup( + + {}} /> + + ) + ).textContent + expect(text?.endsWith('3m')).toBe(true) + const strip = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + // Ran from 5m ago until it settled 3m ago; a finished row never ticks. + expect(strip.querySelector('li')?.textContent).toMatch(/2m 0s$/) + }) +}) + +describe('the chat strip from views', () => { + it('nests a child-owned shell under its owner and stops by the provider id', () => { + const children = [ + view('child', { description: 'Dev server owner' }), + view('shell', { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: 'child' + }), + view('main-shell', { + kind: 'command', + description: 'tail -f log', + agentType: undefined, + state: 'monitoring' + }) + ] + const root = mount( + renderToStaticMarkup( + {}} + onStop={() => {}} + /> + ) + ) + const groups = [...root.querySelectorAll('ul[aria-label]')].map((list) => + list.getAttribute('aria-label') + ) + expect(groups).toEqual(['Agents', 'Shell']) + const nested = root.querySelector('ul[aria-label="Agents"] ul') + expect(nested?.textContent).toContain('npm run dev') + expect(root.querySelector('ul[aria-label="Shell"]')?.textContent).not.toContain('npm run dev') + const stopShell = root.querySelector('button[aria-label="Stop npm run dev"]') + expect(stopShell?.hasAttribute('disabled')).toBe(true) + }) +}) + +describe('one lifecycle word for a child, on the sidebar row and the strip header', () => { + it.each<[string, AgentChildWorkView[], string, string]>([ + ['failed', [settled('failed')], 'blocked', 'blocked'], + ['cancelled', [settled('cancelled')], 'idle', 'idle'], + ['ended, outcome unknown', [settled('unknown')], 'idle', 'idle'], + ['finished', [settled('succeeded')], 'done', 'done'], + ['monitoring its own shell', [settled('succeeded'), OWNED_SHELL], 'working', 'monitoring'], + ['waiting', [view('child', { state: 'waiting' })], 'waiting', 'waiting'] + ])('%s', (_name, children, sidebarState, headerState) => { + const [sidebar] = buildSubagentChildRows({ + parentEntry: parentWith(children), + tab, + parentIsFresh: true + }) + const [group] = buildBackgroundTaskGroupsFromViews(children) + // A CLI row carries monitoring as `working` plus its working mode; every other word is shared. + expect(sidebar.state).toBe(sidebarState) + expect(group.tasks[0].state).toBe(headerState) + }) +}) diff --git a/src/renderer/src/components/agent-child-row-text.ts b/src/renderer/src/components/agent-child-row-text.ts new file mode 100644 index 00000000000..96ef7d1d5c7 --- /dev/null +++ b/src/renderer/src/components/agent-child-row-text.ts @@ -0,0 +1,69 @@ +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import { agentStateLabel } from '@/components/AgentStateDot' +import { backgroundTaskStateReason } from '@/components/native-chat/background-task-roster' +import { translate } from '@/i18n/i18n' +import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state' +import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview' + +export type AgentChildRowText = { + /** Leads the line; kept when the row truncates. */ + lead: string + /** Follows the separator; '' when there is nothing more to say. */ + trail: string +} + +/** How long this child has been silent, on the reader's clock the model measured it on. */ +export function agentChildRowNoUpdateLabel(row: AgentChildRowModel, now: number): string { + return agentNoUpdateLabel({ updatedAt: row.recencyAt }, now) +} + +/** The words for a row's detail, reusing the phrasing a CLI agent row uses for the same fact. */ +export function agentChildRowDetailText(row: AgentChildRowModel, now: number): string { + const detail = row.detail + if (!detail) { + return '' + } + switch (detail.kind) { + case 'operation': + return formatAgentToolPreview( + { toolName: detail.toolName, toolInput: detail.input }, + 'working' + ) + case 'monitoring': + return agentStateLabel('monitoring') + case 'message': + return detail.text + case 'ended': + return translate('components.agentChildRow.ended', 'Ended') + case 'no-update': + return agentChildRowNoUpdateLabel(row, now) + case 'role': + return formatAgentTypeLabel(detail.agentType) + case 'reason': + return backgroundTaskStateReason(detail.state) ?? '' + } +} + +/** The line beneath a full-width row: what the child said, or that it ended; '' otherwise. */ +export function agentChildRowMessageLine(row: AgentChildRowModel): string { + if (row.detail?.kind === 'message') { + return row.detail.text + } + return row.detail?.kind === 'ended' ? translate('components.agentChildRow.ended', 'Ended') : '' +} + +/** The row's name, or its state when the child reported none. */ +export function agentChildRowName(row: AgentChildRowModel): string { + return row.name.trim() || agentStateLabel(row.displayState) +} + +export function agentChildRowText(row: AgentChildRowModel, now: number): AgentChildRowText { + const name = agentChildRowName(row) + const detail = agentChildRowDetailText(row, now) + // Why: a monitoring row leads with its state so truncation keeps passive distinct from active. + if (row.displayState === 'monitoring' && detail) { + return { lead: detail, trail: detail === name ? '' : name } + } + return { lead: name, trail: detail } +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 1b64ec6af83..426fe8b57aa 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -9,12 +9,16 @@ import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingCo import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' -import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' +import { + agentChildRowMessageLine, + agentChildRowNoUpdateLabel +} from '@/components/agent-child-row-text' function formatTimeAgo(ts: number, now: number): string { const delta = now - ts @@ -24,6 +28,13 @@ function formatTimeAgo(ts: number, now: number): string { return `${formatCompactDuration(delta)} ago` } +// A child row's silence is the model's, on the clock its compact row and the strip read. +function rowNoUpdateLabel(agent: DashboardAgentRowData, now: number): string { + return agent.childRow + ? agentChildRowNoUpdateLabel(agent.childRow, now) + : agentNoUpdateLabel(agent.entry, now) +} + function stateDotTooltipLabel( agent: DashboardAgentRowData, dotState: AgentDotState, @@ -34,9 +45,7 @@ function stateDotTooltipLabel( } // Why: report the observation, not a verdict on the agent — the elapsed gap is what // lets the user apply context Orca has no way to know (a long build, a slow download). - return dotState === 'unverifiable' - ? agentNoUpdateLabel(agent.entry, now) - : agentStateLabel(dotState) + return dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : agentStateLabel(dotState) } type Props = { @@ -127,7 +136,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const conversationName = useAgentRowConversationName(agent) const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. - const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) + const displayLabel = + prompt || + agentStateLabel( + agent.childRow?.displayState ?? asDotState(agent.state, agent.entry.workingMode) + ) const model = agent.entry.model?.trim() ?? '' const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring' const isWorking = agent.state === 'working' && !isMonitoring @@ -137,9 +150,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' - const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const verdictDotState = agentVerdictDisplayMark(agent.entry) - const isInterrupted = verdictDotState === 'interrupted' + // Why: a child row's message line is the model's, so a child that ended without an outcome says so. + const lastAssistantMessage = agent.childRow + ? agentChildRowMessageLine(agent.childRow) + : (agent.entry.lastAssistantMessage?.trim() ?? '') + const isInterrupted = agentVerdictDisplayMark(agent.entry) === 'interrupted' const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -152,12 +167,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a // failure does so even while subagents still run. - const dotState: AgentDotState = - verdictDotState ?? asDotState(agent.state, agent.entry.workingMode) + const dotState: AgentDotState = agentRowDisplayDotState(agent) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. - const noUpdateLabel = dotState === 'unverifiable' ? agentNoUpdateLabel(agent.entry, now) : null + const noUpdateLabel = dotState === 'unverifiable' ? rowNoUpdateLabel(agent, now) : null // Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks. diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 28d7a2572bb..d176ae0a879 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -12,11 +12,11 @@ import type { DashboardAgentRow } from './useDashboardData' * agent reads "N since it finished", an active one falls through to its start. */ export function lastEnteredDoneAt( - agent: Pick + agent: Pick ): number | null { - // Why: a subagent's synthetic entry may say done while its row is idle or unverifiable. - if (agent.rowSource === 'subagent' && agent.state !== 'done') { - return null + // Why: a subagent has no turns; it ended when its host settled it, if it has. + if (agent.rowSource === 'subagent') { + return agent.childRow?.settledAt ?? null } const entry = agent.entry // Why: same primitive Smart Sort ranks on, so the displayed age and Done eligibility share a clock. diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 02d4010edcd..7ca391ef234 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -1,6 +1,7 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import type { AgentRowState } from '@/lib/agent-row-decay-state' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { AgentChildRowModel } from '../../../../shared/agent-child-row-model' export type DashboardAgentRow = { /** Row identity. For 'subagent' rows this is a synthetic key (the child has @@ -18,6 +19,8 @@ export type DashboardAgentRow = { * stateHistory entry, falling back to updatedAt when no history exists yet. * Used to sort agents by when they started. */ startedAt: number + /** Subagent rows only: what the row shows, decided once for every surface. */ + childRow?: AgentChildRowModel lineage?: { depth: 0 | 1 parentPaneKey?: string diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index 365be31cc47..e37bfa0f9da 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -335,3 +335,47 @@ it('stops elapsed renders in a hidden pane and catches up on reveal', () => { unmount() expect(vi.getTimerCount()).toBe(0) }) + +it('lets the 1 Hz tick sleep while every row has settled, with each run frozen', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + const committed = vi.fn() + const finished = (id: string, firstObservedAt: number, settledAt: number) => ({ + id, + providerId: `task-${id}`, + kind: 'agent' as const, + description: `child ${id}`, + state: 'done' as const, + membership: 'settled' as const, + outcome: 'succeeded' as const, + firstObservedAt, + observedAt: settledAt, + settledAt, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 } + }) + render( + + {}} + isVisible + tasks={[]} + settledTasks={[]} + childViews={[finished('a', 400_000, 520_000), finished('b', 700_000, 760_000)]} + indicatorActive + supportsTaskStop + supportsStopAll + stoppingTaskIds={new Set()} + stoppingAll={false} + onStop={() => {}} + /> + + ) + const rows = screen.getAllByRole('listitem').map((row) => row.textContent) + expect(rows).toEqual(['child a · Agent2m 0s', 'child b · Agent1m 0s']) + committed.mockClear() + act(() => vi.advanceTimersByTime(5_000)) + expect(committed).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index de9ba8c655c..a0ff61a78c8 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -1,18 +1,25 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { Activity, Bot, ChevronDown, CircleHelp, SquareTerminal, Workflow } from 'lucide-react' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' -import { AgentStateDot } from '@/components/AgentStateDot' +import type { + AgentChildRowContext, + AgentChildRowModel +} from '../../../../shared/agent-child-row-model' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { AgentChildRowContent } from '@/components/AgentChildRowContent' +import { agentChildRowName } from '@/components/agent-child-row-text' import { Button } from '@/components/ui/button' import { useNow } from '@/hooks/use-now' import { translate } from '@/i18n/i18n' import { backgroundTasksHeaderContent } from './background-task-header-content' import { - backgroundTaskElapsedLabel, backgroundTaskGroupLabel, - backgroundTaskStateReason, + backgroundTaskRowElapsedLabel, + backgroundTaskRowTicks, buildBackgroundTaskGroups, + buildBackgroundTaskGroupsFromViews, formatBackgroundTaskTokens, - type BackgroundRosterTask + type BackgroundTaskGroup } from './background-task-roster' /** Below this strip width (border-box, live root font size) the header drops @@ -64,78 +71,95 @@ function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean) return dimmed ? `${tone}/40` : tone } -/** Absent means stoppable: a host predating the field published only rows its - * stop could act on, so reading absence as "not stoppable" would hide a - * working control. Only an explicit `false` withholds the button — Claude - * marks its in-turn foreground rows that way, and a Stop on one of those - * resolves to an empty target list and silently reports nothing cancelled. */ -function backgroundTaskStoppable(task: AgentSessionBackgroundTask): boolean { - return task.stoppable !== false -} - function BackgroundTaskRow(props: { - entry: BackgroundRosterTask + row: AgentChildRowModel now: number supportsTaskStop: boolean - stopping: boolean + stoppingTaskIds: ReadonlySet onStop: (taskId: string) => void }): React.JSX.Element { - const { entry, now } = props - const Icon = KIND_ICONS[entry.task.kind] - // Every attention state states its reason on the row, the same ones the collapsed - // header names; `unverifiable` ("no contact") must never be silently dropped. - const reason = backgroundTaskStateReason(entry.state) - // Settled rows keep their final usage but no elapsed — a still-growing clock - // on finished work would lie. + const { row, now } = props + const Icon = KIND_ICONS[row.kind] const meta = [ - entry.task.totalTokens !== undefined - ? formatBackgroundTaskTokens(entry.task.totalTokens) - : null, - entry.settled ? null : backgroundTaskElapsedLabel(entry.task, now) + row.totalTokens !== undefined ? formatBackgroundTaskTokens(row.totalTokens) : null, + backgroundTaskRowElapsedLabel(row, now) ] .filter((part): part is string => part !== null) .join(' · ') + // Only an explicit `false` withholds the button: a Stop on a row the host cannot target + // resolves to an empty list and silently reports nothing cancelled. + const stopId = + !row.settled && props.supportsTaskStop && row.canStop ? (row.providerId ?? null) : null return ( -
  • -
  • +
  • + {row.owned.length > 0 ? ( + // Work a child owns (its shell, a nested agent) reads beneath it, not in its kind's group. +
  • +
      + {row.owned.map((owned) => ( + + ))} +
    +
  • ) : null} - {!entry.settled && props.supportsTaskStop && backgroundTaskStoppable(entry.task) ? ( - - ) : null} - + ) } +function rowsTick(rows: readonly AgentChildRowModel[]): boolean { + return rows.some((row) => backgroundTaskRowTicks(row) || rowsTick(row.owned)) +} + export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] settledTasks: readonly AgentSessionBackgroundTask[] + /** The host's child views, when it publishes them; rows then read these instead of the tasks. */ + childViews?: readonly AgentChildWorkView[] + /** The session's parent-row context for those views, the one its sidebar rows read; without + * it every live claim stands as reported. */ + childRowContext?: AgentChildRowContext supportsTaskStop: boolean /** False when the provider exposes no honest stop at all; the fallback * control is hidden rather than offering a button that cannot act. */ @@ -160,16 +184,18 @@ export function NativeChatBackgroundTasksStatus(props: { const stripRef = useRef(null) const narrow = useNarrowStrip(stripRef) // The 1 Hz elapsed tick must not re-group, re-sort and re-translate the whole roster. - const groups = useMemo( - () => buildBackgroundTaskGroups(props.tasks, props.settledTasks), - [props.tasks, props.settledTasks] + const groups: BackgroundTaskGroup[] = useMemo( + () => + props.childViews !== undefined + ? buildBackgroundTaskGroupsFromViews(props.childViews, props.childRowContext) + : buildBackgroundTaskGroups(props.tasks, props.settledTasks), + [props.childViews, props.childRowContext, props.tasks, props.settledTasks] ) const singleLiveCommand = groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1 - const hasElapsed = groups.some((group) => - group.tasks.some((entry) => !entry.settled && (entry.task.startedAt ?? 0) > 0) - ) - const now = useNow(1_000, props.isVisible && hasElapsed && (expanded || singleLiveCommand)) + // Settled rows are frozen, so a strip of only finished work never wakes the 1 Hz tick. + const ticks = groups.some((group) => rowsTick(group.tasks.map((entry) => entry.row))) + const now = useNow(1_000, props.isVisible && ticks && (expanded || singleLiveCommand)) const header = backgroundTasksHeaderContent(groups, { narrow, now }) const headerText = `${header.segments.map((segment) => segment.text).join(' · ')}${header.detail ? `${header.segments.length > 0 ? ' — ' : ''}${header.detail}` : ''}` return ( @@ -250,11 +276,11 @@ export function NativeChatBackgroundTasksStatus(props: { > {group.tasks.map((entry) => ( ))} diff --git a/src/renderer/src/components/native-chat/background-task-header-content.ts b/src/renderer/src/components/native-chat/background-task-header-content.ts index 0ec1c765e1f..acbd97cb543 100644 --- a/src/renderer/src/components/native-chat/background-task-header-content.ts +++ b/src/renderer/src/components/native-chat/background-task-header-content.ts @@ -166,8 +166,8 @@ export function backgroundTasksHeaderContent( : kindCountLabel(group.kind, 1) // A still-growing clock on finished work would lie, exactly as on the row. const elapsed = - group.kind === 'command' && !entry.settled - ? backgroundTaskElapsedLabel(entry.task, options.now) + group.kind === 'command' && !entry.row.settled + ? backgroundTaskElapsedLabel(entry.row.firstObservedAt, options.now) : null return { segments: [{ text: subject, kind: group.kind }], diff --git a/src/renderer/src/components/native-chat/background-task-roster.test.ts b/src/renderer/src/components/native-chat/background-task-roster.test.ts index eb2232056a7..f4159632c69 100644 --- a/src/renderer/src/components/native-chat/background-task-roster.test.ts +++ b/src/renderer/src/components/native-chat/background-task-roster.test.ts @@ -172,8 +172,8 @@ describe('buildBackgroundTaskGroups', () => { [agent('settled', { state: 'done', startedAt: 0 })] ) expect(built.map((group) => group.kind)).toEqual(['agent', 'monitor']) - expect(built[0].tasks.map((entry) => entry.task.id)).toEqual(['settled', 'early', 'late']) - expect(built[0].tasks[0].settled).toBe(true) + expect(built[0].tasks.map((entry) => entry.row.id)).toEqual(['settled', 'early', 'late']) + expect(built[0].tasks[0].row.settled).toBe(true) }) it('defaults the state slot so a stateless row still reads as work', () => { @@ -234,9 +234,19 @@ describe('resumed tasks from mixed-version hosts', () => { [live, ...shells], [settled, agent('sibling', { state: 'done' })] ) - expect( - groups.flatMap((group) => group.tasks).filter((entry) => entry.task.id === live.id) - ).toEqual([{ task: live, settled: false, state: 'working', name: 'Background agent' }]) + const owners = groups + .flatMap((group) => group.tasks) + .filter((entry) => entry.row.id === live.id) + expect(owners).toHaveLength(1) + expect(owners[0]).toMatchObject({ + row: { + settled: false, + displayState: 'working', + name: 'Background agent', + totalTokens: 20000 + }, + state: 'working' + }) expect(backgroundTasksHeaderContent(groups, { narrow: false, now: NOW }).segments).toEqual([ { text: '2 agents', kind: 'agent' }, { text: '4 shells', kind: 'command' } diff --git a/src/renderer/src/components/native-chat/background-task-roster.ts b/src/renderer/src/components/native-chat/background-task-roster.ts index 4b1c290e944..7b62bb1f57c 100644 --- a/src/renderer/src/components/native-chat/background-task-roster.ts +++ b/src/renderer/src/components/native-chat/background-task-roster.ts @@ -6,6 +6,15 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from '../../../../shared/agent-session-wire' +import { + buildAgentChildRowModels, + buildLegacyTaskRowModels, + usableAgentChildLabel, + type AgentChildRowContext, + type AgentChildRowModel +} from '../../../../shared/agent-child-row-model' +import { agentChildRunStateFor } from '../../../../shared/agent-status-child-work-display' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' import { formatNativeChatDuration } from '../../../../shared/native-chat-turn-status' import { translate } from '@/i18n/i18n' @@ -13,10 +22,10 @@ type TaskKind = AgentSessionBackgroundTask['kind'] type RunState = AgentSessionBackgroundTaskRunState export type BackgroundRosterTask = { - task: AgentSessionBackgroundTask - settled: boolean + /** What the row shows, decided by the same model the sidebar's child rows use. */ + row: AgentChildRowModel + /** The row's display state in the header's vocabulary. */ state: RunState - name: string } export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[] } @@ -24,17 +33,6 @@ export type BackgroundTaskGroup = { kind: TaskKind; tasks: BackgroundRosterTask[ /** Fixed presentation order; groups render only when non-empty. */ const KIND_ORDER: readonly TaskKind[] = ['agent', 'command', 'monitor', 'workflow', 'unknown'] -/** Provider strings that carry no identity; a row falls through to its kind label. */ -const PLACEHOLDER_NAMES = new Set(['unknown', 'untitled', 'task', 'subagent']) - -function usableTaskText(value: string | undefined): string | null { - const trimmed = value?.trim() - if (!trimmed || PLACEHOLDER_NAMES.has(trimmed.toLowerCase())) { - return null - } - return trimmed -} - export function backgroundTaskKindLabel(kind: TaskKind): string { switch (kind) { case 'agent': @@ -50,56 +48,63 @@ export function backgroundTaskKindLabel(kind: TaskKind): string { } } -/** Display name: description → name → kind label. Empty-after-trim and - * placeholder values fall through, so a row always renders something. */ +/** The transcript row's name: description → name → kind label. Empty-after-trim and + * placeholder values fall through, so the row always renders something. */ export function resolveBackgroundTaskName(task: AgentSessionBackgroundTask): string { return ( - usableTaskText(task.description) ?? - usableTaskText(task.name) ?? + usableAgentChildLabel(task.description) ?? + usableAgentChildLabel(task.name) ?? backgroundTaskKindLabel(task.kind) ) } -function effectiveState(task: AgentSessionBackgroundTask, settled: boolean): RunState { - if (task.state) { - return task.state - } - if (settled) { - return 'done' - } - return task.kind === 'monitor' ? 'monitoring' : 'working' +/** Stable-sort first-seen then id, so a live update never reshuffles surviving rows. */ +function groupRosterEntries(entries: BackgroundRosterTask[]): BackgroundTaskGroup[] { + entries.sort((left, right) => { + const startDelta = left.row.firstObservedAt - right.row.firstObservedAt + return startDelta !== 0 ? startDelta : left.row.id < right.row.id ? -1 : 1 + }) + return KIND_ORDER.map((kind) => ({ + kind, + tasks: entries.filter((entry) => entry.row.kind === kind) + })).filter((group) => group.tasks.length > 0) } -/** Merge live and settled tasks into kind groups, stable-sorted first-seen - * (startedAt) then id, so a live update never reshuffles surviving rows. */ +function rosterEntries(rows: readonly AgentChildRowModel[]): BackgroundTaskGroup[] { + return groupRosterEntries( + rows.map((row) => ({ row, state: agentChildRunStateFor(row.displayState) })) + ) +} + +/** Kind groups from a host that publishes only the task roster, live and settled merged. */ export function buildBackgroundTaskGroups( tasks: readonly AgentSessionBackgroundTask[], settledTasks: readonly AgentSessionBackgroundTask[] ): BackgroundTaskGroup[] { - // Older hosts can retain a previous turn beside its resumed live task. - const owners = new Map() - for (const [roster, settled] of [ - [settledTasks, true], - [tasks, false] - ] as const) { - for (const task of roster) { - owners.set(task.id, { - task, - settled, - state: effectiveState(task, settled), - name: resolveBackgroundTaskName(task) - }) - } - } - const entries = [...owners.values()] - entries.sort((left, right) => { - const startDelta = (left.task.startedAt ?? 0) - (right.task.startedAt ?? 0) - return startDelta !== 0 ? startDelta : left.task.id < right.task.id ? -1 : 1 - }) - return KIND_ORDER.map((kind) => ({ - kind, - tasks: entries.filter((entry) => entry.task.kind === kind) - })).filter((group) => group.tasks.length > 0) + // An old host's unlabeled row keeps the kind label it always showed; a view row reads its state. + return rosterEntries( + buildLegacyTaskRowModels(tasks, settledTasks).map((row) => + row.name ? row : { ...row, name: backgroundTaskKindLabel(row.kind) } + ) + ) +} + +// Until a caller passes the session's parent-row context, every live claim stands as reported. +const REPORTED_ROW_CONTEXT: AgentChildRowContext = { + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 0, + hostClockOffsetMs: 0 +} + +/** Kind groups from the host's child views: the main agent's work at the top, each child's own + * work nested beneath it rather than counted again in its kind's group. Pass the context the + * sidebar builds for the same parent (`agentChildRowContextForParent`) so both read one verdict. */ +export function buildBackgroundTaskGroupsFromViews( + views: readonly AgentChildWorkView[], + context: AgentChildRowContext = REPORTED_ROW_CONTEXT +): BackgroundTaskGroup[] { + return rosterEntries(buildAgentChildRowModels(views, context)) } export function backgroundTaskStateWord(state: RunState): string { @@ -155,14 +160,26 @@ export function formatBackgroundTaskTokens(totalTokens: number): string { : `${tokenScaleText(Math.round(totalTokens / 100_000) / 10)}m` } -export function backgroundTaskElapsedLabel( - task: AgentSessionBackgroundTask, - now: number -): string | null { - if (task.startedAt === undefined || task.startedAt <= 0) { +export function backgroundTaskElapsedLabel(startedAt: number, now: number): string | null { + if (startedAt <= 0) { return null } - return formatNativeChatDuration((now - task.startedAt) / 1000) + return formatNativeChatDuration((now - startedAt) / 1000) +} + +/** Whether a row's elapsed time moves: only live work's does. */ +export function backgroundTaskRowTicks(row: AgentChildRowModel): boolean { + return !row.settled && row.firstObservedAt > 0 +} + +/** A live row's clock runs; a settled row's stops where it settled, so finished work never ticks. */ +export function backgroundTaskRowElapsedLabel(row: AgentChildRowModel, now: number): string | null { + if (!row.settled) { + return backgroundTaskElapsedLabel(row.firstObservedAt, now) + } + return row.settledAt !== undefined + ? backgroundTaskElapsedLabel(row.firstObservedAt, row.settledAt) + : null } export function backgroundTaskGroupLabel(kind: TaskKind): string { diff --git a/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap new file mode 100644 index 00000000000..d1c70a5fee2 --- /dev/null +++ b/src/renderer/src/components/sidebar/__snapshots__/subagent-child-row-fallback-pixels.test.tsx.snap @@ -0,0 +1,46 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`chat strip rows from a legacy background-task roster > render exactly as before 1`] = `"

    Agents

    • Failed child · failed
    • Background agent · no contact
    • Cancelled child
    • Approve edits · needs approval
    • Summarize logs900
    • Review the parser18.2k · 10m 0s
    • Explore · failed8m 20s
    • Old host child6m 40s

    Shell

    • npm run dev11m 40s

    Monitors

    • tail -f server.log
    "`; + +exports[`chat strip rows from a legacy background-task roster > render exactly as before 2`] = `"

    Agents

    • Failed child · failed
    • Background agent · no contact
    • Cancelled child
    • Approve edits · needs approval
    • Summarize logs900
    • Review the parser18.2k · 10m 0s
    • Explore · failed8m 20s
    • Old host child6m 40s

    Shell

    • npm run dev11m 40s

    Monitors

    • tail -f server.log
    "`; + +exports[`sidebar child rows from a legacy subagents snapshot > render a stale parent and a lost transport exactly as before 1`] = ` +[ + "
    Review the parser - No update in 5mclaude-sonnet10m
    ", + "
    Explore - No update in 5m8m
    ", + "
    No recent update - No update in 5m16m
    ", + "
    Approve edits - No update in 5m6m
    ", + "
    Review the parser - No update in 5mclaude-sonnet10m
    ", +] +`; + +exports[`sidebar child rows from a legacy subagents snapshot > render the compact rows exactly as before 1`] = ` +[ + "
    Review the parser - general-purposeclaude-sonnet10m
    ", + "
    Explore8m
    ", + "
    Working - Agent16m
    ", + "
    Approve edits - Agent6m
    ", + "
    Rate limited - Agent6m
    ", + "
    Teammate - writer6m
    ", + "
    Lost child - No update in 5m6m
    ", +] +`; + +exports[`sidebar child rows from a legacy subagents snapshot > render the full rows exactly as before 1`] = ` +[ + "
    Review the parserclaude-sonnet
    ", + "
    Explore
    ", + "
    Working
    ", + "
    Approve edits
    ", + "
    Rate limited
    ", + "
    Teammate
    ", + "
    Lost child
    ", +] +`; + +exports[`sidebar child rows from a legacy subagents snapshot > render the full rows exactly as before 2`] = ` +[ + "
    Review the parserclaude-sonnet
    ", + "
    Explore
    ", +] +`; diff --git a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx new file mode 100644 index 00000000000..0be9f5ae546 --- /dev/null +++ b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx @@ -0,0 +1,237 @@ +/** @vitest-environment happy-dom */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' +import type { AgentStatusEntry, AgentSubagentSnapshot } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' +import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' +import { TooltipProvider } from '@/components/ui/tooltip' +import { CompactAgentRow } from './worktree-card-compact-agent-row' +import { buildSubagentChildRows } from './worktree-subagent-child-rows' + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +// Captured on the unmodified renderer: a host that sends only today's legacy shapes (an old host, +// or any CLI pane) must keep rendering exactly these rows. + +const NOW = 1_000_000 + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'wt-1', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +function parentWith( + subagents: AgentSubagentSnapshot[], + overrides: Partial = {} +): AgentStatusEntry { + return { + paneKey: 'parent-tab:leaf-1', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'working', + prompt: 'parent prompt', + updatedAt: 700_000, + stateStartedAt: 10, + stateHistory: [], + subagents, + ...overrides + } +} + +const SUBAGENTS: AgentSubagentSnapshot[] = [ + { + id: 'described', + state: 'working', + startedAt: 400_000, + agentType: 'general-purpose', + model: 'claude-sonnet', + description: 'Review the parser' + }, + { id: 'typed-only', state: 'working', startedAt: 500_000, agentType: 'Explore' }, + { id: 'anonymous', state: 'working', startedAt: 0 }, + { id: 'asking', state: 'waiting', startedAt: 600_000, description: 'Approve edits' }, + { id: 'stuck', state: 'blocked', startedAt: 610_000, description: 'Rate limited' }, + { id: 'parked', state: 'idle', startedAt: 620_000, description: 'Teammate', agentType: 'writer' }, + { id: 'quiet', state: 'unverifiable', startedAt: 630_000, description: 'Lost child' } +] + +function normalize(markup: string): string { + return markup.replaceAll('', '') +} + +function compactMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + normalize( + renderToStaticMarkup( + + {}} /> + + ) + ) + ) +} + +function fullMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] { + return buildSubagentChildRows({ parentEntry: parent, tab, parentIsFresh }).map((agent) => + normalize( + renderToStaticMarkup( + + {}} + onDismiss={() => {}} + stateDotSize="sm" + hideExpand + hideLineageConnectors + /> + + ) + ) + ) +} + +function textOf(markup: string): string { + const container = document.createElement('div') + container.innerHTML = markup + return container.textContent ?? '' +} + +describe('sidebar child rows from a legacy subagents snapshot', () => { + it('render the compact rows exactly as before', () => { + const rows = compactMarkup(parentWith(SUBAGENTS), true) + expect(rows.map(textOf)).toEqual([ + 'Review the parser - general-purposeclaude-sonnet10m', + 'Explore8m', + 'Working - Agent16m', + 'Approve edits - Agent6m', + 'Rate limited - Agent6m', + 'Teammate - writer6m', + 'Lost child - No update in 5m6m' + ]) + expect(rows).toMatchSnapshot() + }) + + it('render a stale parent and a lost transport exactly as before', () => { + const stale = compactMarkup(parentWith(SUBAGENTS.slice(0, 4)), false) + const lost = compactMarkup( + parentWith(SUBAGENTS.slice(0, 1), { subagentObservation: 'unverifiable' }), + true + ) + expect([...stale, ...lost].map(textOf)).toEqual([ + 'Review the parser - No update in 5mclaude-sonnet10m', + 'Explore - No update in 5m8m', + 'No recent update - No update in 5m16m', + 'Approve edits - No update in 5m6m', + 'Review the parser - No update in 5mclaude-sonnet10m' + ]) + expect([...stale, ...lost]).toMatchSnapshot() + }) + + it('render the full rows exactly as before', () => { + expect(fullMarkup(parentWith(SUBAGENTS), true)).toMatchSnapshot() + expect(fullMarkup(parentWith(SUBAGENTS.slice(0, 2)), false)).toMatchSnapshot() + }) +}) + +const TASKS: AgentSessionBackgroundTask[] = [ + { + id: 'agent-working', + kind: 'agent', + description: 'Review the parser', + name: 'general-purpose', + state: 'working', + startedAt: 400_000, + totalTokens: 18_200 + }, + { id: 'agent-waiting', kind: 'agent', description: 'Approve edits', state: 'waiting' }, + { id: 'agent-blocked', kind: 'agent', name: 'Explore', state: 'blocked', startedAt: 500_000 }, + { id: 'agent-quiet', kind: 'agent', description: 'subagent', state: 'unverifiable' }, + { id: 'agent-stateless', kind: 'agent', description: 'Old host child', startedAt: 600_000 }, + { + id: 'command-1', + kind: 'command', + description: 'npm run dev', + startedAt: 300_000, + stoppable: false + }, + { id: 'monitor-1', kind: 'monitor', description: 'tail -f server.log' } +] + +const SETTLED_TASKS: AgentSessionBackgroundTask[] = [ + { + id: 'agent-done', + kind: 'agent', + description: 'Summarize logs', + state: 'done', + startedAt: 100_000, + totalTokens: 900 + }, + { id: 'agent-stopped', kind: 'agent', description: 'Cancelled child', state: 'idle' }, + { id: 'agent-failed', kind: 'agent', description: 'Failed child', state: 'blocked' } +] + +function stripMarkup(supportsTaskStop: boolean): string { + vi.useFakeTimers() + vi.setSystemTime(NOW) + try { + return normalize( + renderToStaticMarkup( + + {}} + onStop={() => {}} + /> + + ) + ) + } finally { + vi.useRealTimers() + } +} + +describe('chat strip rows from a legacy background-task roster', () => { + it('render exactly as before', () => { + const markup = stripMarkup(true) + const container = document.createElement('div') + container.innerHTML = markup + expect([...container.querySelectorAll('li')].map((row) => row.textContent)).toEqual([ + 'Failed child · failed', + 'Background agent · no contactStop', + 'Cancelled child', + 'Approve edits · needs approvalStop', + 'Summarize logs900', + 'Review the parser18.2k · 10m 0sStop', + 'Explore · failed8m 20sStop', + 'Old host child6m 40sStop', + 'npm run dev11m 40s', + 'tail -f server.logStop' + ]) + expect(markup).toMatchSnapshot() + expect(stripMarkup(false)).toMatchSnapshot() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index 7f1215c980a..62a7d54c6d5 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -4,7 +4,11 @@ import { describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row' -import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary' +import { + buildSummaryAgentGroups, + getAgentDotState, + summarizeAgents +} from './worktree-card-agent-summary' import { buildSubagentChildRows } from './worktree-subagent-child-rows' function monitoringAgent(): DashboardAgentRowData { @@ -46,6 +50,30 @@ function renderCompactAgentRow(props: React.ComponentProps { + it('keeps a failed child in the groups and the counts', () => { + const failed: DashboardAgentRowData = { + ...monitoringAgent(), + paneKey: 'tab-1:leaf-1\u0000subagent:child', + rowSource: 'subagent', + state: 'blocked', + childRow: { + id: 'child', + kind: 'agent', + displayState: 'failed', + name: 'Fuzz the tokenizer', + detail: null, + firstObservedAt: 1, + recencyAt: 1, + canStop: false, + settled: true, + owned: [] + } + } + expect(getAgentDotState(failed)).toBe('failed') + expect(buildSummaryAgentGroups([failed]).map((group) => group.state)).toEqual(['failed']) + expect(summarizeAgents([failed], 'Agent')).toBe('Agent failed') + }) + it('presents passive working as monitoring', () => { const agent = monitoringAgent() diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 4ce3f1ad417..76fe81307e9 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -1,8 +1,7 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { formatAgentTypeLabel } from '@/lib/agent-status' -import { agentRowDotState } from '@/lib/agent-row-dot-state' -import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' +import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' export type SummaryAgentGroup = { state: AgentDotState @@ -24,9 +23,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ ] export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { - return ( - agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode) - ) + return agentRowDisplayDotState(agent) } export function formatSummaryStateLabel(state: AgentDotState): string { diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 094cd2e7db9..7ee48834185 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { DashboardAgentChildDisclosure } from '@/components/dashboard/DashboardAgentChildDisclosure' import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { AgentChildRowContent } from '@/components/AgentChildRowContent' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' @@ -183,41 +184,55 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ ) : null + // Why: the selected-row fill is strong enough to wash out the dimmed prompt/secondary text, so + // lift the lead toward full foreground when focused; an unvisited row stays bold either way. + const leadClassName = cn( + isUnvisited ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground/90', + isFocusedPane && !isUnvisited && 'text-foreground' + ) + const rowBody = ( <> - {/* Why: the row's actionable disabled reason must win on every hit area. */} - - {!hideIcon && ( - - - - )} - - {/* Why: the selected-row fill is strong enough to wash out the dimmed - prompt/secondary text, so lift both toward full foreground when focused. */} - + ) : ( + <> + {/* Why: the row's actionable disabled reason must win on every hit area. */} + + {!hideIcon && ( + + + )} - > - {leadingText} - - {trailingText && ( - - {' '} - - {trailingText} + + {leadingText} + {trailingText && ( + + {' '} + - {trailingText} + + )} - )} - + + )} {model && ( 0 ? row.firstObservedAt : parentEntry.stateStartedAt + const paneKey = subagentRowKey(parentEntry.paneKey, row.id) + const detail = row.detail + // The same fields a CLI agent row carries, so every reader of a CLI row reads a child the same way. + const entry: AgentStatusEntry = { + state: state === 'idle' || state === 'unverifiable' ? 'done' : state, + ...(row.displayState === 'monitoring' ? { workingMode: 'monitoring' as const } : {}), + prompt: row.name, + // The parent's delivery clock; the child's own evidence clock rides `evidenceObservedAt`. + updatedAt: parentEntry.updatedAt, + ...(row.observedAt !== undefined ? { evidenceObservedAt: row.observedAt } : {}), + stateStartedAt: startedAt, + agentType: row.agentType, + model: row.model, + ...(detail?.kind === 'operation' + ? { + toolName: detail.toolName, + ...(detail.input !== undefined ? { toolInput: detail.input } : {}) + } + : {}), + ...(detail?.kind === 'message' ? { lastAssistantMessage: detail.text } : {}), + paneKey, + worktreeId: parentEntry.worktreeId, + tabId: parentEntry.tabId, + stateHistory: [], + orchestration: { + taskId: `subagent:${row.id}`, + dispatchId: `subagent:${row.id}`, + displayName: row.name || undefined, + parentPaneKey: parentEntry.paneKey + } + } + return { + paneKey, + entry, + tab, + agentType: row.agentType ?? 'unknown', + rowSource: 'subagent', + state, + activationPaneKey: parentEntry.paneKey, + startedAt, + childRow: row + } +} + /** - * Derive indented child rows for the live in-process subagents/teammates a - * pane's agent has spawned (entry.subagents, reported via agent hooks). These - * children have no PTY or tab of their own: the rows reuse the parent's tab, - * activate the parent's pane, and link into the existing lineage tree through - * `orchestration.parentPaneKey`. + * Derive indented child rows for the subagents/teammates a pane's agent has + * spawned. These children have no PTY or tab of their own: the rows reuse the + * parent's tab, activate the parent's pane, and link into the existing lineage + * tree through `orchestration.parentPaneKey`. The host's child views win; a host + * that sends only the legacy `subagents` snapshot (an old host, any CLI pane) + * is read as before. */ export function buildSubagentChildRows(args: { parentEntry: AgentStatusEntry @@ -25,48 +95,14 @@ export function buildSubagentChildRows(args: { * states are equally unverifiable. */ parentIsFresh: boolean }): DashboardAgentRow[] { - const subagents = args.parentEntry.subagents - if (!subagents || subagents.length === 0) { - return [] - } - return subagents.map((subagent) => { - const freshness = resolveAgentChildWorkFreshness({ - state: subagent.state, - membership: 'live', - parentEvidenceFresh: args.parentIsFresh, - transportObservation: args.parentEntry.subagentObservation ?? 'live' - }) - const state = freshness === 'done' ? 'idle' : freshness === 'monitoring' ? 'working' : freshness - const activeState = state !== 'idle' && state !== 'unverifiable' ? state : undefined - const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt - const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id) - const entry: AgentStatusEntry = { - state: activeState ?? 'done', - prompt: subagent.description ?? subagent.agentType ?? '', - updatedAt: args.parentEntry.updatedAt, - stateStartedAt: startedAt, - agentType: subagent.agentType, - model: subagent.model, - paneKey, - worktreeId: args.parentEntry.worktreeId, - tabId: args.parentEntry.tabId, - stateHistory: [], - orchestration: { - taskId: `subagent:${subagent.id}`, - dispatchId: `subagent:${subagent.id}`, - displayName: subagent.description, - parentPaneKey: args.parentEntry.paneKey - } - } - return { - paneKey, - entry, - tab: args.tab, - agentType: subagent.agentType ?? 'unknown', - rowSource: 'subagent' as const, - state, - activationPaneKey: args.parentEntry.paneKey, - startedAt - } - }) + const { parentEntry } = args + const context = agentChildRowContextForParent(parentEntry, args.parentIsFresh) + const rows = + parentEntry.children !== undefined + ? buildAgentChildRowModels(parentEntry.children, context) + : buildLegacyAgentChildRowModels(parentEntry.subagents ?? [], context) + // Shells and monitors show through their owner's dot; the sidebar lists agents. + return flattenAgentChildRowModels(rows) + .filter((row) => row.kind === 'agent') + .map((row) => childDashboardRow(row, parentEntry, args.tab)) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9351961c28a..5db365466e6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18197,6 +18197,9 @@ } } } + }, + "agentChildRow": { + "ended": "Ended" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e0e77e574ec..79070fc23f5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15171,6 +15171,9 @@ "copySessionIdSuccess": "ID de sesión copiado", "copySessionIdError": "No se pudo copiar el ID de sesión" } + }, + "agentChildRow": { + "ended": "Finalizado" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9a0f0d437c9..04e9bb284ab 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18169,6 +18169,9 @@ } } } + }, + "agentChildRow": { + "ended": "Terminé" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6ec0e9c65e7..90f55805342 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18070,6 +18070,9 @@ } } } + }, + "agentChildRow": { + "ended": "終了" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e5d5cac60fc..dd0dbdfa8a7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18070,6 +18070,9 @@ } } } + }, + "agentChildRow": { + "ended": "종료됨" } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d8a7ebf4561..1514dd25697 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18070,6 +18070,9 @@ } } } + }, + "agentChildRow": { + "ended": "已结束" } }, "dashboardPopout": { diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts index 79c0d7c198c..bf2b7b1e532 100644 --- a/src/renderer/src/lib/agent-row-dot-state.ts +++ b/src/renderer/src/lib/agent-row-dot-state.ts @@ -1,5 +1,7 @@ import type { AgentDotState } from '@/components/AgentStateDot' -import type { AgentWorkingMode } from '../../../shared/agent-status-types' +import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import type { AgentStatusEntry, AgentWorkingMode } from '../../../shared/agent-status-types' import type { AgentRowState } from './agent-row-decay-state' /** @@ -22,3 +24,20 @@ export function agentRowDotState( } return 'idle' } + +/** + * The dot an agent row renders: a child row's own, else the main agent's verdict (a stop or a + * failure, which a failure shows even while subagents still run), else its state. + */ +export function agentRowDisplayDotState(agent: { + state: AgentRowState + entry: Pick + childRow?: Pick +}): AgentDotState { + if (agent.childRow) { + return agent.childRow.displayState + } + return ( + agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode) + ) +} diff --git a/src/shared/agent-child-row-model.test.ts b/src/shared/agent-child-row-model.test.ts new file mode 100644 index 00000000000..a99ef0a7152 --- /dev/null +++ b/src/shared/agent-child-row-model.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest' +import { + agentChildRowContextForParent, + buildAgentChildRowModels, + buildLegacyAgentChildRowModels, + buildLegacyTaskRowModels, + flattenAgentChildRowModels, + type AgentChildRowContext, + type AgentChildRowModel +} from './agent-child-row-model' +import type { AgentChildWorkView } from './agent-status-child-work-view' + +const FRESH: AgentChildRowContext = { + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 900, + hostClockOffsetMs: 0 +} +const STALE: AgentChildRowContext = { ...FRESH, parentEvidenceFresh: false } +const LOST: AgentChildRowContext = { ...FRESH, transportObservation: 'unverifiable' } + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: `child ${id}`, + agentType: 'general-purpose', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 500, + stoppable: true, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...overrides + } +} + +function settled( + id: string, + outcome: NonNullable, + overrides: Partial = {} +): AgentChildWorkView { + return view(id, { + state: 'done', + membership: 'settled', + outcome, + settledAt: 450, + ...overrides + }) +} + +function shell(id: string, owner: string, overrides: Partial = {}) { + return view(id, { + kind: 'command', + description: 'npm run dev', + agentType: undefined, + state: 'monitoring', + parentChildWorkId: owner, + ...overrides + }) +} + +function only(views: AgentChildWorkView[], context = FRESH): AgentChildRowModel { + const [row] = buildAgentChildRowModels(views, context) + return row +} + +describe('buildAgentChildRowModels: the display vocabulary', () => { + it.each<[string, AgentChildWorkView[], Pick]>([ + [ + 'working with no known operation names its role', + [view('a')], + { displayState: 'working', detail: { kind: 'role', agentType: 'general-purpose' } } + ], + [ + 'working with a foreground tool names the tool the way a CLI row does', + [view('a', { operation: { toolName: 'Read', basis: 'open', observedAt: 400 } })], + { displayState: 'working', detail: { kind: 'operation', toolName: 'Read' } } + ], + [ + 'a foreground shell reads as working with its command', + [ + view('a', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: 400 } + }) + ], + { + displayState: 'working', + detail: { kind: 'operation', toolName: 'Bash', input: 'npm test' } + } + ], + [ + 'an idle child whose own shell runs reads monitoring', + [view('a', { state: 'idle' }), shell('s', 'a')], + { displayState: 'monitoring', detail: { kind: 'monitoring' } } + ], + [ + 'a finished child whose own shell runs reads monitoring', + [settled('a', 'succeeded', { lastMessage: 'All green' }), shell('s', 'a')], + { displayState: 'monitoring', detail: { kind: 'monitoring' } } + ], + [ + 'waiting names the tool the approval is for', + [ + view('a', { + state: 'waiting', + operation: { toolName: 'Edit', input: 'src/a.ts', basis: 'open', observedAt: 400 } + }) + ], + { + displayState: 'waiting', + detail: { kind: 'operation', toolName: 'Edit', input: 'src/a.ts' } + } + ], + [ + 'blocked says why', + [view('a', { state: 'blocked', lastMessage: 'Rate limited' })], + { displayState: 'blocked', detail: { kind: 'message', text: 'Rate limited' } } + ], + [ + 'succeeded reads done with its last message', + [settled('a', 'succeeded', { lastMessage: 'Found 3 call sites' })], + { displayState: 'done', detail: { kind: 'message', text: 'Found 3 call sites' } } + ], + [ + 'failed reads failed with its error', + [settled('a', 'failed', { lastMessage: 'Exit code 1' })], + { displayState: 'failed', detail: { kind: 'message', text: 'Exit code 1' } } + ], + [ + 'cancelled reads interrupted and adds no message', + [settled('a', 'cancelled', { lastMessage: 'half a thought' })], + { displayState: 'interrupted', detail: { kind: 'role', agentType: 'general-purpose' } } + ], + [ + 'an ending the lane cannot classify is neutral and says Ended', + [settled('a', 'unknown')], + { displayState: 'idle', detail: { kind: 'ended' } } + ], + [ + 'a parked live child names its role', + [view('a', { state: 'idle' })], + { displayState: 'idle', detail: { kind: 'role', agentType: 'general-purpose' } } + ], + [ + 'a host-reported unverifiable child reports its silence', + [view('a', { state: 'unverifiable' })], + { displayState: 'unverifiable', detail: { kind: 'no-update' } } + ] + ])('%s', (_name, views, expected) => { + expect(only(views)).toMatchObject(expected) + }) + + it('suppresses the tool line while monitoring, even when a stale operation arrives', () => { + const row = only([ + view('a', { + state: 'idle', + operation: { toolName: 'Bash', input: 'npm test', basis: 'reported', observedAt: 400 } + }), + shell('s', 'a') + ]) + expect(row.displayState).toBe('monitoring') + expect(row.detail).toEqual({ kind: 'monitoring' }) + }) + + it('makes every live claim unverifiable under a stale parent or a lost transport', () => { + for (const context of [STALE, LOST]) { + expect(only([view('a')], context)).toMatchObject({ + displayState: 'unverifiable', + detail: { kind: 'no-update' } + }) + // A finished child's monitoring is a claim about live work; its outcome is history. + expect(only([settled('a', 'succeeded'), shell('s', 'a')], context).displayState).toBe( + 'unverifiable' + ) + expect(only([settled('a', 'failed')], context).displayState).toBe('failed') + expect(only([view('a', { state: 'idle' })], context).displayState).toBe('idle') + } + }) + + it('gives sibling rows their own clocks, never the parent one', () => { + const [busy, quiet] = buildAgentChildRowModels( + [view('busy', { observedAt: 880 }), view('quiet', { firstObservedAt: 200, observedAt: 300 })], + STALE + ) + expect([busy.recencyAt, quiet.recencyAt]).toEqual([880, 300]) + expect([busy.observedAt, quiet.observedAt]).toEqual([880, 300]) + expect([busy.firstObservedAt, quiet.firstObservedAt]).toEqual([100, 200]) + }) + + it('nests owned work under its owner and keeps host order', () => { + const rows = buildAgentChildRowModels( + [ + view('a'), + shell('s', 'a', { state: 'working' }), + view('b'), + view('grandchild', { parentChildWorkId: 'a' }) + ], + FRESH + ) + expect(rows.map((row) => row.id)).toEqual(['a', 'b']) + expect(rows[0].owned.map((row) => row.id)).toEqual(['s', 'grandchild']) + expect(flattenAgentChildRowModels(rows).map((row) => row.id)).toEqual([ + 'a', + 's', + 'grandchild', + 'b' + ]) + // A shell names itself; only an agent's own tool line can go stale. + expect(rows[0].owned[0]).toMatchObject({ displayState: 'working', detail: null }) + }) + + it('names a row by its label and omits a role that restates it', () => { + expect( + only([view('a', { description: ' ', name: 'subagent', agentType: 'Explore' })]) + ).toMatchObject({ name: 'Explore', detail: null }) + expect(only([view('a', { description: undefined, agentType: undefined })])).toMatchObject({ + name: '', + detail: { kind: 'role', agentType: 'unknown' } + }) + }) + + it('offers a stop only for a live row the host can target', () => { + expect(only([view('a')]).canStop).toBe(true) + expect(only([view('a', { providerId: undefined })]).canStop).toBe(false) + expect(only([view('a', { stoppable: false })]).canStop).toBe(false) + expect(only([settled('a', 'succeeded')]).canStop).toBe(false) + }) +}) + +describe('buildLegacyAgentChildRowModels', () => { + it('reads a legacy snapshot as before: no clock of its own, no outcome, no operation', () => { + const rows = buildLegacyAgentChildRowModels( + [ + { id: 'w', state: 'working', startedAt: 10, description: 'Review', agentType: 'Explore' }, + { id: 'i', state: 'idle', startedAt: 20, agentType: 'writer' }, + { id: 'u', state: 'unverifiable', startedAt: 30 } + ], + FRESH + ) + expect(rows).toEqual([ + expect.objectContaining({ + id: 'w', + providerId: 'w', + displayState: 'working', + name: 'Review', + detail: { kind: 'role', agentType: 'Explore' }, + firstObservedAt: 10, + recencyAt: 900, + settled: false, + canStop: false + }), + expect.objectContaining({ id: 'i', displayState: 'idle', name: 'writer', detail: null }), + expect.objectContaining({ + id: 'u', + displayState: 'unverifiable', + detail: { kind: 'no-update' } + }) + ]) + expect(rows.every((row) => row.observedAt === undefined)).toBe(true) + }) + + it('decays live legacy states under a stale parent, as before', () => { + const rows = buildLegacyAgentChildRowModels( + [ + { id: 'w', state: 'working', startedAt: 10 }, + { id: 'q', state: 'waiting', startedAt: 10 }, + { id: 'i', state: 'idle', startedAt: 10 } + ], + STALE + ) + expect(rows.map((row) => row.displayState)).toEqual(['unverifiable', 'unverifiable', 'idle']) + }) +}) + +describe('buildLegacyTaskRowModels', () => { + it('keeps the state the host decided and says only its reason', () => { + const rows = buildLegacyTaskRowModels( + [ + { id: 'w', kind: 'agent', description: 'Review', state: 'working', startedAt: 10 }, + { id: 'q', kind: 'agent', description: 'Approve', state: 'waiting' }, + { id: 'u', kind: 'agent', description: 'Lost', state: 'unverifiable' }, + { id: 'm', kind: 'monitor', description: 'tail -f log' }, + { id: 'c', kind: 'command', description: 'npm test', stoppable: false } + ], + [{ id: 'd', kind: 'agent', description: 'Done', totalTokens: 900 }] + ) + expect(rows.map((row) => [row.id, row.displayState, row.detail])).toEqual([ + ['d', 'done', null], + ['w', 'working', null], + ['q', 'waiting', { kind: 'reason', state: 'waiting' }], + ['u', 'unverifiable', { kind: 'reason', state: 'unverifiable' }], + ['m', 'monitoring', null], + ['c', 'working', null] + ]) + expect(rows.map((row) => row.canStop)).toEqual([true, true, true, true, true, false]) + expect(rows[0]).toMatchObject({ settled: true, totalTokens: 900 }) + }) + + it('lets a resumed live task replace its retained settled row', () => { + const rows = buildLegacyTaskRowModels( + [{ id: 'r', kind: 'agent', state: 'working' }], + [{ id: 'r', kind: 'agent', state: 'done' }] + ) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ settled: false, displayState: 'working' }) + }) +}) + +describe('one label rule for every shape a host publishes', () => { + it.each(['task', ' Subagent ', 'unknown', ' '])( + 'skips the placeholder %j on all three', + (label) => { + const [fromView] = buildAgentChildRowModels( + [view('a', { description: label, agentType: 'Explore' })], + FRESH + ) + const [fromSnapshot] = buildLegacyAgentChildRowModels( + [{ id: 'a', state: 'working', startedAt: 10, description: label, agentType: 'Explore' }], + FRESH + ) + const [fromRoster] = buildLegacyTaskRowModels( + [{ id: 'a', kind: 'agent', state: 'working', description: label, name: 'Explore' }], + [] + ) + expect([fromView.name, fromSnapshot.name, fromRoster.name]).toEqual([ + 'Explore', + 'Explore', + 'Explore' + ]) + } + ) + + it('leaves a row with no usable label unnamed on all three', () => { + const [fromSnapshot] = buildLegacyAgentChildRowModels( + [{ id: 'a', state: 'working', startedAt: 10, description: 'task' }], + FRESH + ) + const [fromRoster] = buildLegacyTaskRowModels( + [{ id: 'a', kind: 'agent', state: 'working', description: 'task' }], + [] + ) + expect([only([view('a', { description: 'task', agentType: undefined })]).name]).toEqual(['']) + expect([fromSnapshot.name, fromRoster.name]).toEqual(['', '']) + }) +}) + +describe('agentChildRowContextForParent: the reader clock', () => { + const MIRRORED_PARENT = { + // The host's clock runs 20 minutes ahead of this machine's. + updatedAt: 1_200_000 + 20 * 60_000, + evidenceObservedAt: 1_190_000 + 20 * 60_000, + mirroredEvidenceReceivedAt: 1_190_000 + } + + it("measures a mirrored parent on this machine's receipt clock, never the host's", () => { + const context = agentChildRowContextForParent(MIRRORED_PARENT, false) + expect(context).toMatchObject({ parentObservedAt: 1_190_000, hostClockOffsetMs: -20 * 60_000 }) + const [legacy] = buildLegacyAgentChildRowModels( + [{ id: 'a', state: 'working', startedAt: 10 }], + context + ) + expect(legacy.recencyAt).toBe(1_190_000) + // A view's own host stamp moves onto the reader clock; its host-clock age is kept. + const [child] = buildAgentChildRowModels( + [view('a', { observedAt: 1_100_000 + 20 * 60_000 })], + context + ) + expect(child.recencyAt).toBe(1_100_000) + expect(child.observedAt).toBe(1_100_000 + 20 * 60_000) + }) + + it('leaves a parent observed on this machine on its own clock', () => { + expect( + agentChildRowContextForParent({ updatedAt: 500, evidenceObservedAt: 400 }, true) + ).toEqual({ + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 400, + hostClockOffsetMs: 0 + }) + }) +}) diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts new file mode 100644 index 00000000000..8e1cf606eea --- /dev/null +++ b/src/shared/agent-child-row-model.ts @@ -0,0 +1,376 @@ +/** + * One row model for every surface that lists child work (the sidebar's child rows and the chat + * strip), with one builder per shape a host may publish. All three name a row with one label rule + * (`usableAgentChildLabel`) and decide what it says with one detail rule (`agentChildRowDetail`): + * - `buildAgentChildRowModels`: the host's child views (`children`), the source of record. + * - `buildLegacyAgentChildRowModels`: the `subagents` snapshot. + * - `buildLegacyTaskRowModels`: the session feed's `tasks` / `settledTasks` roster. + * + * The two legacy builders exist only for hosts that publish no views, and each is deleted, not + * migrated, when its last publisher is gone: the snapshot builder once CLI panes publish + * `children` and no supported paired host predates that; the roster builder once no supported + * paired host predates child views on the session feed. + */ +import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' +import type { AgentChildWorkKind } from './agent-status-child-work' +import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness' +import { + agentChildWorkOwnedLiveness, + deriveAgentChildDisplayState, + type AgentChildDisplayState +} from './agent-status-child-work-display' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import { + agentStatusAuthorityObservedAt, + agentStatusEvidenceObservedAt +} from './agent-status-freshness' +import type { AgentStatusEntry, AgentSubagentSnapshot } from './agent-status-types' + +/** What a child row says beside its name. Surfaces format it; they never decide it. */ +export type AgentChildRowDetail = + /** The tool the child is running now, as `tool: input` — the line a CLI agent row shows. */ + | { kind: 'operation'; toolName: string; input?: string } + /** Its own work is over and only work it owns still runs; the tool line is stale. */ + | { kind: 'monitoring' } + | { kind: 'message'; text: string } + /** Settled, and the lane cannot say how. */ + | { kind: 'ended' } + /** How long since the last evidence for this child (see `recencyAt`). */ + | { kind: 'no-update' } + /** The child's role, when nothing more specific is known. */ + | { kind: 'role'; agentType: string } + /** A host that publishes only a run state: that state's reason word. */ + | { kind: 'reason'; state: 'waiting' | 'blocked' | 'unverifiable' } + +/** One child row, for every surface that lists child work (sidebar and chat strip). */ +export type AgentChildRowModel = { + /** Row identity: the host's child id, or the provider id an old host names it by. */ + id: string + /** The id a targeted stop names; absent when the host holds none. */ + providerId?: string + kind: AgentChildWorkKind + displayState: AgentChildDisplayState + /** '' when the child reported no label; a surface then names the row by its state. */ + name: string + agentType?: string + model?: string + detail: AgentChildRowDetail | null + /** When the child was first seen: the elapsed anchor. */ + firstObservedAt: number + /** Last evidence for THIS child. Absent from a host that reports none. */ + observedAt?: number + /** The reader-clock time a "no update" reading measures from: the child's own evidence, else + * its parent's. */ + recencyAt: number + settledAt?: number + totalTokens?: number + canStop: boolean + settled: boolean + /** Work this child owns (a nested agent, a shell it launched), in host order. */ + owned: AgentChildRowModel[] +} + +export type AgentChildRowContext = { + /** A stale parent makes every live claim beneath it unverifiable. */ + parentEvidenceFresh: boolean + transportObservation: 'live' | 'unverifiable' + /** The parent's last evidence on the READER's clock (its receipt time when mirrored from another + * host): recency for a child whose host reports no clock of its own. */ + parentObservedAt: number + /** Reader clock minus the host's, at that evidence; 0 for a parent observed on this machine. Moves + * a child's host-stamped clock onto the reader's, so no age subtracts across two machines. */ + hostClockOffsetMs: number +} + +/** The context a parent row gives its children. Every surface that lists one parent's children + * builds it here, from the same parent row, so a lost child reads the same everywhere. */ +export function agentChildRowContextForParent( + parent: Pick< + AgentStatusEntry, + 'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'subagentObservation' + >, + parentEvidenceFresh: boolean +): AgentChildRowContext { + // The parent row decays on this same clock, so a child's silence reads the same as its parent's. + const parentObservedAt = agentStatusEvidenceObservedAt(parent) + return { + parentEvidenceFresh, + transportObservation: parent.subagentObservation ?? 'live', + parentObservedAt, + hostClockOffsetMs: parentObservedAt - agentStatusAuthorityObservedAt(parent) + } +} + +/** Provider strings that carry no identity; the next label wins. */ +const PLACEHOLDER_LABELS = new Set(['unknown', 'untitled', 'task', 'subagent']) + +/** A provider label that names the child, or undefined for an empty or placeholder one. */ +export function usableAgentChildLabel(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && !PLACEHOLDER_LABELS.has(trimmed.toLowerCase()) ? trimmed : undefined +} + +// A live claim beneath a parent Orca no longer hears from asserts nothing; settled history stands. +function withFreshness( + displayState: AgentChildDisplayState, + context: AgentChildRowContext +): AgentChildDisplayState { + if ( + displayState === 'done' || + displayState === 'failed' || + displayState === 'interrupted' || + displayState === 'idle' + ) { + return displayState + } + return resolveAgentChildWorkFreshness({ + state: displayState, + membership: 'live', + parentEvidenceFresh: context.parentEvidenceFresh, + transportObservation: context.transportObservation + }) +} + +type AgentChildRowDetailSource = { + /** `run-state`: the host reported only the child's run state (the legacy task roster). */ + evidence: 'child' | 'run-state' + kind: AgentChildWorkKind + name: string + agentType?: string + operation?: { toolName: string; input?: string } + lastMessage?: string + settled: boolean +} + +// A role that restates the name adds nothing. +function roleDetail(source: AgentChildRowDetailSource): AgentChildRowDetail | null { + if (source.kind !== 'agent') { + return null + } + const agentType = source.agentType ?? 'unknown' + return source.name.trim() === agentType.trim() ? null : { kind: 'role', agentType } +} + +function messageOrRole(source: AgentChildRowDetailSource): AgentChildRowDetail | null { + const text = source.lastMessage?.trim() + return text ? { kind: 'message', text } : roleDetail(source) +} + +/** The one rule for what a child row says, in the order a CLI agent row decides it. */ +function agentChildRowDetail( + source: AgentChildRowDetailSource, + displayState: AgentChildDisplayState +): AgentChildRowDetail | null { + if (source.evidence === 'run-state') { + return displayState === 'waiting' || + displayState === 'blocked' || + displayState === 'unverifiable' + ? { kind: 'reason', state: displayState } + : null + } + switch (displayState) { + case 'unverifiable': + return { kind: 'no-update' } + case 'monitoring': + // A shell or monitor names itself; only an agent's own tool line goes stale. + return source.kind === 'agent' ? { kind: 'monitoring' } : null + case 'working': + case 'waiting': + return source.operation + ? { + kind: 'operation', + toolName: source.operation.toolName, + ...(source.operation.input !== undefined ? { input: source.operation.input } : {}) + } + : messageOrRole(source) + case 'blocked': + case 'done': + case 'failed': + return messageOrRole(source) + case 'interrupted': + return roleDetail(source) + case 'idle': + return source.settled ? { kind: 'ended' } : roleDetail(source) + } +} + +function viewName(view: AgentChildWorkView): string { + return ( + usableAgentChildLabel(view.description) ?? + usableAgentChildLabel(view.name) ?? + usableAgentChildLabel(view.agentType) ?? + '' + ) +} + +function rowFromView( + view: AgentChildWorkView, + views: readonly AgentChildWorkView[], + ownedByOwner: ReadonlyMap, + context: AgentChildRowContext, + path: ReadonlySet +): AgentChildRowModel { + const displayState = withFreshness( + deriveAgentChildDisplayState(view, agentChildWorkOwnedLiveness(views, view.id)), + context + ) + const settled = view.membership === 'settled' + const name = viewName(view) + const nextPath = new Set(path).add(view.id) + return { + id: view.id, + ...(view.providerId !== undefined ? { providerId: view.providerId } : {}), + kind: view.kind, + displayState, + name, + ...(view.agentType !== undefined ? { agentType: view.agentType } : {}), + ...(view.model !== undefined ? { model: view.model } : {}), + detail: agentChildRowDetail( + { + evidence: 'child', + kind: view.kind, + name, + agentType: view.agentType, + operation: view.operation, + lastMessage: view.lastMessage, + settled + }, + displayState + ), + firstObservedAt: view.firstObservedAt, + observedAt: view.observedAt, + recencyAt: view.observedAt + context.hostClockOffsetMs, + ...(view.settledAt !== undefined ? { settledAt: view.settledAt } : {}), + ...(view.totalTokens !== undefined ? { totalTokens: view.totalTokens } : {}), + canStop: !settled && view.stoppable && view.providerId !== undefined, + settled, + owned: (ownedByOwner.get(view.id) ?? []) + .filter((owned) => !nextPath.has(owned.id)) + .map((owned) => rowFromView(owned, views, ownedByOwner, context, nextPath)) + } +} + +/** + * Child rows from the host's views: the main agent's children at the top, and everything a child + * owns nested beneath it. Display state comes from the shared child fold, so a finished subagent + * whose shell still runs reads `monitoring` exactly as a CLI agent does. + */ +export function buildAgentChildRowModels( + views: readonly AgentChildWorkView[], + context: AgentChildRowContext +): AgentChildRowModel[] { + const ownedByOwner = new Map() + for (const view of views) { + if (view.parentChildWorkId !== undefined) { + ownedByOwner.set(view.parentChildWorkId, [ + ...(ownedByOwner.get(view.parentChildWorkId) ?? []), + view + ]) + } + } + return views + .filter((view) => view.parentChildWorkId === undefined) + .map((view) => rowFromView(view, views, ownedByOwner, context, new Set())) +} + +/** + * Child rows from a host that sends only the legacy `subagents` snapshot (an old host, or any CLI + * pane): live agents with a lifecycle state and a spawn time, and no clock of their own. + */ +export function buildLegacyAgentChildRowModels( + subagents: readonly AgentSubagentSnapshot[], + context: AgentChildRowContext +): AgentChildRowModel[] { + return subagents.map((subagent) => { + const displayState = withFreshness( + deriveAgentChildDisplayState({ state: subagent.state, membership: 'live' }, null), + context + ) + const name = + usableAgentChildLabel(subagent.description) ?? usableAgentChildLabel(subagent.agentType) ?? '' + return { + id: subagent.id, + providerId: subagent.id, + kind: 'agent', + displayState, + name, + ...(subagent.agentType !== undefined ? { agentType: subagent.agentType } : {}), + ...(subagent.model !== undefined ? { model: subagent.model } : {}), + detail: agentChildRowDetail( + { evidence: 'child', kind: 'agent', name, agentType: subagent.agentType, settled: false }, + displayState + ), + firstObservedAt: subagent.startedAt, + recencyAt: context.parentObservedAt, + canStop: false, + settled: false, + owned: [] + } + }) +} + +function legacyTaskDisplayState( + task: AgentSessionBackgroundTask, + settled: boolean +): AgentChildDisplayState { + if (task.state) { + return task.state + } + if (settled) { + return 'done' + } + return task.kind === 'monitor' ? 'monitoring' : 'working' +} + +// The host decided each row's state; the row keeps it. +function legacyTaskRow(task: AgentSessionBackgroundTask, settled: boolean): AgentChildRowModel { + const displayState = legacyTaskDisplayState(task, settled) + const name = usableAgentChildLabel(task.description) ?? usableAgentChildLabel(task.name) ?? '' + const startedAt = task.startedAt ?? 0 + return { + id: task.id, + providerId: task.id, + kind: task.kind, + displayState, + name, + detail: agentChildRowDetail( + { evidence: 'run-state', kind: task.kind, name, settled }, + displayState + ), + firstObservedAt: startedAt, + recencyAt: startedAt, + ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}), + // Absent means stoppable: a host predating the field published only rows its stop could act on. + canStop: task.stoppable !== false, + settled, + owned: [] + } +} + +/** + * Rows from a host that publishes only the session feed's task roster (a host predating child + * views): flat, each with the run state the host decided and no clock beyond its start. + */ +export function buildLegacyTaskRowModels( + tasks: readonly AgentSessionBackgroundTask[], + settledTasks: readonly AgentSessionBackgroundTask[] +): AgentChildRowModel[] { + // Older hosts can retain a previous turn beside its resumed live task; the live row wins. + const rows = new Map() + for (const [roster, settled] of [ + [settledTasks, true], + [tasks, false] + ] as const) { + for (const task of roster) { + rows.set(task.id, legacyTaskRow(task, settled)) + } + } + return [...rows.values()] +} + +/** Every row of a tree, owners before what they own. */ +export function flattenAgentChildRowModels( + rows: readonly AgentChildRowModel[] +): AgentChildRowModel[] { + return rows.flatMap((row) => [row, ...flattenAgentChildRowModels(row.owned)]) +} diff --git a/src/shared/agent-status-child-work-display.ts b/src/shared/agent-status-child-work-display.ts new file mode 100644 index 00000000000..c83957199f9 --- /dev/null +++ b/src/shared/agent-status-child-work-display.ts @@ -0,0 +1,114 @@ +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import type { AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' +import type { AgentChildWorkId, AgentChildWorkOutcome } from './agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import type { AgentStatusState } from './agent-status-types' +import { groupedBy } from './grouped-by' + +// What a surface derives from child views. Kept apart from the projection, which resolves owners +// against status subjects and so reaches host-only code: every surface, renderer included, loads +// this module. + +type AgentChildWorkOwnershipView = Pick< + AgentChildWorkView, + 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId' +> + +/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */ +export function agentChildWorkOwnedLiveness( + views: readonly AgentChildWorkOwnershipView[], + ownerId: AgentChildWorkId +): AgentChildWorkLiveness { + const owned = groupedBy(views, (view) => view.parentChildWorkId) + const seen = new Set([ownerId]) + const frontier = [ownerId] + const liveDescendants: AgentChildWorkOwnershipView[] = [] + for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) { + for (const view of owned.get(owner) ?? []) { + if (!seen.has(view.id)) { + seen.add(view.id) + frontier.push(view.id) + if (view.membership === 'live') { + liveDescendants.push(view) + } + } + } + } + return agentChildWorkLiveness(liveDescendants) +} + +/** The dot a child row renders; every value is an `AgentStateDot` state. */ +export type AgentChildDisplayState = + | 'working' + | 'monitoring' + | 'waiting' + | 'blocked' + | 'done' + | 'failed' + | 'interrupted' + | 'idle' + | 'unverifiable' + +const SETTLED_DISPLAY_STATE: Record = { + succeeded: 'done', + failed: 'failed', + cancelled: 'interrupted', + // Neutral: an ending the lane cannot classify asserts nothing. + unknown: 'idle' +} + +/** + * A child's display state, through the same fold that decides a parent row's: work that is idle + * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it + * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold. + */ +export function deriveAgentChildDisplayState( + view: Pick, + ownedLiveness: AgentChildWorkLiveness +): AgentChildDisplayState { + if (view.state === 'unverifiable') { + return 'unverifiable' + } + // Stored only by a shell or a monitor, and neither owns work. + if (view.state === 'monitoring') { + return 'monitoring' + } + const leadState: AgentStatusState = + view.membership === 'settled' || view.state === 'done' || view.state === 'idle' + ? 'done' + : view.state + // A child's cancel never hides the work it left running. + const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false } + const folded = foldAgentLeadStatus(foldInput) + if (folded.stateName !== 'done') { + return folded.workingMode ?? folded.stateName + } + return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown'] +} + +/** + * A display state in the run-state words a host publishes and every older reader already speaks: + * a failure reads as `blocked`, a cancel as `idle`. The row's own dot keeps the real outcome. + */ +export function agentChildRunStateFor( + displayState: AgentChildDisplayState +): AgentSessionBackgroundTaskRunState { + switch (displayState) { + case 'failed': + return 'blocked' + case 'interrupted': + return 'idle' + case 'working': + case 'monitoring': + case 'waiting': + case 'blocked': + case 'done': + case 'idle': + case 'unverifiable': + return displayState + } +} diff --git a/src/shared/agent-status-child-work-view.test.ts b/src/shared/agent-status-child-work-view.test.ts index 9c79a545183..da0de4e752c 100644 --- a/src/shared/agent-status-child-work-view.test.ts +++ b/src/shared/agent-status-child-work-view.test.ts @@ -15,7 +15,9 @@ import { } from './agent-status-child-work-projection' import { agentChildWorkOwnedLiveness, - deriveAgentChildDisplayState, + deriveAgentChildDisplayState +} from './agent-status-child-work-display' +import { projectAgentChildWorkViews, type AgentChildWorkView, type AgentChildWorkViewAlias diff --git a/src/shared/agent-status-child-work-view.ts b/src/shared/agent-status-child-work-view.ts index 8c70f6a3fea..bfcbcfc11d5 100644 --- a/src/shared/agent-status-child-work-view.ts +++ b/src/shared/agent-status-child-work-view.ts @@ -1,4 +1,3 @@ -import { foldAgentLeadStatus } from './agent-lead-status-fold' import { AGENT_CHILD_WORK_ALIAS_KINDS, type AgentChildWorkAliasInput, @@ -15,12 +14,8 @@ import { type AgentChildWorkOutcome, type AgentChildWorkState } from './agent-status-child-work' -import { - agentChildWorkLiveness, - type AgentChildWorkLiveness -} from './agent-status-child-work-liveness' import { agentStatusSubjectsEqual } from './agent-status-subject' -import type { AgentStatusState } from './agent-status-types' +import { groupedBy } from './grouped-by' /** What a surface reads about one child: a read-only projection of the host's record. * Host bookkeeping (residency, invocation history, provenance, aliases) never travels. A view @@ -83,21 +78,6 @@ function providerIdFor( return undefined } -// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it. -function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map { - const groups = new Map() - for (const item of items) { - const key = keyOf(item) - const group = groups.get(key) - if (group) { - group.push(item) - } else { - groups.set(key, [item]) - } - } - return groups -} - function isOnOwnershipCycle( start: AgentChildWorkInput, byId: ReadonlyMap @@ -161,80 +141,3 @@ export function projectAgentChildWorkViews( } }) } - -type AgentChildWorkOwnershipView = Pick< - AgentChildWorkView, - 'id' | 'kind' | 'state' | 'membership' | 'parentChildWorkId' -> - -/** Liveness of all live work beneath a child, at any depth — the same input a parent row folds. */ -export function agentChildWorkOwnedLiveness( - views: readonly AgentChildWorkOwnershipView[], - ownerId: AgentChildWorkId -): AgentChildWorkLiveness { - const owned = groupedBy(views, (view) => view.parentChildWorkId) - const seen = new Set([ownerId]) - const frontier = [ownerId] - const liveDescendants: AgentChildWorkOwnershipView[] = [] - for (let owner = frontier.pop(); owner !== undefined; owner = frontier.pop()) { - for (const view of owned.get(owner) ?? []) { - if (!seen.has(view.id)) { - seen.add(view.id) - frontier.push(view.id) - if (view.membership === 'live') { - liveDescendants.push(view) - } - } - } - } - return agentChildWorkLiveness(liveDescendants) -} - -/** The dot a child row renders; every value is an `AgentStateDot` state. */ -export type AgentChildDisplayState = - | 'working' - | 'monitoring' - | 'waiting' - | 'blocked' - | 'done' - | 'failed' - | 'interrupted' - | 'idle' - | 'unverifiable' - -const SETTLED_DISPLAY_STATE: Record = { - succeeded: 'done', - failed: 'failed', - cancelled: 'interrupted', - // Neutral: an ending the lane cannot classify asserts nothing. - unknown: 'idle' -} - -/** - * A child's display state, through the same fold that decides a parent row's: work that is idle - * or finished enters it as `done`, so a live shell the child owns reads `monitoring` exactly as it - * would under a CLI agent. `unverifiable` is a freshness verdict and bypasses the fold. - */ -export function deriveAgentChildDisplayState( - view: Pick, - ownedLiveness: AgentChildWorkLiveness -): AgentChildDisplayState { - if (view.state === 'unverifiable') { - return 'unverifiable' - } - // Stored only by a shell or a monitor, and neither owns work. - if (view.state === 'monitoring') { - return 'monitoring' - } - const leadState: AgentStatusState = - view.membership === 'settled' || view.state === 'done' || view.state === 'idle' - ? 'done' - : view.state - // A child's cancel never hides the work it left running. - const foldInput = { leadState, childWorkLiveness: ownedLiveness, interrupted: false } - const folded = foldAgentLeadStatus(foldInput) - if (folded.stateName !== 'done') { - return folded.workingMode ?? folded.stateName - } - return view.membership === 'live' ? 'idle' : SETTLED_DISPLAY_STATE[view.outcome ?? 'unknown'] -} diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts index 25d6b6413d4..4fb741b5ec1 100644 --- a/src/shared/agent-status-field-normalization.ts +++ b/src/shared/agent-status-field-normalization.ts @@ -2,12 +2,17 @@ // String normalizers shared by every agent-status payload field: trim/fold to // a single line for previews, preserve structure for multiline bodies, and // truncate without splitting surrogate pairs. Extracted from -// agent-status-types.ts, which owns the payload shapes and per-field caps. +// agent-status-types.ts, which owns the payload shapes and the caps no other module applies. import { compactDispatchPromptForStatus } from './orca-dispatch-status-prompt' /** Maximum character length for the prompt field. Truncated on parse. */ export const AGENT_STATUS_MAX_FIELD_LENGTH = 200 +/** Maximum character length for the toolInput preview. */ +export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160 +/** Maximum character length for the agentType label. Truncated on parse. */ +export const AGENT_TYPE_MAX_LENGTH = 40 +export const AGENT_MODEL_MAX_LENGTH = 120 const SINGLE_LINE_FIELD_SCAN_OVERHEAD = 64 const SINGLE_LINE_FIELD_SCAN_MULTIPLIER = 8 diff --git a/src/shared/agent-status-subagent-snapshot.ts b/src/shared/agent-status-subagent-snapshot.ts new file mode 100644 index 00000000000..b5a9f5af8fa --- /dev/null +++ b/src/shared/agent-status-subagent-snapshot.ts @@ -0,0 +1,108 @@ +// A pane's in-process subagents as the hook lane publishes them on a status row: the snapshot +// shape, its normalization at every admission point, and the equality stores reuse arrays by. + +import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH, + normalizeOptionalField +} from './agent-status-field-normalization' + +export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable' + +/** A live in-process child of the pane's provider session. Rendered as an + * indented child row with no PTY of its own. */ +export type AgentSubagentSnapshot = { + /** Provider-assigned lifecycle id. */ + id: string + agentType?: string + /** Provider model used by this child, when exposed by its lifecycle event. */ + model?: string + description?: string + state: AgentSubagentState + /** Timestamp (ms) when this subagent was first observed. */ + startedAt: number +} + +/** Maximum subagent child rows carried per status entry. Bounds per-pane cache + * and IPC fanout against a runaway spawner. */ +export const AGENT_STATUS_MAX_SUBAGENTS = 32 +const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 + +function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null + } + const obj = value as Record + if (typeof obj.id !== 'string') { + return null + } + const id = obj.id.trim() + if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) { + return null + } + if ( + obj.state !== 'working' && + obj.state !== 'blocked' && + obj.state !== 'waiting' && + obj.state !== 'idle' && + obj.state !== 'unverifiable' + ) { + return null + } + return { + id, + state: obj.state, + startedAt: + typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0, + agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), + model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), + description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) + } +} + +export function normalizeAgentSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined + } + const normalized: AgentSubagentSnapshot[] = [] + for (const item of value) { + const snapshot = normalizeSubagentSnapshot(item) + if (snapshot) { + normalized.push(snapshot) + if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) { + break + } + } + } + return normalized.length > 0 ? normalized : undefined +} + +/** Structural equality for subagent lists so stores can reuse the previous + * array reference (and skip fanout) when nothing actually changed. */ +export function agentSubagentsEqual( + a: AgentSubagentSnapshot[] | undefined, + b: AgentSubagentSnapshot[] | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b || a.length !== b.length) { + return !a && !b + } + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if ( + x.id !== y.id || + x.state !== y.state || + x.startedAt !== y.startedAt || + x.agentType !== y.agentType || + x.model !== y.model || + x.description !== y.description + ) { + return false + } + } + return true +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0150ef2b7fd..acc7e4f4255 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -8,8 +8,12 @@ import type { AgentStateHistoryEntry } from './agent-state-history' import { isAgentJournalTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' +import type { AgentChildWorkView } from './agent-status-child-work-view' import type { TuiAgent } from './tui-agent' import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH, normalizeInteractivePromptField, normalizeOptionalField, normalizeOptionalMultilineField, @@ -18,7 +22,23 @@ import { } from './agent-status-field-normalization' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +import { + normalizeAgentSubagentsField, + type AgentSubagentSnapshot +} from './agent-status-subagent-snapshot' + +export { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_MAX_FIELD_LENGTH, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH +} from './agent-status-field-normalization' +export { + AGENT_STATUS_MAX_SUBAGENTS, + agentSubagentsEqual, + type AgentSubagentSnapshot, + type AgentSubagentState +} from './agent-status-subagent-snapshot' export type { AgentStatusCacheIdentity, AgentStatusClearIpcPayload, @@ -53,22 +73,6 @@ export type AgentStatusOrchestrationContext = { attention?: OrchestrationFleetAttention } -export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable' - -/** A live in-process child of the pane's provider session. Rendered as an - * indented child row with no PTY of its own. */ -export type AgentSubagentSnapshot = { - /** Provider-assigned lifecycle id. */ - id: string - agentType?: string - /** Provider model used by this child, when exposed by its lifecycle event. */ - model?: string - description?: string - state: AgentSubagentState - /** Timestamp (ms) when this subagent was first observed. */ - startedAt: number -} - export type AgentStatusEntry = { /** Renderer-local status-feed confirmation for children; absent on hook rows. */ subagentObservation?: 'live' | 'unverifiable' @@ -142,6 +146,9 @@ export type AgentStatusEntry = { /** The main agent's own state; absent from old hosts and from writers that carry no main agent fact * (OSC, launch seeds), where readers fall back to `state`. */ mainAgent?: AgentMainAgentStatus + /** The host's child-work views for this session, when it publishes them; child rows then read + * these instead of `subagents`. Absent from older hosts. */ + children?: AgentChildWorkView[] /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata @@ -237,8 +244,6 @@ export function pickParsedAgentStatusPayload( */ /** Maximum character length for the toolName field. */ export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60 -/** Maximum character length for the toolInput preview. */ -export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160 /** Maximum character length for the lastAssistantMessage preview. * Why: 8 KB fits a multi-paragraph summary while bounding per-pane cache against a buggy/malicious agent spamming huge strings. */ export const AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH = 8000 @@ -260,67 +265,11 @@ const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) export function isAgentStatusState(value: unknown): value is AgentStatusState { return typeof value === 'string' && VALID_STATES.has(value) } -/** Maximum character length for the agentType label. Truncated on parse. */ -export const AGENT_TYPE_MAX_LENGTH = 40 -export const AGENT_MODEL_MAX_LENGTH = 120 -/** Maximum subagent child rows carried per status entry. Bounds per-pane cache - * and IPC fanout against a runaway spawner. */ -export const AGENT_STATUS_MAX_SUBAGENTS = 32 export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = { structuralTokens: 4096, nestingDepth: 16 } as const -const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 - -function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null { - if (typeof value !== 'object' || value === null) { - return null - } - const obj = value as Record - if (typeof obj.id !== 'string') { - return null - } - const id = obj.id.trim() - if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) { - return null - } - if ( - obj.state !== 'working' && - obj.state !== 'blocked' && - obj.state !== 'waiting' && - obj.state !== 'idle' && - obj.state !== 'unverifiable' - ) { - return null - } - return { - id, - state: obj.state, - startedAt: - typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0, - agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), - model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), - description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) - } -} - -function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | undefined { - if (!Array.isArray(value) || value.length === 0) { - return undefined - } - const normalized: AgentSubagentSnapshot[] = [] - for (const item of value) { - const snapshot = normalizeSubagentSnapshot(item) - if (snapshot) { - normalized.push(snapshot) - if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) { - break - } - } - } - return normalized.length > 0 ? normalized : undefined -} /** A malformed `mainAgent` drops the FIELD, never the row: the combined `state` is still valid * evidence, and readers fall back to it exactly as they do for a host that predates the field. */ @@ -344,35 +293,6 @@ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentSta } } -/** Structural equality for subagent lists so stores can reuse the previous - * array reference (and skip fanout) when nothing actually changed. */ -export function agentSubagentsEqual( - a: AgentSubagentSnapshot[] | undefined, - b: AgentSubagentSnapshot[] | undefined -): boolean { - if (a === b) { - return true - } - if (!a || !b || a.length !== b.length) { - return !a && !b - } - for (let i = 0; i < a.length; i++) { - const x = a[i] - const y = b[i] - if ( - x.id !== y.id || - x.state !== y.state || - x.startedAt !== y.startedAt || - x.agentType !== y.agentType || - x.model !== y.model || - x.description !== y.description - ) { - return false - } - } - return true -} - /** * Normalize and validate an already-parsed agent status object. Shared by the * JSON string entry point (`parseAgentStatusPayload`) and the object entry @@ -420,7 +340,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), - subagents: normalizeSubagentsField(obj.subagents), + subagents: normalizeAgentSubagentsField(obj.subagents), mainAgent: normalizeMainAgentStatusField(obj.mainAgent) } } diff --git a/src/shared/grouped-by.ts b/src/shared/grouped-by.ts new file mode 100644 index 00000000000..0797dca9620 --- /dev/null +++ b/src/shared/grouped-by.ts @@ -0,0 +1,14 @@ +// Not `Map.groupBy`: the relay runs this core on Node 18, which lacks it. +export function groupedBy(items: readonly T[], keyOf: (item: T) => K): Map { + const groups = new Map() + for (const item of items) { + const key = keyOf(item) + const group = groups.get(key) + if (group) { + group.push(item) + } else { + groups.set(key, [item]) + } + } + return groups +} From b6c2de9f9277c49370644eed37169dc65b3e6279 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:09:11 +0000 Subject: [PATCH 355/387] fix(codex): index a new account home before bridging history into it (#22971) * fix(codex): index a new account home before bridging history into it Codex indexes every rollout present when it first creates its state DB, and the TUI gives up after 30s, so large histories broke a new account's launch. Fixes #20669 * test(codex): keep the account migration test from starting the real Codex binary Selecting an account starts the history bridge, which spawned codex app-server on the fixture homes and raced the test's temp-dir cleanup. * fix(codex): index a new account's most recent bridged history first Indexing a large history takes minutes, and Codex's /resume hides unindexed threads once a directory has any indexed one, so recent conversations stayed missing until the heal reached them in directory-listing order. * test(codex): cover the history bridge's quit, no-history and failed-link guards Drop the stop check before creating the state DB: it ran in the same tick as the check at bridge start, so it could never observe a quit. * test(codex): make the account heal tests fail closed instead of reaching a real codex Fake invocations now point at a nonexistent binary and a throwaway CODEX_HOME, and cover per-home failure memory and a session that fails during quit. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- ...home-service-per-account-migration.test.ts | 14 + .../codex-account-session-bridge.test.ts | 306 ++++++++++++++++-- .../codex/codex-account-session-bridge.ts | 128 +++++++- .../codex-account-session-index-heal.test.ts | 306 ++++++++++++++++++ .../codex/codex-account-session-index-heal.ts | 122 +++++++ .../codex-index-heal-binary-contract.test.ts | 42 ++- .../codex/codex-session-index-heal-state.ts | 18 +- .../codex/codex-session-index-heal.test.ts | 33 ++ src/main/codex/codex-session-index-heal.ts | 204 +++++++----- src/main/codex/codex-state-db-test-fixture.ts | 14 + src/main/codex/codex-state-db.test.ts | 42 ++- src/main/codex/codex-state-db.ts | 37 ++- src/main/startup/main-process-quit.ts | 2 + 13 files changed, 1128 insertions(+), 140 deletions(-) create mode 100644 src/main/codex/codex-account-session-index-heal.test.ts create mode 100644 src/main/codex/codex-account-session-index-heal.ts create mode 100644 src/main/codex/codex-state-db-test-fixture.ts diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 53736a11beb..4a7ad01e1b5 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -6,6 +6,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { CodexManagedAccount } from '../../shared/managed-account-types' import type * as NodeOs from 'node:os' import { readHookTrustEntries } from '../codex/config-toml-trust' +import { writeCodexStateDbBackfillStatus } from '../codex/codex-state-db-test-fixture' const testState = { userData: '', home: '' } const previousEnv: Record = {} @@ -15,6 +16,17 @@ vi.mock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => testState.home } }) +// Why: selecting an account starts the history bridge, which would otherwise +// spawn the real `codex app-server` on these fixture homes. +vi.mock('../codex/codex-account-session-index-heal', () => ({ + createCodexAccountStateDb: async () => false, + healCodexAccountSessionIndex: async () => ({ + outcome: 'up-to-date', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + }) +})) beforeEach(() => { vi.resetModules() @@ -212,6 +224,8 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { const siblingRollout = join('2026', '07', '21', 'rollout-2026-07-21T10-00-00-bbbb.jsonl') writeRollout(systemHome(), systemRollout, '{"session":"real-home"}\n') writeRollout(accountOne.managedHomePath, siblingRollout, '{"session":"account-one"}\n') + // Why: history is linked only once Codex has indexed the new home (#20669). + writeCodexStateDbBackfillStatus(accountTwo.managedHomePath, 'complete') const { settings, store } = createStore([accountOne, accountTwo], accountOne.id) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const bridge = await import('../codex/codex-account-session-bridge') diff --git a/src/main/codex/codex-account-session-bridge.test.ts b/src/main/codex/codex-account-session-bridge.test.ts index 7cebd37c1f4..3befa013473 100644 --- a/src/main/codex/codex-account-session-bridge.test.ts +++ b/src/main/codex/codex-account-session-bridge.test.ts @@ -1,14 +1,31 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { _internals, bridgeCodexSessionsIntoAccountHome, - startCodexAccountSessionBridgeInBackground + startCodexAccountSessionBridgeInBackground, + stopCodexAccountSessionBridges } from './codex-account-session-bridge' +import { writeCodexStateDbBackfillStatus } from './codex-state-db-test-fixture' +import SyncDatabase from '../sqlite/sync-database' let workspaceRoot: string +const healIndexStub = vi.fn(async () => ({ + outcome: 'up-to-date' as const, + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 +})) function writeRollout(homePath: string, relativePath: string, contents: string): string { const filePath = join(homePath, 'sessions', relativePath) @@ -21,12 +38,25 @@ function rolloutPath(homePath: string, relativePath: string): string { return join(homePath, 'sessions', relativePath) } -const ROLLOUT_A = join('2026', '07', '20', 'rollout-2026-07-20T10-00-00-aaaa.jsonl') -const ROLLOUT_B = join('2026', '07', '21', 'rollout-2026-07-21T10-00-00-bbbb.jsonl') +const THREAD_A = '019a0000-0000-7000-8000-00000000000a' +const THREAD_B = '019a0000-0000-7000-8000-00000000000b' +const ROLLOUT_A = join( + '2026', + '07', + '20', + 'rollout-2026-07-20T10-00-00-019a0000-0000-7000-8000-00000000000a.jsonl' +) +const ROLLOUT_B = join( + '2026', + '07', + '21', + 'rollout-2026-07-21T10-00-00-019a0000-0000-7000-8000-00000000000b.jsonl' +) beforeEach(() => { workspaceRoot = mkdtempSync(join(tmpdir(), 'codex-account-session-bridge-')) _internals.resetBackgroundBridgeTasks() + healIndexStub.mockClear() }) afterEach(() => { @@ -47,7 +77,14 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { options: { batchSize: 1, yieldMs: 0 } }) - expect(summary).toEqual({ scannedFiles: 2, linkedFiles: 2 }) + expect(summary).toEqual({ + scannedFiles: 2, + linkedFiles: 2, + bridgedThreads: new Map([ + [THREAD_A, '2026-07-20T10-00-00'], + [THREAD_B, '2026-07-21T10-00-00'] + ]) + }) expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('system session\n') expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('account a session\n') }) @@ -97,7 +134,11 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [systemHome] }) - expect(second).toEqual({ scannedFiles: 1, linkedFiles: 0 }) + expect(second).toEqual({ + scannedFiles: 1, + linkedFiles: 0, + bridgedThreads: new Map([[THREAD_A, '2026-07-20T10-00-00']]) + }) }) it('never links a home into itself or scans a duplicate source twice', async () => { @@ -109,7 +150,27 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [targetHome, targetHome] }) - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0, bridgedThreads: new Map() }) + }) + + it('does not hand a rollout it failed to link to the index heal', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeRollout(systemHome, ROLLOUT_B, 'session\n') + // A file where the target's day directory belongs makes that one link fail. + mkdirSync(join(targetHome, 'sessions', '2026', '07'), { recursive: true }) + writeFileSync(join(targetHome, 'sessions', '2026', '07', '20'), 'not a directory') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await bridgeCodexSessionsIntoAccountHome({ + targetCodexHomePath: targetHome, + sourceCodexHomePaths: [systemHome] + }) + + warn.mockRestore() + expect(summary.linkedFiles).toBe(1) + expect(summary.bridgedThreads).toEqual(new Map([[THREAD_B, '2026-07-21T10-00-00']])) }) it('skips a source home that has no sessions tree', async () => { @@ -119,24 +180,35 @@ describe('bridgeCodexSessionsIntoAccountHome', () => { sourceCodexHomePaths: [join(workspaceRoot, 'missing')] }) - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0, bridgedThreads: new Map() }) }) }) +type BridgeDependencies = NonNullable< + Parameters[1] +> + +// Why: the real steps spawn `codex app-server`; their behavior is covered separately. +function startBridge( + targetCodexHomePath: string, + sourceCodexHomePaths: string[], + dependencies: BridgeDependencies = {} +): Promise { + return startCodexAccountSessionBridgeInBackground( + { targetCodexHomePath, sourceCodexHomePaths }, + { createStateDb: async () => false, healIndex: healIndexStub, ...dependencies } + ) +} + describe('startCodexAccountSessionBridgeInBackground', () => { it('shares one in-flight task per target home', () => { const systemHome = join(workspaceRoot, 'system') const targetHome = join(workspaceRoot, 'account') writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') - const first = startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: targetHome, - sourceCodexHomePaths: [systemHome] - }) - const second = startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: targetHome, - sourceCodexHomePaths: [systemHome] - }) + const first = startBridge(targetHome, [systemHome]) + const second = startBridge(targetHome, [systemHome]) expect(second).toBe(first) return first @@ -147,19 +219,203 @@ describe('startCodexAccountSessionBridgeInBackground', () => { const firstTarget = join(workspaceRoot, 'account-a') const secondTarget = join(workspaceRoot, 'account-b') writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(firstTarget, 'complete') + writeCodexStateDbBackfillStatus(secondTarget, 'complete') await Promise.all([ - startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: firstTarget, - sourceCodexHomePaths: [systemHome] - }), - startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: secondTarget, - sourceCodexHomePaths: [systemHome] - }) + startBridge(firstTarget, [systemHome]), + startBridge(secondTarget, [systemHome]) ]) expect(readFileSync(rolloutPath(firstTarget, ROLLOUT_A), 'utf-8')).toBe('session\n') expect(readFileSync(rolloutPath(secondTarget, ROLLOUT_A), 'utf-8')).toBe('session\n') }) + + // #20669: rollouts present when Codex first creates its state DB force a + // blocking backfill that times out the first TUI launch. + it('lets Codex index a new home before linking history into it', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + const createStateDb = vi.fn(async (home: string) => { + expect(existsSync(rolloutPath(home, ROLLOUT_A))).toBe(false) + writeCodexStateDbBackfillStatus(home, 'complete') + return true + }) + + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(createStateDb).toHaveBeenCalledWith(targetHome) + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('session\n') + }) + + // Why: an older Codex keeps no state DB, and `sqlite_home` keeps it outside + // the home; either way every launch must keep linking new history. + it('keeps linking new history on later launches when no state DB lives in the home', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'first\n') + const createStateDb = vi.fn(async () => true) + + await startBridge(targetHome, [systemHome], { createStateDb }) + writeRollout(systemHome, ROLLOUT_B, 'second\n') + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('first\n') + expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('second\n') + // Only the empty home needed Codex to run first. + expect(createStateDb).toHaveBeenCalledTimes(1) + }) + + it('does not start Codex on an empty home when no source has history', async () => { + const createStateDb = vi.fn(async () => true) + + await startBridge(join(workspaceRoot, 'account'), [join(workspaceRoot, 'system')], { + createStateDb + }) + + expect(createStateDb).not.toHaveBeenCalled() + }) + + it('does not link history when Codex could not create the state DB', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + + await startBridge(targetHome, [systemHome], { createStateDb: async () => false }) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('bridges compressed-only history into a new home and indexes it', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + const compressed = `${ROLLOUT_A}.zst` + writeRollout(systemHome, compressed, 'compressed\n') + const createStateDb = vi.fn(async (home: string) => { + writeCodexStateDbBackfillStatus(home, 'complete') + return true + }) + + await startBridge(targetHome, [systemHome], { createStateDb }) + + expect(readFileSync(rolloutPath(targetHome, compressed), 'utf-8')).toBe('compressed\n') + expect(healIndexStub).toHaveBeenCalledWith( + targetHome, + new Map([[THREAD_A, '2026-07-20T10-00-00']]), + { + shouldStop: expect.any(Function) + } + ) + }) + + it('skips linking into a state DB that predates backfill tracking', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + mkdirSync(targetHome, { recursive: true }) + new SyncDatabase(join(targetHome, 'state_5.sqlite')).close() + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + }) + + it('skips linking while Codex is still indexing the home', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'running') + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('reports why it skips a home whose state DB cannot be read', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + mkdirSync(targetHome, { recursive: true }) + writeFileSync(join(targetHome, 'state_5.sqlite'), 'not a sqlite database') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await startBridge(targetHome, [systemHome]) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Codex state DB is unreadable'), + expect.any(String) + ) + warn.mockRestore() + }) + + it('indexes the bridged threads once linking finishes', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') + + await startBridge(targetHome, [systemHome]) + + expect(healIndexStub).toHaveBeenCalledWith( + targetHome, + new Map([[THREAD_A, '2026-07-20T10-00-00']]), + { + shouldStop: expect.any(Function) + } + ) + }) + + it('links nothing when the app quits while Codex creates the state DB', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + + await startBridge(targetHome, [systemHome], { + createStateDb: async (home) => { + writeCodexStateDbBackfillStatus(home, 'complete') + stopCodexAccountSessionBridges() + return true + } + }) + + expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false) + expect(healIndexStub).not.toHaveBeenCalled() + }) + + it('does not start Codex on a new home after the app quits', async () => { + const systemHome = join(workspaceRoot, 'system') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + const createStateDb = vi.fn(async () => true) + stopCodexAccountSessionBridges() + + await startBridge(join(workspaceRoot, 'account'), [systemHome], { createStateDb }) + + expect(createStateDb).not.toHaveBeenCalled() + }) + + it('stops indexing and starts no new bridge once the app quits', async () => { + const systemHome = join(workspaceRoot, 'system') + const targetHome = join(workspaceRoot, 'account') + const laterTarget = join(workspaceRoot, 'later-account') + writeRollout(systemHome, ROLLOUT_A, 'session\n') + writeCodexStateDbBackfillStatus(targetHome, 'complete') + writeCodexStateDbBackfillStatus(laterTarget, 'complete') + let shouldStop: (() => boolean) | undefined + + await startBridge(targetHome, [systemHome], { + healIndex: async (_home, _threads, options) => { + shouldStop = options?.shouldStop + stopCodexAccountSessionBridges() + return { outcome: 'stopped', healedThreads: 0, missingThreads: 0, failedThreads: 0 } + } + }) + await startBridge(laterTarget, [systemHome]) + + expect(shouldStop?.()).toBe(true) + expect(existsSync(rolloutPath(laterTarget, ROLLOUT_A))).toBe(false) + }) }) diff --git a/src/main/codex/codex-account-session-bridge.ts b/src/main/codex/codex-account-session-bridge.ts index 72096103aa1..bb6110a8651 100644 --- a/src/main/codex/codex-account-session-bridge.ts +++ b/src/main/codex/codex-account-session-bridge.ts @@ -3,7 +3,17 @@ import { dirname, join, relative } from 'node:path' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' +import { + createCodexAccountStateDb, + healCodexAccountSessionIndex +} from './codex-account-session-index-heal' +import { parseCodexRolloutThreadId } from './codex-session-index-heal-state' import { linkCodexSessionFile } from './codex-session-link' +import { + countCodexSessionFilesUpTo, + findNewestCodexStateDbPath, + readCodexStateDbBackfillStatus +} from './codex-state-db' /** * Bridges Codex history between Orca-managed Codex homes. @@ -19,24 +29,99 @@ import { linkCodexSessionFile } from './codex-session-link' export type CodexAccountSessionBridgeSummary = { scannedFiles: number linkedFiles: number + /** Thread id -> rollout timestamp for every rollout present in the target home via this bridge. */ + bridgedThreads: Map } const backgroundBridgeTasksByTargetHome = new Map>() +let stopping = false + +type BackgroundBridgeDependencies = { + createStateDb: typeof createCodexAccountStateDb + healIndex: typeof healCodexAccountSessionIndex +} + +const defaultBackgroundBridgeDependencies: BackgroundBridgeDependencies = { + createStateDb: createCodexAccountStateDb, + healIndex: healCodexAccountSessionIndex +} + +/** + * Why: Codex indexes every rollout present when it first creates a home's state + * DB, and the TUI gives up waiting after 30s. Linking history into a fresh home + * first turns its first launch into a minutes-long backfill (#20669), so an + * empty home gets its state DB before any history; the heal indexes it afterwards. + */ +async function isReadyForBridgedHistory( + targetCodexHomePath: string, + sourceCodexHomePaths: readonly string[], + dependencies: BackgroundBridgeDependencies +): Promise { + if ( + !findNewestCodexStateDbPath(targetCodexHomePath) && + !hasSessionRollouts(targetCodexHomePath) + ) { + if ( + !sourceCodexHomePaths.some(hasSessionRollouts) || + !(await dependencies.createStateDb(targetCodexHomePath)) + ) { + return false + } + } + const status = readCodexStateDbBackfillStatus(targetCodexHomePath) + if (status.kind === 'unreadable') { + // Why: contention clears by the next launch; corruption would skip every launch, so surface it. + console.warn( + '[codex-account-session-bridge] Skipping history bridge; Codex state DB is unreadable:', + status.error + ) + return false + } + // Why: no DB in a home that has history means Codex keeps it elsewhere + // (`sqlite_home`) or keeps none, so linking cannot stall a launch here. + // `not-tracked` is a pre-backfill schema that Codex migrates to `pending`. + return status.kind === 'complete' || status.kind === 'missing' +} + +function hasSessionRollouts(codexHomePath: string): boolean { + return countCodexSessionFilesUpTo(join(codexHomePath, 'sessions'), 1) > 0 +} /** * Starts one background bridge per target home, sharing in-flight work. */ -export function startCodexAccountSessionBridgeInBackground(args: { - targetCodexHomePath: string - sourceCodexHomePaths: readonly string[] - options?: CodexSessionBridgeIncrementalOptions -}): Promise { +export function startCodexAccountSessionBridgeInBackground( + args: { + targetCodexHomePath: string + sourceCodexHomePaths: readonly string[] + options?: CodexSessionBridgeIncrementalOptions + }, + dependenciesOverride: Partial = {} +): Promise { + if (stopping) { + return Promise.resolve() + } const key = normalizeRuntimePathForComparison(args.targetCodexHomePath) const inFlight = backgroundBridgeTasksByTargetHome.get(key) if (inFlight) { return inFlight } - const task = bridgeCodexSessionsIntoAccountHome(args) + const dependencies = { ...defaultBackgroundBridgeDependencies, ...dependenciesOverride } + const task = isReadyForBridgedHistory( + args.targetCodexHomePath, + args.sourceCodexHomePaths, + dependencies + ) + .then(async (ready) => { + // Why: skip rather than feed a running backfill; the next launch retries. + if (!ready || stopping) { + return + } + const summary = await bridgeCodexSessionsIntoAccountHome(args) + await dependencies.healIndex(args.targetCodexHomePath, summary.bridgedThreads, { + shouldStop: () => stopping + }) + }) .catch((error: unknown) => { console.warn('[codex-account-session-bridge] Background session bridge failed:', error) }) @@ -50,6 +135,11 @@ export function startCodexAccountSessionBridgeInBackground(args: { return task } +/** Stops background bridges at quit; links and index reads made so far are kept. */ +export function stopCodexAccountSessionBridges(): void { + stopping = true +} + /** * Mirrors every source home's rollouts into the target home's sessions tree. */ @@ -58,7 +148,11 @@ export async function bridgeCodexSessionsIntoAccountHome(args: { sourceCodexHomePaths: readonly string[] options?: CodexSessionBridgeIncrementalOptions }): Promise { - const summary: CodexAccountSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } + const summary: CodexAccountSessionBridgeSummary = { + scannedFiles: 0, + linkedFiles: 0, + bridgedThreads: new Map() + } const targetSessionsRoot = join(args.targetCodexHomePath, 'sessions') for (const sourceHomePath of dedupeSourceHomes( args.sourceCodexHomePaths, @@ -73,9 +167,18 @@ export async function bridgeCodexSessionsIntoAccountHome(args: { args.options ?? {} )) { summary.scannedFiles += 1 - if (bridgeRolloutIntoAccountHome(sourceSessionsRoot, targetSessionsRoot, sourceFilePath)) { + const result = bridgeRolloutIntoAccountHome( + sourceSessionsRoot, + targetSessionsRoot, + sourceFilePath + ) + if (result === 'linked') { summary.linkedFiles += 1 } + const rollout = parseCodexRolloutThreadId(sourceFilePath) + if (result !== 'failed' && rollout) { + summary.bridgedThreads.set(rollout.threadId, rollout.rolloutStamp) + } } } return summary @@ -88,20 +191,20 @@ function bridgeRolloutIntoAccountHome( sourceSessionsRoot: string, targetSessionsRoot: string, sourceFilePath: string -): boolean { +): 'linked' | 'existing' | 'failed' { const targetFilePath = join(targetSessionsRoot, relative(sourceSessionsRoot, sourceFilePath)) // Why: rollout names carry the session UUID, so an existing target path is the // same conversation already bridged (often the same inode) — never a conflict. if (existsSync(targetFilePath)) { - return false + return 'existing' } try { mkdirSync(dirname(targetFilePath), { recursive: true }) } catch (error) { console.warn('[codex-account-session-bridge] Failed to create session directory:', error) - return false + return 'failed' } - return linkCodexSessionFile(sourceFilePath, targetFilePath) + return linkCodexSessionFile(sourceFilePath, targetFilePath) ? 'linked' : 'failed' } /** @@ -127,5 +230,6 @@ function dedupeSourceHomes( export const _internals = { resetBackgroundBridgeTasks: (): void => { backgroundBridgeTasksByTargetHome.clear() + stopping = false } } diff --git a/src/main/codex/codex-account-session-index-heal.test.ts b/src/main/codex/codex-account-session-index-heal.test.ts new file mode 100644 index 00000000000..fbb5fae404c --- /dev/null +++ b/src/main/codex/codex-account-session-index-heal.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + _internals, + createCodexAccountStateDb, + healCodexAccountSessionIndex +} from './codex-account-session-index-heal' +import { + CodexAppServerUnsupportedError, + type CodexAppServerInvocation, + type CodexAppServerRpc +} from './codex-app-server-session' + +const HOME = '/codex-accounts/account-1/home' + +// Why: fails closed if a regression bypasses the fake session — never the real codex or ~/.codex. +function buildInvocation(): CodexAppServerInvocation { + return { + command: '/nonexistent/orca-test-codex', + args: ['app-server'], + cliPath: null, + env: { CODEX_HOME: join(tmpdir(), 'orca-test-nonexistent-codex-home') }, + timeoutMs: 1_000 + } +} + +/** Runs the heal body against a fake app-server that answers thread/read. */ +function fakeAppServer(onRead: (threadId: string) => void = () => {}) { + const readThreadIds: string[] = [] + const runSession = vi.fn( + async ( + _invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ): Promise => { + await body({ + request: async (method, params) => { + expect(method).toBe('thread/read') + const threadId = String(params?.threadId) + readThreadIds.push(threadId) + onRead(threadId) + return {} + }, + notify: () => {} + }) + } + ) + return { runSession, readThreadIds } +} + +/** Bridged threads keyed by id, all from the same rollout timestamp. */ +function bridged(...threadIds: string[]): Map { + return new Map(threadIds.map((threadId) => [threadId, '2026-07-20T10-00-00'])) +} + +beforeEach(() => { + _internals.resetFailedThreads() +}) + +describe('healCodexAccountSessionIndex', () => { + it('reads only bridged threads missing from the Codex index', async () => { + const { runSession, readThreadIds } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a', 'b', 'c'), { + readIndexedThreadIds: () => new Set(['b']), + buildInvocation, + runSession + }) + + expect(readThreadIds.sort()).toEqual(['a', 'c']) + expect(summary).toEqual({ + outcome: 'completed', + healedThreads: 2, + missingThreads: 0, + failedThreads: 0 + }) + }) + + it('reads the most recent bridged rollouts first', async () => { + const { runSession, readThreadIds } = fakeAppServer() + + await healCodexAccountSessionIndex( + HOME, + new Map([ + ['middle', '2026-08-01T09-00-00'], + ['oldest', '2025-12-31T23-59-59'], + ['newest', '2026-09-28T01-46-16'] + ]), + { readIndexedThreadIds: () => new Set(), buildInvocation, runSession, readConcurrency: 1 } + ) + + expect(readThreadIds).toEqual(['newest', 'middle', 'oldest']) + }) + + it('does not start Codex when every bridged thread is already indexed', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(['a']), + buildInvocation, + runSession + }) + + expect(summary.outcome).toBe('up-to-date') + expect(runSession).not.toHaveBeenCalled() + }) + + it('does not start Codex when its index cannot be read', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => null, + buildInvocation, + runSession + }) + + expect(summary.outcome).toBe('no-index') + expect(runSession).not.toHaveBeenCalled() + }) + + it('does not start Codex once the app is quitting', async () => { + const { runSession } = fakeAppServer() + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession, + shouldStop: () => true + }) + + expect(summary.outcome).toBe('stopped') + expect(runSession).not.toHaveBeenCalled() + }) + + it('stops retrying a thread Codex refuses to index until Orca restarts', async () => { + const { runSession, readThreadIds } = fakeAppServer((threadId) => { + if (threadId === 'broken') { + throw new Error('codex app-server thread/read failed: invalid rollout') + } + }) + const dependencies = { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + } + + const first = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies) + const second = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies) + + expect(first).toEqual({ + outcome: 'completed', + healedThreads: 0, + missingThreads: 0, + failedThreads: 1 + }) + expect(second.outcome).toBe('up-to-date') + expect(readThreadIds).toEqual(['broken']) + }) + + it('remembers a refused thread only for the home that refused it', async () => { + const { runSession, readThreadIds } = fakeAppServer((threadId) => { + if (readThreadIds.length === 1) { + throw new Error(`codex app-server thread/read failed: invalid rollout ${threadId}`) + } + }) + const dependencies = { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + } + + await healCodexAccountSessionIndex(HOME, bridged('shared'), dependencies) + const other = await healCodexAccountSessionIndex( + '/codex-accounts/account-2/home', + bridged('shared'), + dependencies + ) + + expect(other.healedThreads).toBe(1) + expect(readThreadIds).toEqual(['shared', 'shared']) + }) + + it('counts a rollout Codex cannot find as missing and does not reread it', async () => { + const { runSession, readThreadIds } = fakeAppServer(() => { + throw new Error('codex app-server thread/read failed: no rollout found for thread id gone') + }) + const options = { readIndexedThreadIds: () => new Set(), buildInvocation, runSession } + + const first = await healCodexAccountSessionIndex(HOME, bridged('gone'), options) + const second = await healCodexAccountSessionIndex(HOME, bridged('gone'), options) + + expect(first).toEqual({ + outcome: 'completed', + healedThreads: 0, + missingThreads: 1, + failedThreads: 0 + }) + expect(second.outcome).toBe('up-to-date') + expect(readThreadIds).toEqual(['gone']) + }) + + it('retries a thread on the next pass when the app-server session fails', async () => { + const failing = vi.fn(async () => { + throw new Error('codex app-server exited before responding') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const readIndexedThreadIds = (): Set => new Set() + + const first = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds, + buildInvocation, + runSession: failing + }) + const { runSession, readThreadIds } = fakeAppServer() + const second = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds, + buildInvocation, + runSession + }) + + expect(first.outcome).toBe('aborted') + expect(second.outcome).toBe('completed') + expect(readThreadIds).toEqual(['a']) + warn.mockRestore() + }) + + it('reports a session that fails during quit as stopped, not unsupported', async () => { + let stopping = false + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession: async () => { + stopping = true + throw new CodexAppServerUnsupportedError('app-server killed during quit') + }, + shouldStop: () => stopping + }) + + expect(summary.outcome).toBe('stopped') + }) + + it('aborts rather than writing off a thread while a live Codex holds the database', async () => { + const { runSession } = fakeAppServer(() => { + throw new Error('codex app-server thread/read failed: database is locked') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), { + readIndexedThreadIds: () => new Set(), + buildInvocation, + runSession + }) + + expect(summary).toEqual({ + outcome: 'aborted', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + }) + warn.mockRestore() + }) +}) + +describe('createCodexAccountStateDb', () => { + it('starts Codex on the home without sending any request', async () => { + const { runSession, readThreadIds } = fakeAppServer() + const invocations: string[] = [] + + const created = await createCodexAccountStateDb(HOME, { + buildInvocation: (home, timeoutMs) => { + invocations.push(home) + return { ...buildInvocation(), timeoutMs } + }, + runSession + }) + + expect(created).toBe(true) + expect(invocations).toEqual([HOME]) + expect(readThreadIds).toEqual([]) + }) + + it('treats a Codex without app-server as having no state DB to stall', async () => { + const created = await createCodexAccountStateDb(HOME, { + buildInvocation, + runSession: async () => { + throw new CodexAppServerUnsupportedError('unknown subcommand app-server') + } + }) + + expect(created).toBe(true) + }) + + it('reports failure when Codex could not start', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const created = await createCodexAccountStateDb(HOME, { + buildInvocation, + runSession: async () => { + throw new Error('spawn codex ENOENT') + } + }) + + expect(created).toBe(false) + warn.mockRestore() + }) +}) diff --git a/src/main/codex/codex-account-session-index-heal.ts b/src/main/codex/codex-account-session-index-heal.ts new file mode 100644 index 00000000000..1af87e34407 --- /dev/null +++ b/src/main/codex/codex-account-session-index-heal.ts @@ -0,0 +1,122 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + isCodexAppServerUnsupportedError, + runCodexAppServerSession +} from './codex-app-server-session' +import { + buildNativeHealInvocation, + readCodexThreadsForIndexHeal, + type CodexSessionIndexHealOptions, + type CodexThreadReadPassOutcome +} from './codex-session-index-heal' +import { readIndexedCodexThreadIds } from './codex-state-db' + +// Why: Codex indexes a home's rollouts only once, when it first creates the +// state DB, and the /resume picker's "All" view lists only indexed threads. +// History bridged in afterwards needs `thread/read`, Codex's lazy-indexing +// path, to become visible there (#20669). + +export type CodexAccountSessionIndexHealSummary = { + outcome: CodexThreadReadPassOutcome | 'up-to-date' | 'no-index' + healedThreads: number + missingThreads: number + failedThreads: number +} + +export type CodexAccountSessionIndexHealOptions = CodexSessionIndexHealOptions & { + readIndexedThreadIds?: (codexHomePath: string) => Set | null +} + +// Why: app-server finishes state DB startup before it answers `initialize`, +// which takes ~100ms on an empty home. +const STATE_DB_CREATE_TIMEOUT_MS = 15_000 + +// Why: a just-linked rollout Codex refuses or cannot find fails the same way on +// every read; skip it until Orca restarts instead of respawning app-server each launch. +const failedThreadIdsByHome = new Map>() + +/** + * Starts Codex once on an empty home so it creates and indexes its state DB + * before any history is bridged in. False when Codex could not start. + */ +export async function createCodexAccountStateDb( + codexHomePath: string, + options: Pick = {} +): Promise { + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const runSession = options.runSession ?? runCodexAppServerSession + try { + await runSession(buildInvocation(codexHomePath, STATE_DB_CREATE_TIMEOUT_MS), async () => {}) + return true + } catch (error) { + // Why: a Codex without app-server predates the state DB, so linking cannot stall it. + if (isCodexAppServerUnsupportedError(error)) { + return true + } + console.warn('[codex-account-session-index-heal] Failed to create Codex state DB:', error) + return false + } +} + +/** + * Indexes the bridged threads Codex has not indexed yet, newest rollout first. + * Diffing against the state DB on every pass makes an interrupted heal resume + * on the next launch. + */ +export async function healCodexAccountSessionIndex( + codexHomePath: string, + bridgedThreads: ReadonlyMap, + options: CodexAccountSessionIndexHealOptions = {} +): Promise { + const summary: CodexAccountSessionIndexHealSummary = { + outcome: 'up-to-date', + healedThreads: 0, + missingThreads: 0, + failedThreads: 0 + } + if (bridgedThreads.size === 0) { + return summary + } + // Why: with no DB in the home, Codex keeps none (older CLI) or uses a + // `sqlite_home` shared by every Orca home, which already indexes these threads. + const indexed = (options.readIndexedThreadIds ?? readIndexedCodexThreadIds)(codexHomePath) + if (!indexed) { + return { ...summary, outcome: 'no-index' } + } + const homeKey = normalizeRuntimePathForComparison(codexHomePath) + const failed = failedThreadIdsByHome.get(homeKey) ?? new Set() + failedThreadIdsByHome.set(homeKey, failed) + // Why: a large history takes minutes to index, and /resume hides unindexed + // threads once a directory has any indexed one, so recent work goes first. + const pending = [...bridgedThreads] + .filter(([threadId]) => !indexed.has(threadId) && !failed.has(threadId)) + .sort(([, left], [, right]) => (left < right ? 1 : left > right ? -1 : 0)) + .map(([threadId]) => ({ threadId })) + if (pending.length === 0) { + return summary + } + summary.outcome = await readCodexThreadsForIndexHeal( + codexHomePath, + pending, + ({ threadId }, outcome) => { + if (outcome === 'healed') { + summary.healedThreads += 1 + return + } + failed.add(threadId) + if (outcome === 'missing') { + summary.missingThreads += 1 + } else { + summary.failedThreads += 1 + } + }, + options + ) + return summary +} + +export const _internals = { + resetFailedThreads: (): void => { + failedThreadIdsByHome.clear() + } +} diff --git a/src/main/codex/codex-index-heal-binary-contract.test.ts b/src/main/codex/codex-index-heal-binary-contract.test.ts index 5580fc02d1d..8954f618a6a 100644 --- a/src/main/codex/codex-index-heal-binary-contract.test.ts +++ b/src/main/codex/codex-index-heal-binary-contract.test.ts @@ -3,10 +3,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +import { zstdCompressSync } from 'node:zlib' import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import SyncDatabase from '../sqlite/sync-database' import { runCodexAppServerSession, type CodexAppServerRpc } from './codex-app-server-session' -import { findNewestCodexStateDbPath } from './codex-state-db' +import { findNewestCodexStateDbPath, readCodexStateDbBackfillStatus } from './codex-state-db' // Why this file exists: every other index-heal test drives a stub app-server and // asserts "healed" as "the `thread/read` call did not error". That pins Orca's half @@ -17,7 +19,7 @@ import { findNewestCodexStateDbPath } from './codex-state-db' // real-binary backstop, built to the same shape as the Git binary compatibility // contract in src/shared/git-binary-compatibility.test.ts. // -// Keep it narrow. It pins the four arms that ablation established Orca relies on, +// Keep it narrow. It pins the arms that ablation established Orca relies on, // and deliberately asserts nothing else about the app-server, so an unrelated Codex // release does not redden it into being disabled. @@ -83,11 +85,18 @@ describeCodexContract( mkdirSync(join(home, 'sessions'), { recursive: true }) // An app-server session over an empty sessions tree is what stamps the backfill complete. await runAppServerSession(home, async () => undefined) + // Why: the account bridge links history only after this no-request session stamps it. + expect(readCodexStateDbBackfillStatus(home).kind).toBe('complete') expect(readThreadRows(home)).toEqual([]) return home } - function writeRollout(home: string, threadId: string, stamp: string): void { + function writeRollout( + home: string, + threadId: string, + stamp: string, + options: { compressed?: boolean } = {} + ): void { const dayDir = join(home, 'sessions', '2026', '08', '29') mkdirSync(dayDir, { recursive: true }) const meta = { @@ -112,10 +121,13 @@ describeCodexContract( type: 'event_msg', payload: { type: 'user_message', message: 'index-heal contract fixture' } } - writeFileSync( - join(dayDir, `rollout-${stamp}-${threadId}.jsonl`), - `${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n` - ) + const contents = `${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n` + const fileName = `rollout-${stamp}-${threadId}.jsonl` + if (options.compressed) { + writeFileSync(join(dayDir, `${fileName}.zst`), zstdCompressSync(contents)) + } else { + writeFileSync(join(dayDir, fileName), contents) + } } // Why reuse runCodexAppServerSession rather than a local JSON-RPC client: it is the @@ -128,7 +140,8 @@ describeCodexContract( return runCodexAppServerSession( { command: binary!, - args: ['app-server'], + // Why: the heal and account state-DB creation launch with these exact args. + args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: binary!, env: { CODEX_HOME: home }, timeoutMs: SESSION_TIMEOUT_MS @@ -171,6 +184,19 @@ describeCodexContract( expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }]) }) + // Why: the account bridge links `.jsonl.zst` history and heals it with the same read. + it('indexes a compressed-only rollout on read', async () => { + const home = await createBackfilledCodexHome() + const threadId = '01a04f62-715e-7830-9371-50db585caa74' + writeRollout(home, threadId, '2026-08-29T17-00-00', { compressed: true }) + + await runAppServerSession(home, async (rpc) => { + await rpc.request('thread/read', { threadId }) + }) + + expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }]) + }) + it('leaves an already-indexed thread as a single row', async () => { const home = await createBackfilledCodexHome() const threadId = '01a04f62-715e-7830-9371-50db585caa72' diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 0a5fd0499c1..ab62b77669d 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -22,7 +22,7 @@ const HEAL_UNSUPPORTED_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const HEAL_FAILED_THREAD_RETRY_INTERVAL_MS = 24 * 60 * 60 * 1000 const CODEX_ROLLOUT_THREAD_ID_PATTERN = - /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i + /^rollout-(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i export type CodexSessionIndexHealPaths = { auditLogPath: string @@ -70,11 +70,11 @@ export async function collectPendingHealThreads( if (!isPathInsideOrEqual(paths.systemSessionsRoot, line.target)) { continue } - const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) - if (!match) { + const rollout = parseCodexRolloutThreadId(line.target) + if (!rollout) { continue } - const threadId = match[2].toLowerCase() + const { threadId } = rollout const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( auditRecordId @@ -88,13 +88,21 @@ export async function collectPendingHealThreads( pendingByThreadId.delete(threadId) continue } - pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1], auditRecordId }) + pendingByThreadId.set(threadId, { threadId, rolloutStamp: rollout.rolloutStamp, auditRecordId }) } return [...pendingByThreadId.values()].sort((left, right) => left.rolloutStamp < right.rolloutStamp ? 1 : left.rolloutStamp > right.rolloutStamp ? -1 : 0 ) } +/** Thread id (lower-cased) and timestamp segment encoded in a rollout file name. */ +export function parseCodexRolloutThreadId( + filePath: string +): { threadId: string; rolloutStamp: string } | null { + const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(filePath)) + return match ? { threadId: match[2].toLowerCase(), rolloutStamp: match[1] } : null +} + function lastPathSegment(filePath: string): string { return filePath.split(/[\\/]/).at(-1) ?? '' } diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 20051fe1058..1be3d047207 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -10,10 +10,12 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import type { CodexAppServerInvocation } from './codex-app-server-session' import { createCodexSessionBackfillAuditWriter } from './codex-session-backfill-audit' import { CODEX_SESSION_INDEX_HEAL_VERSION } from './codex-session-index-heal-state' import { + buildNativeHealInvocation, runCodexSessionIndexHeal, type CodexSessionIndexHealPaths } from './codex-session-index-heal' @@ -340,6 +342,7 @@ describe('runCodexSessionIndexHeal', () => { const marker = JSON.parse(readFileSync(rig.paths.healMarkerPath, 'utf-8')) as { retryableFailureAt: number } + expect(marker.retryableFailureAt).toEqual(expect.any(Number)) marker.retryableFailureAt = 0 writeFileSync(rig.paths.healMarkerPath, `${JSON.stringify(marker)}\n`, 'utf-8') const retried = await runCodexSessionIndexHeal(rig.paths, { @@ -490,6 +493,25 @@ describe('runCodexSessionIndexHeal', () => { expect(resumed.healedThreads + summary.healedThreads).toBe(4) }) + it('writes no completion marker when stop flips inside the last batch', async () => { + const rig = createHealRig({ + auditedThreads: [ + { stamp: '2026-07-02T10-00-00', id: threadId('2') }, + { stamp: '2026-07-01T10-00-00', id: threadId('1') } + ] + }) + + const summary = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + readConcurrency: 1, + interBatchDelayMs: 0, + shouldStop: () => rig.readLog().threadIds.length > 0 + }) + + expect(summary).toMatchObject({ outcome: 'stopped', healedThreads: 1 }) + expect(existsSync(rig.paths.healMarkerPath)).toBe(false) + }) + it('does not spawn another server when stop flips during the inter-batch delay', async () => { const rig = createHealRig({ auditedThreads: [ @@ -777,3 +799,14 @@ describe('runCodexSessionIndexHeal', () => { warnSpy.mockRestore() }) }) + +describe('buildNativeHealInvocation', () => { + it('starts a read-only, plugin-free app-server pinned to the given home', () => { + const invocation = buildNativeHealInvocation('/codex-home', 1_000) + + for (const arg of CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS) { + expect(invocation.args).toContain(arg) + } + expect(invocation.env).toEqual({ CODEX_HOME: '/codex-home' }) + }) +}) diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 12747312646..364263f2f47 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -1,6 +1,7 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' +import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { getSpawnArgsForWindows } from '../win32-utils' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' @@ -17,7 +18,8 @@ import { import { isCodexAppServerUnsupportedError, runCodexAppServerSession, - type CodexAppServerInvocation + type CodexAppServerInvocation, + type CodexAppServerRpc } from './codex-app-server-session' export type { CodexSessionIndexHealPaths } from './codex-session-index-heal-state' @@ -49,12 +51,20 @@ export type CodexSessionIndexHealSummary = { export type CodexSessionIndexHealOptions = { /** Polled between reads and batches; true stops promptly, progress is kept. */ shouldStop?: () => boolean - buildInvocation?: (systemCodexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + buildInvocation?: (codexHomePath: string, timeoutMs: number) => CodexAppServerInvocation + runSession?: ( + invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ) => Promise readsPerServerSession?: number readConcurrency?: number interBatchDelayMs?: number } +export type CodexThreadReadOutcome = HealLedgerOutcome + +export type CodexThreadReadPassOutcome = 'completed' | 'stopped' | 'unsupported' | 'aborted' + let backgroundHealTask: Promise | null = null export function resolveCodexSessionIndexHealPaths( @@ -132,69 +142,30 @@ export async function runCodexSessionIndexHeal( return summary } - const systemCodexHomePath = dirname(paths.systemSessionsRoot) - const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation - const readsPerServerSession = resolveHealWorkLimit( - options.readsPerServerSession, - HEAL_READS_PER_SERVER_SESSION - ) - const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) - const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS const shouldStop = options.shouldStop ?? ((): boolean => false) - - for (let offset = 0; offset < pending.length; offset += readsPerServerSession) { - if (shouldStop()) { - summary.outcome = 'stopped' - return summary - } - if (offset > 0 && interBatchDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) - if (shouldStop()) { - // Why: opt-out can happen during the throttle delay; do not spawn a - // real-home app-server after the lane has been disabled. - summary.outcome = 'stopped' - return summary + const outcome = await readCodexThreadsForIndexHeal( + dirname(paths.systemSessionsRoot), + pending, + (thread, readOutcome) => { + if (readOutcome === 'healed') { + summary.healedThreads += 1 + } else if (readOutcome === 'missing') { + // The backfilled rollout was deleted after the audit was written. + summary.missingThreads += 1 + } else { + summary.failedThreads += 1 } - } - const batch = pending.slice(offset, offset + readsPerServerSession) - const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length - try { - await runCodexAppServerSession( - buildInvocation(systemCodexHomePath, timeoutMs), - async (rpc) => { - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < batch.length && !shouldStop()) { - const thread = batch[nextIndex] - nextIndex += 1 - await healOneThread(rpc, thread, paths, summary) - } - } - await Promise.all(Array.from({ length: readConcurrency }, () => worker())) - } - ) - } catch (error) { - if (isCodexAppServerUnsupportedError(error)) { - if (shouldStop()) { - summary.outcome = 'stopped' - return summary - } - // Why: no retry churn on old CLIs — remember unsupported and re-probe - // after the retry interval or a version bump; nothing is marked healed. - writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) - summary.outcome = 'unsupported' - return summary - } - // Transport failure (timeout, early exit, spawn error): unprocessed ids - // were never appended to the ledger, so the next pass resumes them. - console.warn('[codex-session-index-heal] Heal batch aborted:', error) - summary.outcome = 'aborted' - return summary - } + recordHealOutcome(paths, thread, readOutcome) + }, + options + ) + if (outcome === 'unsupported') { + // Why: no retry churn on old CLIs — remember unsupported and re-probe + // after the retry interval or a version bump; nothing is marked healed. + writeHealMarker(paths, auditBytes, summary, { unsupportedAt: Date.now() }) } - - if (shouldStop()) { - summary.outcome = 'stopped' + if (outcome !== 'completed' || shouldStop()) { + summary.outcome = outcome === 'completed' ? 'stopped' : outcome return summary } writeHealMarker( @@ -206,16 +177,76 @@ export async function runCodexSessionIndexHeal( return summary } -async function healOneThread( - rpc: { request: (method: string, params?: Record) => Promise }, - thread: PendingHealThread, - paths: CodexSessionIndexHealPaths, - summary: CodexSessionIndexHealSummary -): Promise { +/** + * Drives `thread/read` over `threads` in bounded app-server batches, reporting + * each settled read. Transport failures and sqlite contention abort the pass + * without reporting, so the caller's next pass retries those threads. + */ +export async function readCodexThreadsForIndexHeal( + codexHomePath: string, + threads: readonly T[], + onOutcome: (thread: T, outcome: CodexThreadReadOutcome) => void, + options: CodexSessionIndexHealOptions = {} +): Promise { + const buildInvocation = options.buildInvocation ?? buildNativeHealInvocation + const runSession = options.runSession ?? runCodexAppServerSession + const readsPerServerSession = resolveHealWorkLimit( + options.readsPerServerSession, + HEAL_READS_PER_SERVER_SESSION + ) + const readConcurrency = resolveHealWorkLimit(options.readConcurrency, HEAL_READ_CONCURRENCY) + const interBatchDelayMs = options.interBatchDelayMs ?? HEAL_INTER_BATCH_DELAY_MS + const shouldStop = options.shouldStop ?? ((): boolean => false) + + for (let offset = 0; offset < threads.length; offset += readsPerServerSession) { + if (shouldStop()) { + return 'stopped' + } + if (offset > 0 && interBatchDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, interBatchDelayMs)) + if (shouldStop()) { + // Why: opt-out can happen during the throttle delay; do not spawn an + // app-server after the lane has been disabled. + return 'stopped' + } + } + const batch = threads.slice(offset, offset + readsPerServerSession) + const timeoutMs = HEAL_BATCH_TIMEOUT_BASE_MS + HEAL_BATCH_TIMEOUT_PER_READ_MS * batch.length + try { + await runSession(buildInvocation(codexHomePath, timeoutMs), async (rpc) => { + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < batch.length && !shouldStop()) { + const thread = batch[nextIndex] + nextIndex += 1 + onOutcome(thread, await readOneThread(rpc, thread.threadId)) + } + } + await Promise.all(Array.from({ length: readConcurrency }, () => worker())) + }) + } catch (error) { + if (shouldStop()) { + return 'stopped' + } + if (isCodexAppServerUnsupportedError(error)) { + return 'unsupported' + } + // Transport failure (timeout, early exit, spawn error): unprocessed ids + // were never reported, so the next pass resumes them. + console.warn('[codex-session-index-heal] Heal batch aborted:', error) + return 'aborted' + } + } + return 'completed' +} + +async function readOneThread( + rpc: CodexAppServerRpc, + threadId: string +): Promise { try { - await rpc.request('thread/read', { threadId: thread.threadId }) - summary.healedThreads += 1 - recordHealOutcome(paths, thread, 'healed') + await rpc.request('thread/read', { threadId }) + return 'healed' } catch (error) { if (isCodexAppServerUnsupportedError(error)) { throw error @@ -223,22 +254,18 @@ async function healOneThread( const message = error instanceof Error ? error.message : String(error) if (!message.startsWith('codex app-server thread/read failed')) { // Not an RPC-level response: the server died or timed out. Abort the - // batch without recording, so the id is retried on the next pass. + // batch without reporting, so the id is retried on the next pass. throw error } if (/no rollout found/i.test(message)) { - // The backfilled rollout was deleted after the audit was written. - summary.missingThreads += 1 - recordHealOutcome(paths, thread, 'missing') - return + return 'missing' } if (isTransientSqliteContention(message)) { - // Why: an active Codex process can briefly own sqlite; leave the id off - // the ledger and abort this pass so a later startup resumes it. + // Why: an active Codex process can briefly own sqlite; leave the id + // unreported and abort this pass so a later startup resumes it. throw error } - summary.failedThreads += 1 - recordHealOutcome(paths, thread, 'failed') + return 'failed' } } @@ -259,20 +286,23 @@ function resolveHealWorkLimit(value: number | undefined, maximum: number): numbe return Math.min(Math.floor(value), maximum) } -function buildNativeHealInvocation( - systemCodexHomePath: string, +export function buildNativeHealInvocation( + codexHomePath: string, timeoutMs: number ): CodexAppServerInvocation { const command = resolveCodexCommand() - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server']) + // Why: each session is torn down after one batch, so plugin startup could + // leave marketplace clones running; indexing needs neither plugins nor tools. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ + ...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS + ]) return { command: spawnCmd, args: spawnArgs, cliPath: command, - // Why: pin the real home explicitly — nested Orca launches can inherit a - // managed CODEX_HOME from the daemon environment, which would index the - // wrong sqlite DB. - env: { CODEX_HOME: systemCodexHomePath }, + // Why: pin the home explicitly — nested Orca launches can inherit a managed + // CODEX_HOME from the daemon environment, which would index the wrong sqlite DB. + env: { CODEX_HOME: codexHomePath }, timeoutMs } } diff --git a/src/main/codex/codex-state-db-test-fixture.ts b/src/main/codex/codex-state-db-test-fixture.ts new file mode 100644 index 00000000000..e84f0328c3b --- /dev/null +++ b/src/main/codex/codex-state-db-test-fixture.ts @@ -0,0 +1,14 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' + +/** Writes a Codex state DB whose startup backfill reports `status`. */ +export function writeCodexStateDbBackfillStatus(codexHomePath: string, status: string): void { + mkdirSync(codexHomePath, { recursive: true }) + const db = new SyncDatabase(join(codexHomePath, 'state_5.sqlite')) + db.exec( + 'CREATE TABLE backfill_state (id INTEGER PRIMARY KEY, status TEXT NOT NULL); ' + + `INSERT INTO backfill_state (id, status) VALUES (1, '${status}')` + ) + db.close() +} diff --git a/src/main/codex/codex-state-db.test.ts b/src/main/codex/codex-state-db.test.ts index 7cf0ac88ac5..a89a556b807 100644 --- a/src/main/codex/codex-state-db.test.ts +++ b/src/main/codex/codex-state-db.test.ts @@ -1,12 +1,13 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SyncDatabase from '../sqlite/sync-database' import { findNewestCodexStateDbPath, isCodexStateDbBackfillPending, - readCodexStateDbBackfillStatus + readCodexStateDbBackfillStatus, + readIndexedCodexThreadIds } from './codex-state-db' const temporaryHomes: string[] = [] @@ -67,6 +68,19 @@ describe('Codex state DB backfill status', () => { expect(isCodexStateDbBackfillPending(home)).toBe(true) }) + it('counts compressed rollouts, which Codex also backfills', async () => { + const home = await createHome() + const sessions = join(home, 'sessions', '2026', '08', '04') + await mkdir(sessions, { recursive: true }) + await Promise.all( + Array.from({ length: 100 }, (_, index) => + writeFile(join(sessions, `rollout-${index}.jsonl.zst`), '') + ) + ) + + expect(isCodexStateDbBackfillPending(home)).toBe(true) + }) + it('does not call a complete backfill pending', async () => { const home = await createHome() createBackfillDb(home, 5, 'complete') @@ -74,3 +88,27 @@ describe('Codex state DB backfill status', () => { expect(isCodexStateDbBackfillPending(home)).toBe(false) }) }) + +describe('readIndexedCodexThreadIds', () => { + it('returns lower-cased thread ids from the newest state DB', async () => { + const home = await createHome() + const path = createBackfillDb(home, 5, 'complete') + const db = new SyncDatabase(path) + db.exec( + "CREATE TABLE threads (id TEXT PRIMARY KEY); INSERT INTO threads (id) VALUES ('ABC'), ('def')" + ) + db.close() + + expect(readIndexedCodexThreadIds(home)).toEqual(new Set(['abc', 'def'])) + }) + + it('returns null when there is no state DB or no threads table', async () => { + const home = await createHome() + expect(readIndexedCodexThreadIds(home)).toBeNull() + + createBackfillDb(home, 5, 'complete') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(readIndexedCodexThreadIds(home)).toBeNull() + warn.mockRestore() + }) +}) diff --git a/src/main/codex/codex-state-db.ts b/src/main/codex/codex-state-db.ts index e2b437aea3a..ae8ff3eb046 100644 --- a/src/main/codex/codex-state-db.ts +++ b/src/main/codex/codex-state-db.ts @@ -71,6 +71,37 @@ export function readCodexStateDbBackfillStatus(codexHomePath: string): CodexStat } } +/** + * Lower-cased thread ids already in Codex's state DB, or null when the DB is + * missing or unreadable. Read-only; never creates or mutates Codex's database. + */ +export function readIndexedCodexThreadIds(codexHomePath: string): Set | null { + const stateDbPath = findNewestCodexStateDbPath(codexHomePath) + if (!stateDbPath) { + return null + } + let db: SyncDatabase | null = null + try { + db = new SyncDatabase(stateDbPath, { readonly: true, fileMustExist: true }) + const ids = new Set() + for (const row of db.prepare('SELECT id FROM threads').all()) { + if (typeof row.id === 'string') { + ids.add(row.id.toLowerCase()) + } + } + return ids + } catch (error) { + console.warn('[codex-state-db] Failed to read indexed Codex threads:', error) + return null + } finally { + try { + db?.close() + } catch { + // A close failure cannot change the read-only result already collected. + } + } +} + export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): number { let count = 0 const pendingDirectories = [sessionsRoot] @@ -85,7 +116,11 @@ export function countCodexSessionFilesUpTo(sessionsRoot: string, limit: number): for (const entry of entries) { if (entry.isDirectory()) { pendingDirectories.push(join(directory, entry.name)) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if ( + entry.isFile() && + // Why: Codex's startup backfill parses compressed rollouts too. + (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zst')) + ) { count += 1 if (count >= limit) { break diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index ad4548db7bd..f104a3ec343 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -15,6 +15,7 @@ import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { stopCodexAccountSessionBridges } from '../codex/codex-account-session-bridge' import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' @@ -134,6 +135,7 @@ function installWillQuitHandler(): void { state.pluginMarketplaceInstaller = null const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + stopCodexAccountSessionBridges() // Why before the stop: teardown stamps each working session's resume marker with why the app // went away, and an update install is a restart the user never chose. setStructuredAgentSessionTeardownTrigger(updateQuitInProgress ? 'update' : 'quit') From bb91e39eb961e2c63a839037f988d7c925ac5023 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:14:28 -0700 Subject: [PATCH 356/387] test(native-chat): main's Codex child tests expect a turn to end on its own turn/completed (#23783) * fix(native-chat): a Codex child's turn ends on its own turn/completed #22553 ended a child thread's turn on an `error` Codex will not retry, reading the verdict module #23682 deleted when it made turn/completed the only end of a Codex turn. The two landed minutes apart with no textual conflict, so main no longer typechecks. Codex runs every thread's events, spawned children included, through the same per-thread handler: a turn-ending error is recorded as the turn's last error and the turn then completes as failed. So a child's failed turn/completed is its end, as on the primary thread, and a closed thread stays the one child ending with no completion. * refactor(native-chat): a closed Codex child thread is its own frame With a child's turn now ending only on its own turn/completed, the turn-ended frame carried a fixed turnId (null) and state (unverifiable), and endTurn took parameters nothing passed. Name the one remaining case: a thread-closed frame, and closeThread ending the running turn as unverifiable. Drop a test step that no longer exercised anything. --- .../codex/codex-background-task-frames.ts | 32 ++++------ .../codex/codex-background-task-tracker.ts | 4 +- .../codex/codex-child-work-evidence.test.ts | 58 +++++++++---------- ...dex-structured-child-work-producer.test.ts | 7 ++- .../codex/codex-structured-thread-facts.ts | 8 --- src/main/codex/codex-subagent-executions.ts | 15 ++--- 6 files changed, 49 insertions(+), 75 deletions(-) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 029cc7b50c7..f01863956b6 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -7,7 +7,7 @@ import { import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' -import { readCodexErrorWillRetry, readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexTurnId } from './codex-structured-thread-facts' export type CodexBackgroundTaskFrame = | { @@ -25,15 +25,11 @@ export type CodexBackgroundTaskFrame = state: NativeChatSubagentState } | { - /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */ - kind: 'turn-ended' + /** A child thread that closed: it ran its last turn, and Codex never said how it went. */ + kind: 'thread-closed' threadId: string - turnId: string | null - state: CodexChildTurnEnding } -type CodexChildTurnEnding = Extract - export type CodexBackgroundTaskEvent = { method: string threadId: string @@ -41,24 +37,16 @@ export type CodexBackgroundTaskEvent = { } /** - * The two ways Codex ends a child's turn without `turn/completed`. An `error` it will not retry is - * that turn's own end: for a child, `turn/completed` may never follow, and without this end the - * child's lifecycle row latches on `working` for the life of the session. A closed thread ran its - * last turn, and Codex never said how it went. - * - * #23682 dropped the equivalent reading from the primary journal path, where Codex's own failed - * `turn/completed` always follows within ~32 ms and carries the duration the error lacks. A child - * has no such guarantee, which is why this path still settles on the error. + * The one way Codex ends a child's turn without `turn/completed`: a closed thread ran its last + * turn and Codex never said how it went. A turn-ending `error` is not one — Codex follows it with + * a failed `turn/completed` for the same turn, which is that turn's end and carries the duration + * and receipt time the error does not. */ -function readCodexChildTurnEnding( +function readCodexChildThreadClosed( event: CodexBackgroundTaskEvent ): CodexBackgroundTaskFrame | null { - if (event.method === 'error' && !readCodexErrorWillRetry(event.params)) { - const turnId = readCodexTurnId(event.params) - return { kind: 'turn-ended', threadId: event.threadId, turnId, state: 'failed' } - } return event.method === 'thread/closed' - ? { kind: 'turn-ended', threadId: event.threadId, turnId: null, state: 'unverifiable' } + ? { kind: 'thread-closed', threadId: event.threadId } : null } @@ -67,7 +55,7 @@ export function readCodexBackgroundTaskFrame( primaryThreadId: string ): CodexBackgroundTaskFrame | null { // The session's own turn ends through the journal's turn boundaries, never here. - const ending = event.threadId === primaryThreadId ? null : readCodexChildTurnEnding(event) + const ending = event.threadId === primaryThreadId ? null : readCodexChildThreadClosed(event) if (ending) { return ending } diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 351b35e6b84..6429a84d4fd 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -80,8 +80,8 @@ export class CodexBackgroundTaskTracker { frame.parentTurnId, frame.spawnerThreadId ) - } else if (frame?.kind === 'turn-ended') { - this.executions.endTurn(frame.threadId, frame.turnId, frame.state) + } else if (frame?.kind === 'thread-closed') { + this.executions.closeThread(frame.threadId) } else if (frame && frame.threadId !== this.primaryThreadId) { this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) } diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 9013b07a674..5b1b1bc29b4 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -159,18 +159,10 @@ describe('Codex child-work evidence', () => { expect(tracker.state).toBeNull() }) - const childError = ( - turnId: string | undefined, - willRetry: boolean - ): CodexBackgroundTaskEvent => ({ + const childError = (turnId: string, willRetry: boolean): CodexBackgroundTaskEvent => ({ method: 'error', threadId: CHILD, - params: { - threadId: CHILD, - ...(turnId ? { turnId } : {}), - willRetry, - error: { message: 'boom' } - } + params: { threadId: CHILD, turnId, willRetry, error: { message: 'boom' } } }) const childClosed: CodexBackgroundTaskEvent = { method: 'thread/closed', @@ -178,25 +170,30 @@ describe('Codex child-work evidence', () => { params: { threadId: CHILD } } - it.each([ - ['an error naming its turn that Codex will not retry', childError('c1', false), 'failed'], - ['an error naming no turn that Codex will not retry', childError(undefined, false), 'failed'], - ['its thread closing', childClosed, 'unknown'] - ])( - 'settles a working child whose turn ended with no turn/completed, by %s, in the strip and the record together', - (_label, ending, outcome) => { - const { send, tracker, records } = runningChild() - expect(tracker.state?.tasks).toHaveLength(1) - send(ending) - expect(records()).toEqual([ - expect.objectContaining({ membership: 'settled', state: 'done', outcome }) - ]) - expect(tracker.state).toBeNull() - // The first ending a turn gets stands. - send(turn('turn/completed', CHILD, 'c1', 'completed')) - expect(records()).toEqual([expect.objectContaining({ outcome })]) - } - ) + it('keeps a child working through an error Codex will not retry, and settles it on the failed turn/completed that follows', () => { + const { send, tracker, records } = runningChild() + send(childError('c1', false)) + expect(records()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(tracker.state?.tasks).toHaveLength(1) + send(turn('turn/completed', CHILD, 'c1', 'failed')) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'failed' }) + ]) + expect(tracker.state).toBeNull() + }) + + it('settles a working child whose thread closed with no turn/completed, in the strip and the record together', () => { + const { send, tracker, records } = runningChild() + expect(tracker.state?.tasks).toHaveLength(1) + send(childClosed) + expect(records()).toEqual([ + expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'unknown' }) + ]) + expect(tracker.state).toBeNull() + // The first ending a turn gets stands. + send(turn('turn/completed', CHILD, 'c1', 'completed')) + expect(records()).toEqual([expect.objectContaining({ outcome: 'unknown' })]) + }) it('keeps a child working through a retried error and a systemError status: its turn runs on', () => { const { send, tracker, records } = runningChild() @@ -207,9 +204,6 @@ describe('Codex child-work evidence', () => { }) expect(records()).toEqual([expect.objectContaining({ membership: 'live', state: 'working' })]) expect(tracker.state?.tasks).toHaveLength(1) - // A fatal error naming a turn the child already finished ends nothing. - send(turn('turn/completed', CHILD, 'c1'), childError('c1', false)) - expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })]) }) it('never settles a child on its PARENT turn ending: children outlive the turn', () => { diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index fc011d48bb9..611b7f75b57 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -332,13 +332,18 @@ describe('Codex structured child-work producer', () => { { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, { frame: spawned(LINTER, 'lint', 'p2'), lead: 'working' }, { frame: turn('turn/started', LINTER, 'l1'), lead: 'working' }, - // Codex ends this child's turn with an error it will not retry, and no turn/completed. + // Codex ends this child's turn with an error it will not retry, then a failed completion. { frame: { method: 'error', params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } } }, lead: 'working', + check: () => expect(byDescription('lint')).toMatchObject({ membership: 'live' }) + }, + { + frame: turn('turn/completed', LINTER, 'l1', 'failed'), + lead: 'working', check: () => expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index aaf65f93ec6..f2a42e6d3db 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -65,14 +65,6 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { /** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a * bare string. `idle` and `systemError` are the two arms that mean the thread * is not running; `active` and `notLoaded` are not. */ -/** - * Codex hardcodes `willRetry: false` on an `error` that affects turn status, and `true` on a - * stream error it is about to retry, which ends nothing. - */ -export function readCodexErrorWillRetry(payload: unknown): boolean { - return record(payload)?.willRetry === true -} - export function codexThreadStoppedRunning(payload: unknown): boolean { const type = record(record(payload)?.status)?.type return type === 'idle' || type === 'systemError' diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 939d1b90331..87d9327c70c 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -94,17 +94,12 @@ export class CodexSubagentExecutions { return { child, execution } } - /** A child turn that ended with no `turn/completed`. With no turn named, the one the child is - * running ended; a child running none has nothing to end. The first ending a turn gets stands. */ - endTurn( - agentThreadId: string, - turnId: string | null, - state: Exclude - ): void { + /** The child's thread closed with no `turn/completed`: the turn it was running ended, and how + * it went is unknown. A child running none has nothing to end. */ + closeThread(agentThreadId: string): void { const current = this.children.get(agentThreadId)?.execution - const ended = turnId ?? (current?.state === 'working' ? current.turnId : null) - if (current && ended !== null) { - this.observeTurn(agentThreadId, ended, state) + if (current?.state === 'working') { + this.observeTurn(agentThreadId, current.turnId, 'unverifiable') } } From 36c473ea1ad30b68a7f09035bf7eeb74e8ff438d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:16:20 -0700 Subject: [PATCH 357/387] fix(runtime): wait out Codex 0.157's startup screen, and stop at Codex's startup dialogs, before typing a worker brief (#23745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): wait for Codex's live chat before typing a worker brief Codex 0.157 draws a provisional startup screen (header reads model: loading) and discards typed input while it starts its shared daemon behind it; a fresh Codex home makes that window seconds long, so worker-start pasted briefs that were truncated or never submitted. Codex 0.158 dropped the header labels Orca matched, so worker-start stopped seeing Codex as ready at all. Readiness now requires Codex's live chat on both layouts: the provisional header vetoes a text match unless the live status row is already painted, and 0.158's greeting layout counts once that status row appears. Codex 0.158's model announcement dialog is reported as a blocked prompt instead of receiving the brief. * fix(runtime): recognise Codex's provisional screen from the text copy and the screen probe Live worker-start on a fresh Codex 0.157 home still typed during the daemon start: Codex leaves its alternate screen for that window, so the live screen showed no header and the screen-based veto never fired. The text copy keeps the provisional header until the live chat paints its status row, so the veto now reads it there. The tui-idle visible-screen probe used the bare text rule on the rendered screen; it now goes through the same body rule. * test(daemon): register the new Codex captures' known serializer divergences The serialize round-trip replay picks up every fixture under runtime/__fixtures__, and the three new Codex 0.157/0.158 captures showed 48/9/8 "new-fail" checkpoints against an expected 0, turning CI red. They are the existing live-pen colour leak on restored cells, the same class as the other Codex and DSH entries; this branch changes no serializer code. * fix(runtime): keep Qoder off the Codex screen probe change; drop an unbacked row filter - The tui-idle visible-screen probe now classified Qoder panes with isQoderComposerReady, which skips the working veto evaluateTuiIdle applies first. Qoder paints its composer mid-turn, so an adopted Qoder pane whose hooks said "working" settled the wait immediately. Only Codex and unknown panes take the body rule there; every other agent keeps its old verdict. - The live status-row check skipped rows containing "waiting for startup", a string Codex 0.157/0.158's TUI never prints. The line-folded text copy keeps a whole screen on one line, so the filter could only ever veto the real status row. It is now a bounded includes() with no split. - Lowercase the wait text once in isKnownReadyPromptBody. - Restore the per-frame "screen never takes a settled header away" check, guarded on the provisional veto, instead of checking the final frame only. * fix(runtime): stop reporting Codex 0.158's model announcement once it is answered The announcement's choices stay in the text copy after the user answers it, and the existing dismissal check needed the model:/directory: labels that 0.158's header lacks, so tui-idle waits and the agent-status query kept reporting codex-model-migration-prompt over a live chat. Codex repaints its whole screen, header included, when a startup dialog closes, so the header after the dialog now marks it answered. Also corrects the live-chat marker comments: the middle dot also comes from the daemon session's agents hint row and the warnings notice, not only the status row. * docs(runtime): note that Codex startup dialogs also draw the live-footer dot * fix(runtime): recognise Codex 0.157/0.158 startup dialogs by the rows they really print Codex 0.157 and 0.158 no longer print `Press enter to continue/confirm` on their startup dialogs; they print key rows instead (`enter continue · esc skip`, `enter confirm · esc skip`, `enter/esc continue · ctrl+c quit`). The update, hooks-review and model-migration matchers still required the old wording, so none of these dialogs was reported as blocked. On 0.157 the dialog's `·` also satisfied the live-footer check, so a tui-idle wait read the update dialog as ready and worker-start would type the brief into it, where Enter picks "Update now" (npm install -g, Codex exits). On 0.158 the wait timed out instead of reporting blocked. The matchers now accept the old wording or the new row, tolerating the spaces the line-folded text copy drops around `·`. Each one matches from the dialog's first `·` (for the update dialog that is its title row, `Update available · 0.157.0 → …`), so the dialog is blocked from the same character that would otherwise make the provisional header read as live. The retired-model notice without choices has a catalog-supplied heading (`GPT-5.4 is no longer available`), so it is matched by its own key row. No new blocked-reason value. The startup-dialog matchers move to startup-dialog-blocked-signals.ts to keep terminal-wait-detection.ts under the line limit. Backed by six real captures (update available, hooks review, retired model without choices, each on 0.157.0 and 0.158.0), replayed frame by frame and through a tui-idle wait; the serializer round-trip replay registers their existing live-pen colour divergences. * fix(runtime): keep reporting Codex's retired-model notice after a relaunch in the same pane The retired-model notice is matched by its key row alone, and the matcher took the first `enter/esc continue ·` in the live window while every other startup-dialog matcher takes the last. Quitting Codex from the notice and relaunching it in the same pane leaves the old copy ahead of the new launch's header, so the header read as having dismissed the new notice: 0.157 then read ready and a worker brief would be typed into the dialog. Take the last key row, and replay each captured dialog quit-and-relaunched to pin all six. * fix(runtime): match Codex startup dialogs by the rows the text copy keeps intact Codex 0.157+ paints each startup dialog over its startup screen by cell diff, so Orca's line-folded text copy can drop letters and spaces from a heading: #23765's 0.157.1 capture reads `Updat available`. The update matcher needed `update available`, so on that capture tier 1 read the dialog's own `·` as the live chat's footer and a tui-idle wait settled ready on the update dialog, whose Enter picks "Update now". Match each dialog from its first `·` by rows Codex prints as fixed literals: `available · ` and `enter continue · esc skip` (update), `enter confirm ·` (hooks review), `enter/esc continue|confirm ·` (model notices, which also covers 0.158's new-model announcement, so its choice-text matcher goes). Legacy `Press enter to …` wording still matches. Add the new rows to the blocked-signal prefilter, and replay #23765's 0.157.1 update capture in the dialog suite. * fix(runtime): don't name Codex's mid-session pickers a hooks review Codex's rate-limit reset popup (and its other pickers) end their key row with `enter confirm · esc back`, which the hooks-review row matched now that it no longer needs the heading. Exclude `esc back` instead of requiring `esc skip`, so a half-painted hooks-review row still blocks. --- .../serialize-grid-transcript-replay.test.ts | 11 + ...x-0157-fresh-home-daemon-install.meta.json | 9 + .../codex-0157-fresh-home-daemon-install.txt | 2 + .../codex-0157-hooks-review-dialog.meta.json | 9 + .../codex-0157-hooks-review-dialog.txt | 2 + .../codex-0157-model-retired-dialog.meta.json | 9 + .../codex-0157-model-retired-dialog.txt | 2 + ...dex-0157-update-available-dialog.meta.json | 9 + .../codex-0157-update-available-dialog.txt | 2 + .../codex-0158-fresh-home-greeting.meta.json | 9 + .../codex-0158-fresh-home-greeting.txt | 1 + .../codex-0158-hooks-review-dialog.meta.json | 9 + .../codex-0158-hooks-review-dialog.txt | 1 + ...x-0158-model-announcement-dialog.meta.json | 9 + .../codex-0158-model-announcement-dialog.txt | 1 + .../codex-0158-model-retired-dialog.meta.json | 9 + .../codex-0158-model-retired-dialog.txt | 1 + ...dex-0158-update-available-dialog.meta.json | 9 + .../codex-0158-update-available-dialog.txt | 1 + ...codex-header-readiness-transcripts.test.ts | 102 ++++++++- .../codex-startup-dialog-transcripts.test.ts | 198 ++++++++++++++++++ src/main/runtime/codex-terminal-readiness.ts | 21 ++ ...ntime-start-tui-idle-visible-read-probe.ts | 4 +- .../runtime/startup-dialog-blocked-signals.ts | 76 +++++++ src/main/runtime/terminal-wait-detection.ts | 45 ++-- 25 files changed, 521 insertions(+), 30 deletions(-) create mode 100644 src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt create mode 100644 src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt create mode 100644 src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt create mode 100644 src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt create mode 100644 src/main/runtime/codex-startup-dialog-transcripts.test.ts create mode 100644 src/main/runtime/startup-dialog-blocked-signals.ts diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 298aaa681bf..cbe9b5db2c0 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -43,6 +43,17 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'codex-0157-effort-override-embedded-warning': 4, 'codex-0157-no-daemon-effort-override': 16, 'codex-0157-plain-ready': 18, + // Fresh-home 0.157/0.158 captures: the live pen's true-colour fg/bg leaks onto restored cells. + 'codex-0157-fresh-home-daemon-install': 48, + 'codex-0158-fresh-home-greeting': 9, + 'codex-0158-model-announcement-dialog': 8, + // Codex 0.157/0.158 startup-dialog captures: the same live-pen true-colour leak onto restored cells. + 'codex-0157-update-available-dialog': 26, + 'codex-0158-update-available-dialog': 8, + 'codex-0157-hooks-review-dialog': 24, + 'codex-0158-hooks-review-dialog': 8, + 'codex-0157-model-retired-dialog': 22, + 'codex-0158-model-retired-dialog': 6, // Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures). 'codex-0-157-1-update-dialog': 16, 'codex-0-158-0-approval': 12, diff --git a/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json new file mode 100644 index 00000000000..04203db4fbe --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:45:00.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch (no -m/-c) on a fresh CODEX_HOME. The provisional screen reads `model: loading` while Codex installs and starts its shared daemon (`Installing daemon from CLI version 0.157.0`); 0.157 discards typed input during that start. The live header fills in at the end. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt new file mode 100644 index 00000000000..a6aa2c9af2c --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-fresh-home-daemon-install.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l─────────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/x157fresh/repo ││ permissions: YOLO mode │╰────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/x157fresh/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭────────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/x157fresh/repo ││ permissions: YOLO mode │╰────────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;repo[?2026h[?25h[?2026l[?2026h[?25lTip: Visit the ]8;;https://community.openai.com/c/codex/37Codex community forum]8;; (]8;;https://community.openai.com/c/codex/37https://community.openai.com/c/codex/37]8;;).GPT-6-Astra default · /Users/user1/cr/rounds/x157fresh/repo←agens · ? for shortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ repo]0;repo[?2026h[?25lGPT-6-Astra /model to change⠈⢀⠁⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⡀⠠⠁⢀⠁[?25h[?2026l[?2026h[?25l⠄[?25h[?2026l[?2026h[?25l⠄[?25h[?2026l[?2026h[?25l⠈⢀⠁⠐⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⠄⡀⠈⠠⠁⢀⠁[?25h[?2026l[?2026h[?25l⠈⢀⠁⠐⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⠄⡀⠈⠠⠁⢀[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json new file mode 100644 index 00000000000..ebc2a471a20 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:27.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt new file mode 100644 index 00000000000..14b1ad743e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-hooks-review-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-h57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-h57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json new file mode 100644 index 00000000000..0e97bbe4715 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:31:06.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets model = \"gpt-5.4-mini\", a model missing from 0.157's catalog, so Codex shows its retired-model notice without choices: `GPT-5.4 Mini is no longer available`, `Switch to GPT-6 Luna to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model; the heading comes from the model catalog, not the older `Codex just got an upgrade` wording. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt new file mode 100644 index 00000000000..776b261a2d6 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-model-retired-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-m57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-m57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 Mini is no longer available  Codex now uses GPT-6 Luna in place of GPT-5.4 Mini. Switch to GPT-6 Luna to continue. enter/esc continue · ctrl+c quit     [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json new file mode 100644 index 00000000000..a81301d2215 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:29:52.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.157.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.157.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt new file mode 100644 index 00000000000..4701e63af24 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0157-update-available-dialog.txt @@ -0,0 +1,2 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[<1u[?1007l[?1049l[<1u[>4;0m[?2004l[?1004l[0 q[?25hInstalling daemon from CLI version 0.157.0 into /Users/user1/cr/rounds/dlg-u57/home/.codex/packages/app-server-daemon... +[?2004h[>4;0m[>7u[?1004h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭──────────────────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.0) ││ ││ model: loading /model to change ││ directory: /Users/user1/cr/rounds/dlg-u57/repo ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯ › Ask Codex to do anything ? for shortcuts[0 q╭[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.157.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json new file mode 100644 index 00000000000..ea7d9513a9f --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:44:00.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch (no -m/-c) on a fresh CODEX_HOME. Greeting layout: an unboxed `>_ OpenAI Codex (v0.158.0)` header with no model:/directory: labels, starting from a provisional screen whose directory reads `loading`; the live status row under the composer appears last. Braille logo animation frames included. Cut 4.6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt new file mode 100644 index 00000000000..72670b00f4b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-fresh-home-greeting.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingShall we see where this goes?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/x158fresh/repo permissions: YOLO modeShall we see where this goes?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;repo[?2026h[?25h[?2026l[?2026h[?25lGPT-6-Astra default · /Users/user1/cr/rounds/x158fresh/repo←agens · ? for shortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ repo[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ repo[?2026h[?25h[?2026l]0;repo[?2026h[?25l⠈⢀⠁⠂⠄⠈⠁⠈⠁⠈⢀⠠⠄⠠⢀⠠⡀⠠⠁⢀⠁[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json new file mode 100644 index 00000000000..896d3474482 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:46.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME with a hooks.json of 8 command hooks that were never trusted. Over the provisional screen Codex paints `Hooks need review`, `1. Review hooks` / `2. Trust all and continue` / `3. Continue without trusting (hooks won't run)` and the key row `enter confirm · esc skip`, where older builds wrote `Press enter to confirm`. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt new file mode 100644 index 00000000000..ad25e6eda6e --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-hooks-review-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingBring your unfinished thoughts.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-h58/repo permissions: YOLO modeBring your unfinished thoughts.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Hooks need review 8 hooks are new or changed. Hooks can run outside the sandbox after you trust them.  › 1. Review hooks  2. Trust all and continue 3. Continue without trusting (hooks won't run) enter confirm · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json new file mode 100644 index 00000000000..05790b10e95 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T21:44:30.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets an older model with an available upgrade. After the provisional screen, Codex paints its model announcement (`Meet GPT-6 Sol`, `1. Try new model` / `2. Use existing model`, `enter/esc confirm`), which owns Enter. Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt new file mode 100644 index 00000000000..a5c88157d74 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-announcement-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHere for a quick fix or the extended edition?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/x158mig/repo permissions: YOLO modeHere for a quick fix or the extended edition?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Meet GPT-6 Sol  Our latest Sol is more intelligent and more efficient so your usage limits go further. This model is a great daily driver for complex tasks, especially coding. › 1. Try new model  2. Use existing model  enter/esc confirm · ctrl+c quit    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json new file mode 100644 index 00000000000..92137927416 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:31:56.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets model_provider = \"amazon-bedrock\" and model = \"openai.gpt-5.4\" (0.158 keeps the notice for a model missing from its catalog only on the OpenAI and Bedrock providers, and the OpenAI provider asks for a sign-in first, which the rig does not have). Codex shows the notice without choices: `GPT-5.4 on Amazon Bedrock is no longer offered in Codex`, `Switch to GPT-6 Sol on Amazon Bedrock to continue.` and the key row `enter/esc continue · ctrl+c quit`. Enter or Esc switches the model. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt new file mode 100644 index 00000000000..fd9fac82c43 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-model-retired-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingBring a question. Bonus points if it’s a weird one.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-m58b/repo permissions: YOLO modeBring a question. Bonus points if it’s a weird one.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  GPT-5.4 on Amazon Bedrock is no longer offered in Codex  Codex now uses GPT-6 Sol on Amazon Bedrock in place of GPT-5.4 on Amazon Bedrock. Switch to GPT-6 Sol on Amazon Bedrock to continue. enter/esc continue · ctrl+c quit    [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json new file mode 100644 index 00000000000..db18029d308 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-29T03:30:10.000Z", + "platform": "darwin", + "command": ["codex", "--dangerously-bypass-approvals-and-sandbox"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.158.0, plain launch on a fresh CODEX_HOME whose config.toml sets check_for_update_on_startup = true, with a version.json cache naming 0.159.0 checked moments earlier (so no network check) and CODEX_MANAGED_BY_NPM=1 so Codex offers an update command. Over the provisional screen Codex paints `Update available · 0.158.0 → 0.159.0`, `1. Update now (runs `npm install -g @openai/codex`)` / `2. Skip` / `3. Skip until next version` and the key row `enter continue · esc skip`, where older builds wrote `Press enter to continue`. Enter picks Update now. No key was sent (only automatic terminal-query replies). Cut 6 s after spawn. Home-directory user name redacted by hand, same length.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt new file mode 100644 index 00000000000..2c92e766ae2 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0158-update-available-dialog.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHere for a quick fix or the extended edition?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉ › Ask Codex to do anything ? for shortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/user1/cr/rounds/dlg-u58/repo permissions: YOLO modeHere for a quick fix or the extended edition?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Update available · 0.158.0 → 0.159.0 Release notes: ]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next version  enter continue · esc skip    [?2026l \ No newline at end of file diff --git a/src/main/runtime/codex-header-readiness-transcripts.test.ts b/src/main/runtime/codex-header-readiness-transcripts.test.ts index 0cfcf548017..ea8dc03cef2 100644 --- a/src/main/runtime/codex-header-readiness-transcripts.test.ts +++ b/src/main/runtime/codex-header-readiness-transcripts.test.ts @@ -5,7 +5,11 @@ import { replayTranscript, type TranscriptReplayFrame } from './agent-transcript-replay-test-harness' -import { isKnownReadyPromptBody, isKnownReadyPromptPreview } from './terminal-wait-detection' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isKnownReadyPromptSettled +} from './terminal-wait-detection' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -19,7 +23,9 @@ const PLAIN = 'codex-0157-plain-ready' const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning' const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning' const NO_DAEMON = 'codex-0157-no-daemon-effort-override' -const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON] +// Fresh CODEX_HOME: the provisional header stays up while Codex installs and starts its daemon. +const FRESH_HOME = 'codex-0157-fresh-home-daemon-install' +const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON, FRESH_HOME] async function finalFrame( name: string, @@ -41,6 +47,16 @@ function screenShowsLoadingHeader(screenLines: string[]): boolean { return screen.includes('openai codex') && /(?:model|directory):\s+loading/.test(screen) } +// Codex's default status row opens with ` ·`; only the live chat paints it. +const LIVE_STATUS_ROW_RE = /\b(?:default|minimal|low|medium|high|xhigh) ·/ + +function screenShowsProvisionalStartup(screenLines: string[]): boolean { + return ( + screenShowsLoadingHeader(screenLines) && + !screenLines.some((line) => LIVE_STATUS_ROW_RE.test(line.toLowerCase())) + ) +} + describe('Codex 0.157 header readiness from captured bytes', () => { it.each([EFFORT_OVERRIDE, CONFIG_OVERRIDE])( '%s: the line-folded wait text never shows a ready header', @@ -71,6 +87,26 @@ describe('Codex 0.157 header readiness from captured bytes', () => { } ) + // Why: 0.157 discards input typed during its daemon start, behind the provisional header. + it.each(ALL_FIXTURES)( + '%s: never ready while the screen shows the provisional `model: loading` startup screen', + async (name) => { + let sawTextOnlyReadiness = false + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + if (screenShowsProvisionalStartup(frame.screenLines)) { + sawTextOnlyReadiness ||= isKnownReadyPromptPreview(frame.waitText) + expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + } + } + // Presence precondition for the text-copy fixtures: the text rules alone would say ready here. + if (name === PLAIN || name === FRESH_HOME) { + expect(sawTextOnlyReadiness).toBe(true) + } + } + ) + // Why these sizes: grids out of step with the 120x40 recording garble the header (review of #23475). describe.each([ [120, 40], @@ -78,15 +114,21 @@ describe('Codex 0.157 header readiness from captured bytes', () => { [30, 50], [108, 30], [60, 5] - ])('at %ix%i the screen never takes readiness away from the text rules', (cols, rows) => { + ])('at %ix%i the screen never takes a settled header away from the text rules', (cols, rows) => { it.each(ALL_FIXTURES)('%s', async (name) => { + let settledFrames = 0 for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) { - if (isKnownReadyPromptPreview(frame.waitText)) { + if (isKnownReadyPromptSettled(frame.waitText)) { + settledFrames += 1 expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( true ) } } + // Presence precondition: the text-copy fixtures reach a settled header. + if (name === PLAIN || name === FRESH_HOME || name === NO_DAEMON) { + expect(settledFrames).toBeGreaterThan(0) + } }) }) @@ -175,6 +217,58 @@ describe('Codex 0.157 header readiness from captured bytes', () => { 15_000 ) + it('does not settle while Codex is still installing its daemon behind the provisional screen', async () => { + const data = readRuntimeFixture(FRESH_HOME) + const install = data.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = data.slice(0, data.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: provisional, + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + // Why no bytes: worker-start waits on a pane that has printed nothing yet, which is when the + // runtime asks for a visible-screen read instead of its own text copy. + it('does not settle from a visible-screen read of the provisional screen', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '', + size: { cols: 120, rows: 40 } + }) + const readVisibleScreen = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: [ + '╭──────────────────────────────────────────╮', + '│ >_ OpenAI Codex (v0.157.0) │', + '│ model: loading /model to change │', + '│ directory: ~/repo/app │', + '╰──────────────────────────────────────────╯', + '› Ask Codex to do anything', + ' ? for shortcuts' + ], + truncated: false, + nextCursor: null, + source: 'screen' + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + // Presence precondition: the visible-screen probe actually ran. + expect(readVisibleScreen).toHaveBeenCalled() + }, 15_000) + it('keeps timing out on the garbled 80x24 default grid, as before', async () => { const { runtime, handle } = await codexPane(EFFORT_OVERRIDE) await expect( diff --git a/src/main/runtime/codex-startup-dialog-transcripts.test.ts b/src/main/runtime/codex-startup-dialog-transcripts.test.ts new file mode 100644 index 00000000000..01dbe447304 --- /dev/null +++ b/src/main/runtime/codex-startup-dialog-transcripts.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + readRuntimeFixture, + replayTranscript, + type TranscriptReplayFrame +} from './agent-transcript-replay-test-harness' +import { detectTerminalWaitBlockedReason, isKnownReadyPromptBody } from './terminal-wait-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +type StartupDialog = { + name: string + reason: RuntimeTerminalWaitBlockedReason + heading: string + keyRow: string +} + +// codex-cli 0.157.0 / 0.158.0 recordings at 120x40 on a fresh CODEX_HOME (see each .meta.json). +// Each dialog owns Enter, and none prints the `Press enter to …` wording older builds did. +const DIALOGS: StartupDialog[] = [ + { + // Recorded separately on 0.157.1 (STA-8834). + name: 'codex-0-157-1-update-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0158-update-available-dialog', + reason: 'agent-update-prompt', + heading: 'Update available ·', + keyRow: 'enter continue · esc skip' + }, + { + name: 'codex-0157-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0158-hooks-review-dialog', + reason: 'agent-hooks-review-prompt', + heading: 'Hooks need review', + keyRow: 'enter confirm · esc skip' + }, + { + name: 'codex-0157-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer available', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-retired-dialog', + reason: 'codex-model-migration-prompt', + heading: 'is no longer offered', + keyRow: 'enter/esc continue · ctrl+c quit' + }, + { + name: 'codex-0158-model-announcement-dialog', + reason: 'codex-model-migration-prompt', + heading: 'Try new model', + keyRow: 'enter/esc confirm · ctrl+c quit' + } +] +const DIALOGS_0158 = DIALOGS.filter((dialog) => dialog.name.startsWith('codex-0158-')) +const LIVE_CHAT_FIXTURES = [ + 'codex-0157-plain-ready', + 'codex-0157-effort-override-embedded-warning', + 'codex-0157-config-override-embedded-warning', + 'codex-0157-no-daemon-effort-override', + 'codex-0157-fresh-home-daemon-install', + 'codex-0158-fresh-home-greeting' +] + +function screenText(frame: TranscriptReplayFrame): string { + return frame.screenLines.join('\n') +} + +async function lastFrame(data: string): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(data, 120, 40)) { + last = frame + } + return last +} + +// Why stitched: no capture spans answering a dialog. Codex repaints every cell once a startup +// dialog closes, so the 0.158 greeting capture's paints stand in for that repaint. +function answered(name: string): string { + const greeting = readRuntimeFixture('codex-0158-fresh-home-greeting') + return readRuntimeFixture(name) + greeting.slice(greeting.indexOf('\x1b[?2026h')) +} + +describe('Codex 0.157/0.158 startup dialogs from captured bytes', () => { + it.each(DIALOGS)( + '$name: reports $reason from its key row on, and never reads ready while it is up', + async ({ name, reason, heading, keyRow }) => { + let headingFrames = 0 + let keyRowFrames = 0 + for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenText(frame) + if (screen.includes(heading)) { + headingFrames += 1 + expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe( + false + ) + } + if (keyRowFrames > 0 || screen.includes(keyRow)) { + keyRowFrames += 1 + expect(detectTerminalWaitBlockedReason(frame.waitText)).toBe(reason) + } + } + // Presence precondition: the dialog and its key row were painted, not just parsed. + expect(headingFrames).toBeGreaterThan(0) + expect(keyRowFrames).toBeGreaterThan(0) + } + ) + + it.each(DIALOGS_0158)( + '$name: stops reporting once Codex repaints its chat after it', + async ({ name, heading }) => { + const last = await lastFrame(answered(name)) + // Presence precondition: the answered dialog is still in the text copy. + expect(last?.waitText).toContain(heading.replace(' ·', '')) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBeNull() + } + ) + + it.each(DIALOGS)( + '$name: still reports $reason when Codex is relaunched in the same pane and shows it again', + async ({ name, reason }) => { + // Why: quitting Codex from a dialog leaves that copy in the text copy ahead of the relaunch. + const dialog = readRuntimeFixture(name) + const last = await lastFrame(`${dialog}\x1b[?1049l\r\n% codex\r\n${dialog}`) + expect(detectTerminalWaitBlockedReason(last?.waitText ?? '')).toBe(reason) + expect( + isKnownReadyPromptBody(last?.waitText ?? '', 'codex', () => last?.screenLines ?? null) + ).toBe(false) + } + ) + + it.each(LIVE_CHAT_FIXTURES)('%s: a live chat reports no dialog', async (name) => { + const waitText = (await lastFrame(readRuntimeFixture(name)))?.waitText ?? '' + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + // Why these lines: chat can name a dialog, and Codex's own update notice and footer draw `·`. + const chat = [ + '› is there an update available, and do my hooks need review?', + '✨ Update available! 0.158.0 -> 0.159.0', + 'Run npm install -g @openai/codex to update.', + ' gpt-6-astra default · ~/repo', + ' ← for agents · ? for shortcuts' + ].join('\n') + expect(detectTerminalWaitBlockedReason(`${waitText}\n${chat}`)).toBeNull() + }) + + it('does not name a mid-session Codex popup a hooks review', () => { + // Why: Codex's rate-limit reset popup (and other pickers) ends `enter confirm · esc back`. + const popup = [ + ' Use this reset?', + ' 1. Yes, use reset Reset your weekly and 5-hour usage limits.', + '› 2. No, go back Choose a different reset', + ' enter confirm · esc back' + ].join('\n') + expect(detectTerminalWaitBlockedReason(popup)).not.toBe('agent-hooks-review-prompt') + }) + + describe('through the runtime', () => { + it.each(DIALOGS)( + '$name: stops a tui-idle wait as $reason instead of typing into it', + async ({ name, reason }) => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: readRuntimeFixture(name), + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) + }) +}) diff --git a/src/main/runtime/codex-terminal-readiness.ts b/src/main/runtime/codex-terminal-readiness.ts index 3b15c3359df..cdbc8903d21 100644 --- a/src/main/runtime/codex-terminal-readiness.ts +++ b/src/main/runtime/codex-terminal-readiness.ts @@ -34,6 +34,27 @@ export function findCodexScreenReadyPromptIndex(screen: string): number | null { : null } +// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live +// screen shows no header then, while the text copy keeps the provisional one until the live chat +// paints its footer after it (a later model repaint rewrites only the value, never the label). +// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`); +// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first. +export function isCodexProvisionalStartupText(normalized: string): boolean { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return false + } + const loading = /model:\s+loading/.exec(normalized.slice(headerIndex)) + return loading !== null && !normalized.includes('·', headerIndex + loading.index) +} + +// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the +// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered. +export function findCodexHeaderIndex(normalized: string): number | null { + const index = normalized.lastIndexOf('openai codex (v') + return index === -1 ? null : index +} + /** * Tier 1b, codex panes only: the empty composer with no busy status row just above it and no * header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index bb03b17eca3..4d70572eb2a 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -11,7 +11,7 @@ import { import { withTimeout } from './runtime-async-boundaries' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptSettled } from './terminal-wait-detection' import type { RuntimeTerminalWait, @@ -80,7 +80,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith const ready = agent === 'antigravity' ? isAntigravityReadyPromptSnapshot(snapshotText) - : isKnownReadyPromptPreview(snapshotText) + : isKnownReadyPromptSettled(snapshotText) if (!blockedReason && !ready) { return } diff --git a/src/main/runtime/startup-dialog-blocked-signals.ts b/src/main/runtime/startup-dialog-blocked-signals.ts new file mode 100644 index 00000000000..e964205a9cc --- /dev/null +++ b/src/main/runtime/startup-dialog-blocked-signals.ts @@ -0,0 +1,76 @@ +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' + +// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the +// startup header for the live chat's footer, so each dialog must be matched by the time that `·` +// lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the +// text copy can lose letters and spaces (`updat available`); these rows are fixed literals. +// Update: `Update available · 0.157.1 → 0.158.0`, then `enter continue · esc skip`. +const CODEX_UPDATE_ROW_RE = /available\s*·\s*\d+\.\d+|enter\s*continue\s*·\s*esc\s*skip/g +// Why not `esc back`: Codex's mid-session pickers end `enter confirm · esc back`. +const CODEX_HOOKS_REVIEW_KEY_ROW_RE = /enter\s*confirm\s*·(?!\s*esc\s*back)/g +// Why both verbs: the retired-model notice says `continue`, the new-model announcement `confirm`. +const CODEX_MODEL_MIGRATION_KEY_ROW_RE = /enter\/esc\s*(?:continue|confirm)\s*·/g + +function lastMatchIndex(text: string, row: RegExp): number { + let index = -1 + for (const match of text.matchAll(row)) { + index = match.index + } + return index +} + +// Why the last match, and the legacy wording too: an answered dialog stays in the text copy, and +// builds before 0.157 wrote `Press enter to continue/confirm`. +function findDialogIndex( + normalized: string, + legacyHeading: string, + legacyKeys: string, + row: RegExp +): number { + const rowIndex = lastMatchIndex(normalized, row) + const headingIndex = normalized.lastIndexOf(legacyHeading) + const legacyIndex = + headingIndex !== -1 && normalized.includes(legacyKeys, headingIndex) ? headingIndex : -1 + return Math.max(rowIndex, legacyIndex) +} + +// Why together: each startup dialog owns Enter before the chat exists, so a brief typed into one +// answers it (Codex's update dialog defaults to `Update now`). +export function findStartupDialogBlockedSignals( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number }[] { + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = findDialogIndex( + normalized, + 'update available', + 'press enter to continue', + CODEX_UPDATE_ROW_RE + ) + if (updateIndex !== -1) { + candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = findDialogIndex( + normalized, + 'codex just got an upgrade', + 'press enter to continue', + CODEX_MODEL_MIGRATION_KEY_ROW_RE + ) + if (modelMigrationIndex !== -1) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = findDialogIndex( + normalized, + 'hooks need review', + 'press enter to confirm', + CODEX_HOOKS_REVIEW_KEY_ROW_RE + ) + if (hooksIndex !== -1) { + // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. + candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) + } + return candidates +} diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 1540eef2d5f..2618a50279c 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -9,9 +9,12 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import type { TuiAgent } from '../../shared/tui-agent' import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' import { + findCodexHeaderIndex, findCodexScreenReadyPromptIndex, - isCodexComposerReadyScreen + isCodexComposerReadyScreen, + isCodexProvisionalStartupText } from './codex-terminal-readiness' +import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals' import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i @@ -52,6 +55,19 @@ export function isKnownReadyPromptPreview(preview: string): boolean { return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) } +/** + * The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview + * (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed + * behind it while its daemon starts. + */ +export function isKnownReadyPromptSettled(preview: string): boolean { + const normalized = preview.toLowerCase() + return ( + isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) && + !isCodexProvisionalStartupText(normalized) + ) +} + /** * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's * visible grid, or null when the runtime has no trustworthy one. @@ -69,7 +85,7 @@ export function isKnownReadyPromptBody( if (agent === 'qoder') { return isQoderComposerReady(readScreenLines()) } - if (isKnownReadyPromptPreview(waitText)) { + if (isKnownReadyPromptSettled(waitText)) { return true } // Why the agent gate: another agent's screen can merely mention "OpenAI Codex". @@ -139,6 +155,7 @@ export function findActionableTerminalWaitBlockedSignal( function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), + findCodexHeaderIndex(normalized), findAntigravityReadyPromptIndex(normalized), findCursorActivePromptIndex(normalized), findMuseReadyPromptIndex(normalized) @@ -199,7 +216,7 @@ function findCodexReadyPromptIndex(normalized: string): number | null { } export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i + /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i // Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. const CURSOR_APPROVAL_CHOICE_MARKERS = [ @@ -268,27 +285,7 @@ function findTerminalWaitBlockedSignal( function findBlockedSignalInLiveWindow( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. - candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) - } + const candidates = findStartupDialogBlockedSignals(normalized) const trustIndex = Math.max( normalized.lastIndexOf('do you trust'), normalized.lastIndexOf('trust this'), From e1362ada4c33e7ef14709dff56564ea5dbcf5d98 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 29 Sep 2026 01:20:37 -0700 Subject: [PATCH 358/387] fix(terminal): stop inline-image decoders exhausting the renderer's wasm memory budget (#23499) V8 reserves an 8 GiB guard region per wasm memory inside its 1 TiB sandbox, so an Electron renderer can hold only ~124 live wasm memories regardless of free RAM. @xterm/addon-image instantiated a SIXEL decoder per terminal at activation (and kept IIP decoders after the first image), so ~120+ terminals exhausted the budget: new panes raised 'WebAssembly.instantiate(): Out of memory' rejections, and the next Kitty/IIP image threw 'WebAssembly.Memory(): could not allocate memory' out of the parser, permanently wedging that terminal's write queue. The addon-image source patch now borrows SIXEL decoders from a shared pool only while a sequence is open (color registers stay on the terminal), drops IIP decoders after each image, and turns a failed decoder allocation into a dropped image instead of a parser throw. Bundles regenerated with regenerate-xterm-patches.mjs --write. Co-authored-by: m4air --- .../@xterm__addon-image@0.10.0-beta.300.patch | 279 ++++++++++++++++-- ...erm__addon-image@0.10.0-beta.300.src.patch | 259 +++++++++++++++- .../xterm-image-wasm-budget-contract.test.mjs | 160 ++++++++++ docs/reference/xterm-patch-regeneration.md | 9 + pnpm-lock.yaml | 6 +- 5 files changed, 676 insertions(+), 37 deletions(-) create mode 100644 config/scripts/xterm-image-wasm-budget-contract.test.mjs diff --git a/config/patches/@xterm__addon-image@0.10.0-beta.300.patch b/config/patches/@xterm__addon-image@0.10.0-beta.300.patch index c44cb33d2d5..44d6ac32c14 100644 --- a/config/patches/@xterm__addon-image@0.10.0-beta.300.patch +++ b/config/patches/@xterm__addon-image@0.10.0-beta.300.patch @@ -1,24 +1,24 @@ diff --git a/lib/addon-image.js b/lib/addon-image.js -index 38cf9e8708a5f314b8d587e5428ce7facb0d4076..4807d674d75a5aeaba40cf86972ce0a6158a33f4 100644 +index 38cf9e8708a5f314b8d587e5428ce7facb0d4076..4c5f44e00b0972978d762df5990de86002537649 100644 --- a/lib/addon-image.js +++ b/lib/addon-image.js @@ -1,3 +1,3 @@ /*! For license information please see addon-image.js.LICENSE.txt */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.ImageAddon=t():e.ImageAddon=t()}(globalThis,()=>(()=>{"use strict";var e={939(e,t){function A(e){return 255&e}function i(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,A,i=255){return((255&i)<<24|(255&A)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,A){return Math.max(e,Math.min(A,t))}function a(e,t,A){return A<0&&(A+=1),A>1&&(A-=1),6*A<1?t+6*(e-t)*A:2*A<1?e:3*A<2?t+(e-t)*(4-6*A):t}function n(e,t,A){return(4278190080|Math.round(A/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=A,t.green=i,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=A(e),o=i(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let A=0;A<6;++A)for(let i=0;i<6;++i)for(let s=0;s<6;++s)e.push(r(t[A],t[i],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const i=A(939),s=A(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),A=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:i.DEFAULT_FOREGROUND,fillColor:i.DEFAULT_BACKGROUND,palette:i.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,A={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,A).then(A=>(o=o||A.module,new d(e,A.instance||A,t)))}t.DecoderAsync=g;class d{constructor(e,t,A){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)A.bandHandler=this._handle_band.bind(this),A.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(i.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const A=e+t;if(A>this._canvas.length){if(this._opts.memoryLimit&&4*A>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(A/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let A=this._lastOffset;if(2===this._mode){let i=this.height-this._currentHeight,s=0;for(;s<6&&i>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),A+e*s),s++,i--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(A,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let i=0;i<6;++i)this._canvas.set(this._pSrc.subarray(t*i,t*i+e),A+e*i);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,A=this._opts.paletteLimit,i=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,A,i?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,A=e.length){let i=t;for(;i0){const A=this._PIXEL_OFFSET;let i=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(A*s,A*s+e),i+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,i+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let A=this._lastOffset;this._realloc(A,6*e);for(let i=0;i<6;++i)this._canvas.set(this._pSrc.subarray(t*i,t*i+e),A+e*i)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let A=0,i=0;for(let e=0;e{if(this._disposed)return(0,i.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return A&&(Array.isArray(A)?A.push(r):A.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let A=0,i=t.length;At.fire(e))},e.map=function(e,t){return(A,i,s)=>e(e=>A.call(i,t(e)),void 0,s)},e.any=function(...e){return(t,A,s)=>{const r=new i.DisposableStore;for(const i of e)r.add(i(e=>t.call(A,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,A){return t(A),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function A(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=A,t.dispose=i,t.combinedDisposable=function(...e){return A(()=>i(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,A){var i=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=A(463),r=A(699),o=i(A(669)),a=i(A(61)),n=A(389),h=A(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,A,i){this._opts=e,this._renderer=t,this._storage=A,this._coreTerminal=i,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(4194304,s,r),this._qoiDec=new a.default(4194304)}reset(){this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,A){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,A))&&(this._dec.release(),this._aborted=!0);else{const i=this._hp.parse(e,t,A);if(-1===i)return void(this._aborted=!0);if(i>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);this._dec.init()}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(i,A))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,A,i;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,A=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,A=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(i=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==i?i:1,o=`]1337;ReportCellSize=${A.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*l(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_resize(e,t){var A,i,s,o;const a=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const A=h/e,i=(g-n)/t,s=Math.min(A,i);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,A){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*A}}},389(e,t){function A(e){let t="";for(let A=0;A57)throw new Error("illegal char");t=10*t+e[A]-48}return t}function s(e){const t=A(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:i,size:i,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(A(e),"base64").toString();const t=atob(A(e)),i=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==i||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=i,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=A(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const A=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===A[0]&&169478669===A[1]&&1380206665===A[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,A]=function(e){const t=e.length;let A=4,i=e[A]<<8|e[A+1];for(;;){if(A+=i,A>=t)return[0,0];if(255!==e[A])return[0,0];if(192===e[A+1]||194===e[A+1])return A+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===A[1]&&(1718187617===A[2]||1936291425===A[2])){let A=-1;const i=Math.min(e.length-16,1024);for(let t=8;t0&&i>0)return{mime:"image/avif",width:t,height:i}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const i=A(939),s=A(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,A){const i=(null!=e?e:document).createElement("canvas");return i.width=0|t,i.height=0|A,i}static createImageData(e,t,A,i){if("function"!=typeof ImageData){const s=e.createImageData(t,A);return i&&s.data.set(new Uint8ClampedArray(i,0,t*A*4)),s}return i?new ImageData(new Uint8ClampedArray(i,0,t*A*4),t,A):new ImageData(t,A)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,A;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(A=this._renderService)||void 0===A||A.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,A){var i,s,r,o,a;const n=e*((null===(i=this.dimensions)||void 0===i?void 0:i.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);A&&"top"!==A||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),A&&"bottom"!==A||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,A,i,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,h=Math.ceil(n.width/o),g=t%h*o,d=Math.floor(t/h)*a,l=A*o,I=i*a,c=s*o+g>n.width?n.width-g:s*o,_=d+a>n.height?n.height-d:a;r.drawImage(n,Math.floor(g),Math.floor(d),Math.ceil(c),Math.ceil(_),Math.floor(l),Math.floor(I),Math.ceil(c),Math.ceil(_))}extractTile(e,t){const{width:A,height:i}=this.cellSize;if(-1===A||-1===i)return;this._rescaleImage(e,A,i);const s=e.actual,o=Math.ceil(s.width/A),a=t%o*A,n=Math.floor(t/o)*i,h=A+a>s.width?s.width-a:A,g=n+i>s.height?s.height-n:i,d=r.createCanvas(this.document,h,g),l=d.getContext("2d");return l?(l.drawImage(s,Math.floor(a),Math.floor(n),Math.floor(h),Math.floor(g),0,0,Math.floor(h),Math.floor(g)),d):void 0}drawPlaceholder(e,t,A=1){var i;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(i=this._placeholderBitmap)&&void 0!==i?i:this._placeholder,e*r,t*o%2?0:1,r*A,o,e*r,t*o,r*A,o)}}rescaleCanvas(){var e,t;const A=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,i=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===A&&e.canvas.height===i||(e.canvas.width=A,e.canvas.height=i)}_rescaleImage(e,t,A){if(t===e.actualCellSize.width&&A===e.actualCellSize.height)return;const{width:i,height:s}=e.origCellSize;if(t===i&&A===s)return e.actual=e.orig,e.actualCellSize.width=i,void(e.actualCellSize.height=s);const o=r.createCanvas(this.document,Math.ceil(e.orig.width*t/i),Math.ceil(e.orig.height*A/s)),a=o.getContext("2d");a&&(a.drawImage(e.orig,0,0,o.width,o.height),e.actual=o,e.actualCellSize.width=t,e.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,A;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const i=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(A=this.dimensions)||void 0===A?void 0:A.css.canvas.height)||0);i.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(i.style.zIndex="-1",s.insertBefore(i,s.firstChild)):(i.style.zIndex="0",s.appendChild(i));const o=i.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):i.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const A=32,s=r.createCanvas(this.document,A,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,A,e),n=new Uint32Array(a.data.buffer),h=(0,i.toRGBA8888)(0,0,0),g=(0,i.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;tthis._placeholderBitmap=e)}else this._placeholder=void 0}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const i=A(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,A=-1,i=-1){this.imageId=A,this.tileId=i,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,A){this._terminal=e,this._renderer=t,this._opts=A,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const A=this._images.get(e);A&&(this._images.delete(e),window.ImageBitmap&&A.orig instanceof ImageBitmap&&A.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[A,i]of this._images.entries())"alternate"===i.bufferType&&(null===(e=i.marker)||void 0===e||e.dispose(),t.push(A));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const A=this._images.get(e);A&&(null===(t=A.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,A){var i,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;A.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++A)this._writeToCell(t,c+A,n,e*o+A),_++;if(A.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),A.scrolling?"iip"===A.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(i=t.marker)||void 0===i||i.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:A.layer,zIndex:A.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,A;let i=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:i=!0,i&&s)break;if(i&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!i&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const i=n.lines.get(e+n.ydisp);if(!i)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,A;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const i=this._terminal._core.buffer,s=i.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-i.tileId%h+o);let l=i.tileId;for(let e=o+1;e>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}o.set(i.PALETTE_VT340_COLOR),t.SixelHandler=class{constructor(e,t,A){this._opts=e,this._storage=t,this._coreTerminal=A,this._size=0,this._aborted=!1,(0,r.DecoderAsync)({memoryLimit:4*this._opts.pixelLimit,palette:o,paletteLimit:this._opts.sixelPaletteLimit}).then(e=>this._dec=e)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,o,this._opts.sixelPaletteLimit))}hook(e){var t;if(this._size=0,this._aborted=!1,this._dec){const A=1===e.params[1]?0:function(e,t){let A=0;if(!t)return A;if(e.isInverse())if(e.isFgDefault())A=a(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());A=(0,i.toRGBA8888)(...t)}else A=a(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())A=a(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());A=(0,i.toRGBA8888)(...t)}else A=a(t.ansi[e.getBgColor()].rgba);return A}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(A,null,this._opts.sixelPaletteLimit)}}put(e,t,A){if(!this._aborted&&this._dec){if(this._size+=A-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,A)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const A=this._dec.width,i=this._dec.height;if(!A||!i)return i&&this._storage.advanceCursor(i),!0;const r=s.ImageRenderer.createCanvas(void 0,A,i);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,A,i),0,0),this._dec.memoryUsage>4194304&&this._dec.release(),this._storage.addImage(r),!0}}},994(e,t,A){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const i=A(699);t.SixelImageStorage=class{constructor(e,t,A,i){this._storage=e,this._opts=t,this._renderer=A,this._terminal=i,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=i.CELL_SIZE_DEFAULT);const A=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,a.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(i=this._parsedCommand.quiet)&&void 0!==i?i:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(n=this._activeDecoder)&&void 0!==n?n:null==g?void 0:g.decoder;return e&&e.release(),this._activeDecoder=null,g&&this._removePendingEntry(h),void(this._aborted=!0)}this._decodeError||((null==g?void 0:g.decoder)&&!this._activeDecoder&&(this._activeDecoder=g.decoder),this._activeDecoder||(this._activeDecoder=new o.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()),0!==this._activeDecoder.put(e.subarray(t,A))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,g&&this._removePendingEntry(h)))}end(e){var t,A;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const i=this._parsedCommand;if("d"===i.action)return this._handleDelete(i);const s=null!==(A=null!==(t=i.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==A?A:0,r=1===i.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},i),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=i,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),i}case"T":return this._handleTransmitDisplay(e,t,A);case"q":return this._handleQuery(e,t,A);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const A=e.id,i=this._kittyStorage.getImage(A);return i?this._displayImage(i,e).then(t=>{var i;return this._sendResponse(A,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0,e.placementId),!0}):(this._sendResponse(A,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,A){var i,s,r,o,a,n;return"d"!==(null!==(i=e.transmission)&&void 0!==i?i:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(A||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,A){var i,s;if(A)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(i=e.quiet)&&void 0!==i?i:0),!0;this._handleTransmit(e,t,A);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var A;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,A){var i,s,r,o,a,n;const h=null!==(i=e.id)&&void 0!==i?i:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(A)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const A=null!==(a=e.width)&&void 0!==a?a:0,i=null!==(n=e.height)&&void 0!==n?n:0;if(!A||!i)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=A*i*(32===d?4:3);if(t.length=1)return;if(!s&&A>=2)return;const r=`_Gi=${e}${i?`,p=${i}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var A,i,o,a,n,h,g;let d=await this._createBitmap(e);try{const l=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),I=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),c=t.sourceWidth||d.width-l,_=t.sourceHeight||d.height-I,C=Math.max(0,d.width-l),B=Math.max(0,d.height-I),Q=Math.max(0,Math.min(c,C)),m=Math.max(0,Math.min(_,B));if(0===Q||0===m)throw new Error("invalid source rectangle");if(0!==l||0!==I||Q!==d.width||m!==d.height){const e=await createImageBitmap(d,l,I,Q,m);d.close(),d=e}const E=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.width)||r.CELL_SIZE_DEFAULT.width,u=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,p;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,p=t.rows):void 0!==t.columns?(w=t.columns,p=Math.max(1,Math.ceil(d.height/d.width*(w*E)/u))):void 0!==t.rows?(p=t.rows,w=Math.max(1,Math.ceil(d.width/d.height*(p*u)/E))):(w=Math.ceil(d.width/E),p=Math.ceil(d.height/u));let f=d.width,D=d.height;if(void 0===t.columns&&void 0===t.rows||(f=Math.round(w*E),D=Math.round(p*u)),f*D>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const y=this._coreTerminal._core.buffer,v=y.x,k=y.y,M=y.ybase,b=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(f!==d.width||D!==d.height){const e=await createImageBitmap(d,{resizeWidth:f,resizeHeight:D});d.close(),d=e}const S=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),E-1),L=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),u-1);if(0!==S||0!==L){const e=void 0!==t.columns?Math.round(w*E):d.width+S,A=void 0!==t.rows?Math.round(p*u):d.height+L,i=s.ImageRenderer.createCanvas(window.document,e,A),r=i.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(d,S,L);const o=await createImageBitmap(i);if(i.width=i.height=0,d.close(),d=o,f=d.width,D=d.height,f*D>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(d.width/E)),void 0===t.rows&&(p=Math.ceil(d.height/u))}const N=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,d,!0,b,N),d=void 0,1===t.cursorMovement){const e=y.ybase-M;y.x=v,y.y=Math.max(k-e,0)}else y.x=Math.min(v+w,this._coreTerminal.cols)}catch(e){throw null==d||d.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const t=URL.createObjectURL(e),A=new Image;return new Promise((e,i)=>{A.addEventListener("load",()=>{var r;URL.revokeObjectURL(t);const o=s.ImageRenderer.createCanvas(window.document,A.width,A.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(A,0,0),createImageBitmap(o).then(e).catch(i)}),A.addEventListener("error",()=>{URL.revokeObjectURL(t),i(new Error("Failed to load image"))}),A.src=t})}return createImageBitmap(e)}const A=e.width,i=e.height;if(!A||!i)throw new Error("Width and height required for raw pixel data");const r=A*i*(32===e.format?4:3);if(t.length>>24|t<<8,h[l++]=4278190080|t>>>16|A<<16,h[l++]=4278190080|A>>>8}let I=3*g,c=4*g;for(let e=g;ee+t.length,0),a=new Uint8Array(o);let n=0;for(const e of s)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},A=e.split(",");for(const e of A){const A=e.indexOf("=");if(-1===A)continue;const i=e.substring(0,A),s=e.substring(A+1);if("a"===i){t.action=s;continue}if("o"===i){t.compression=s;continue}if("t"===i){t.transmission=s;continue}if("d"===i){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(i){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class A{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const i=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(i);return void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(s)),!this._images.has(i)&&this._images.size>=A._maxStoredImages&&this._evictUndisplayedImages(),this._images.set(i,Object.assign(Object.assign({},t),{id:i})),i}addImage(e,t,A,i,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=A,this._addImageOpts.layer=i,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=A._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=A,A._maxStoredImages=256},669(e,t,A){Object.defineProperty(t,"__esModule",{value:!0});const i=(0,A(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let A=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,A){Object.defineProperty(t,"__esModule",{value:!0});const i=(0,A(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,A=4*t,s=e.length,r=Math.max(A,s)+(Math.min(A,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:i,d:s}=e;let r,o;const a=WebAssembly;return 0===t?i?e=>new a.Instance(o||(o=new a.Module(r||(r=A(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=A(s)),e).then(e=>(o=e.module)&&e.instance):1===t?i?()=>o||(o=new a.Module(r||(r=A(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=A(s))).then(e=>o=e):i?()=>r||(r=A(s)):()=>Promise.resolve(r||(r=A(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let A=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),A=new Uint8Array(t.length);for(let e=0;e{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=A(414),s=A(795),r=A(463),o=A(699),a=A(23),n=A(235),h=A(566),g=A(994),d=A(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const A=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};A.getWinSizePixels=!0,A.getCellSizePixels=!0,A.getWinSizeChars=!0,e.options.windowOptions=A}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),A=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",A),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},A))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),A=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",A),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,A))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),A=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",A),this._disposeLater(t,A,e._core._inputHandler._parser.registerApcHandler({final:"G"},A))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var A;return null===(A=this._storage)||void 0===A?void 0:A.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var A;return null===(A=this._storage)||void 0===A?void 0:A.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(A=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===A?void 0:A.css.canvas.width,h=null===(s=null===(i=this._renderer)||void 0===i?void 0:i.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h(()=>{"use strict";var e={939(e,t){function i(e){return 255&e}function A(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,i,A=255){return((255&A)<<24|(255&i)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,i){return Math.max(e,Math.min(i,t))}function a(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(4-6*i):t}function n(e,t,i){return(4278190080|Math.round(i/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=i,t.green=A,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=i(e),o=A(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let i=0;i<6;++i)for(let A=0;A<6;++A)for(let s=0;s<6;++s)e.push(r(t[i],t[A],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const A=i(939),s=i(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:A.DEFAULT_FOREGROUND,fillColor:A.DEFAULT_BACKGROUND,palette:A.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,i={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,i).then(i=>(o=o||i.module,new d(e,i.instance||i,t)))}t.DecoderAsync=g;class d{constructor(e,t,i){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(A.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&4*i>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(i/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let i=this._lastOffset;if(2===this._mode){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(i,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){const i=this._PIXEL_OFFSET;let A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let i=this._lastOffset;this._realloc(i,6*e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let e=0;e{if(this._disposed)return(0,A.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,A.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let i=0,A=t.length;it.fire(e))},e.map=function(e,t){return(i,A,s)=>e(e=>i.call(A,t(e)),void 0,s)},e.any=function(...e){return(t,i,s)=>{const r=new A.DisposableStore;for(const A of e)r.add(A(e=>t.call(i,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function i(e){return{dispose:e}}function A(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=A,t.combinedDisposable=function(...e){return i(()=>A(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,i){var A=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=i(463),r=i(699),o=A(i(669)),a=A(i(61)),n=i(389),h=i(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,i,A){this._opts=e,this._renderer=t,this._storage=i,this._coreTerminal=A,this._generation=0,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(4194304,s,r),this._qoiDec=new a.default(4194304)}reset(){this._generation++,this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,i){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,i))&&(this._dec.release(),this._aborted=!0);else{const A=this._hp.parse(e,t,i);if(-1===A)return void(this._aborted=!0);if(A>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);this._dec.init()}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(A,i))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,i,A;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,i=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,i=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(A=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==A?A:1,o=`]1337;ReportCellSize=${i.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*lc!==this._generation?(e.close(),!0):(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_resize(e,t){var i,A,s,o;const a=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const i=h/e,A=(g-n)/t,s=Math.min(i,A);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,i){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}}},389(e,t){function i(e){let t="";for(let i=0;i57)throw new Error("illegal char");t=10*t+e[i]-48}return t}function s(e){const t=i(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:A,size:A,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(i(e),"base64").toString();const t=atob(i(e)),A=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==A||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=i(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const i=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===i[0]&&169478669===i[1]&&1380206665===i[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,i]=function(e){const t=e.length;let i=4,A=e[i]<<8|e[i+1];for(;;){if(i+=A,i>=t)return[0,0];if(255!==e[i])return[0,0];if(192===e[i+1]||194===e[i+1])return i+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===i[1]&&(1718187617===i[2]||1936291425===i[2])){let i=-1;const A=Math.min(e.length-16,1024);for(let t=8;t0&&A>0)return{mime:"image/avif",width:t,height:A}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const A=i(939),s=i(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,i){const A=(null!=e?e:document).createElement("canvas");return A.width=0|t,A.height=0|i,A}static createImageData(e,t,i,A){if("function"!=typeof ImageData){const s=e.createImageData(t,i);return A&&s.data.set(new Uint8ClampedArray(A,0,t*i*4)),s}return A?new ImageData(new Uint8ClampedArray(A,0,t*i*4),t,i):new ImageData(t,i)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,i;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(i=this._renderService)||void 0===i||i.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,i){var A,s,r,o,a;const n=e*((null===(A=this.dimensions)||void 0===A?void 0:A.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);i&&"top"!==i||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),i&&"bottom"!==i||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,i,A,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,{width:h,height:g}=e.actualCellSize,d=Math.ceil(n.width/h),l=t%d*h,I=Math.floor(t/d)*g,c=i*o,_=A*a,C=s*h+l>n.width?n.width-l:s*h,B=I+g>n.height?n.height-I:g;r.drawImage(n,Math.floor(l),Math.floor(I),Math.ceil(C),Math.ceil(B),Math.floor(c),Math.floor(_),Math.ceil(C*o/h),Math.ceil(B*a/g))}extractTile(e,t){const{width:i,height:A}=this.cellSize;if(-1===i||-1===A)return;this._rescaleImage(e,i,A);const s=e.actual,{width:o,height:a}=e.actualCellSize,n=Math.ceil(s.width/o),h=t%n*o,g=Math.floor(t/n)*a,d=o+h>s.width?s.width-h:o,l=g+a>s.height?s.height-g:a,I=r.createCanvas(this.document,Math.ceil(d*i/o),Math.ceil(l*A/a)),c=I.getContext("2d");return c?(c.drawImage(s,Math.floor(h),Math.floor(g),Math.floor(d),Math.floor(l),0,0,I.width,I.height),I):void 0}drawPlaceholder(e,t,i=1){var A;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(A=this._placeholderBitmap)&&void 0!==A?A:this._placeholder,e*r,t*o%2?0:1,r*i,o,e*r,t*o,r*i,o)}}rescaleCanvas(){var e,t;const i=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,A=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===i&&e.canvas.height===A||(e.canvas.width=i,e.canvas.height=A)}_rescaleImage(e,t,i){if(t===e.actualCellSize.width&&i===e.actualCellSize.height)return;const{width:A,height:s}=e.origCellSize;if(t===A&&i===s)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const o=Math.ceil(e.orig.width*t/A),a=Math.ceil(e.orig.height*i/s);if(o*a>e.orig.width*e.orig.height)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const n=r.createCanvas(this.document,o,a),h=n.getContext("2d");h&&(h.drawImage(e.orig,0,0,n.width,n.height),e.actual=n,e.actualCellSize.width=t,e.actualCellSize.height=i)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,i;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const A=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(i=this.dimensions)||void 0===i?void 0:i.css.canvas.height)||0);A.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(A.style.zIndex="-1",s.insertBefore(A,s.firstChild)):(A.style.zIndex="0",s.appendChild(A));const o=A.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):A.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const i=32,s=r.createCanvas(this.document,i,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,i,e),n=new Uint32Array(a.data.buffer),h=(0,A.toRGBA8888)(0,0,0),g=(0,A.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;t{this._placeholder!==I?null==e||e.close():this._placeholderBitmap=e}).catch(()=>{})}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const A=i(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,i=-1,A=-1){this.imageId=i,this.tileId=A,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,i){this._terminal=e,this._renderer=t,this._opts=i,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const i=this._images.get(e);i&&(this._images.delete(e),window.ImageBitmap&&i.orig instanceof ImageBitmap&&i.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[i,A]of this._images.entries())"alternate"===A.bufferType&&(null===(e=A.marker)||void 0===e||e.dispose(),t.push(i));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const i=this._images.get(e);i&&(null===(t=i.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,i){var A,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;i.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++i)this._writeToCell(t,c+i,n,e*o+i),_++;if(i.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),i.scrolling?"iip"===i.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(A=t.marker)||void 0===A||A.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:i.layer,zIndex:i.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,i;let A=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:A=!0,A&&s)break;if(A&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!A&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const A=n.lines.get(e+n.ydisp);if(!A)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,i;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const A=this._terminal._core.buffer,s=A.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-A.tileId%h+o);let l=A.tileId;for(let e=o+1;e>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}o.set(A.PALETTE_VT340_COLOR),t.SixelHandler=class{constructor(e,t,i){this._opts=e,this._storage=t,this._coreTerminal=i,this._size=0,this._aborted=!1,(0,r.DecoderAsync)({memoryLimit:4*this._opts.pixelLimit,palette:o,paletteLimit:this._opts.sixelPaletteLimit}).then(e=>this._dec=e)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,o,this._opts.sixelPaletteLimit))}hook(e){var t;if(this._size=0,this._aborted=!1,this._dec){const i=1===e.params[1]?0:function(e,t){let i=0;if(!t)return i;if(e.isInverse())if(e.isFgDefault())i=a(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());i=(0,A.toRGBA8888)(...t)}else i=a(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())i=a(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());i=(0,A.toRGBA8888)(...t)}else i=a(t.ansi[e.getBgColor()].rgba);return i}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(i,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!this._aborted&&this._dec){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,i)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const i=this._dec.width,A=this._dec.height;if(!i||!A)return A&&this._storage.advanceCursor(A),!0;const r=s.ImageRenderer.createCanvas(void 0,i,A);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,i,A),0,0),this._dec.memoryUsage>4194304&&this._dec.release(),this._storage.addImage(r),!0}}},994(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const A=i(699);t.SixelImageStorage=class{constructor(e,t,i,A){this._storage=e,this._opts=t,this._renderer=i,this._terminal=A,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=A.CELL_SIZE_DEFAULT);const i=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,n.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(A=this._parsedCommand.quiet)&&void 0!==A?A:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(h=this._activeDecoder)&&void 0!==h?h:null==I?void 0:I.decoder;return e&&e.release(),this._activeDecoder=null,I&&this._removePendingEntry(l),void(this._aborted=!0)}if(!this._decodeError){if((null==I?void 0:I.decoder)&&!this._activeDecoder&&(this._activeDecoder=I.decoder),!this._activeDecoder){const e=this._maxEncodedBytes+131072;if(e>1e6*this._opts.storageLimit)return this._aborted=!0,void(void 0!==(null===(s=this._parsedCommand)||void 0===s?void 0:s.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",null!==(g=this._parsedCommand.quiet)&&void 0!==g?g:0));const t=Math.max(1,Math.floor(1e6*this._opts.storageLimit/e));for(;this._pendingTransmissions.size>=t;){const e=this._pendingTransmissions.entries().next().value;if(!e)break;e[1].decoder.release(),this._removePendingEntry(e[0]),void 0!==e[1].cmd.id&&this._sendResponse(e[1].cmd.id,"ENOMEM:pending image budget exceeded",null!==(d=e[1].cmd.quiet)&&void 0!==d?d:0)}this._activeDecoder=new a.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()}0!==this._activeDecoder.put(e.subarray(t,i))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,I&&this._removePendingEntry(l))}}end(e){var t,i;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const A=this._parsedCommand;if("d"===A.action)return this._handleDelete(A);const s=null!==(i=null!==(t=A.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==i?i:0,r=1===A.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},A),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=A,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),A}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const i=e.id,A=this._kittyStorage.getImage(i);return A?this._displayImage(A,e).then(t=>{var A;return this._sendResponse(i,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0,e.placementId),!0}):(this._sendResponse(i,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,i){var A,s,r,o,a,n;return"d"!==(null!==(A=e.transmission)&&void 0!==A?A:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(i||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,i){var A,s;if(i)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(A=e.quiet)&&void 0!==A?A:0),!0;this._handleTransmit(e,t,i);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var i;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,i){var A,s,r,o,a,n;const h=null!==(A=e.id)&&void 0!==A?A:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(i)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const i=null!==(a=e.width)&&void 0!==a?a:0,A=null!==(n=e.height)&&void 0!==n?n:0;if(!i||!A)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=i*A*(32===d?4:3);if(t.length=1)return;if(!s&&i>=2)return;const r=`_Gi=${e}${A?`,p=${A}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var i,A,o,a,n,h,g;const d=this._generation;let l=await this._createBitmap(e);try{if(d!==this._generation)throw new Error("image decode canceled");const I=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),c=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),_=t.sourceWidth||l.width-I,C=t.sourceHeight||l.height-c,B=Math.max(0,l.width-I),Q=Math.max(0,l.height-c),m=Math.max(0,Math.min(_,B)),E=Math.max(0,Math.min(C,Q));if(0===m||0===E)throw new Error("invalid source rectangle");if(0!==I||0!==c||m!==l.width||E!==l.height){const e=await createImageBitmap(l,I,c,m,E);l.close(),l=e}const u=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,p=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,f;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,f=t.rows):void 0!==t.columns?(w=t.columns,f=Math.max(1,Math.ceil(l.height/l.width*(w*u)/p))):void 0!==t.rows?(f=t.rows,w=Math.max(1,Math.ceil(l.width/l.height*(f*p)/u))):(w=Math.ceil(l.width/u),f=Math.ceil(l.height/p));let D=l.width,y=l.height;if(void 0===t.columns&&void 0===t.rows||(D=Math.round(w*u),y=Math.round(f*p)),D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const v=this._coreTerminal._core.buffer,k=v.x,M=v.y,b=v.ybase,S=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(D!==l.width||y!==l.height){const e=await createImageBitmap(l,{resizeWidth:D,resizeHeight:y});l.close(),l=e}const L=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),u-1),N=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),p-1);if(0!==L||0!==N){const e=void 0!==t.columns?Math.round(w*u):l.width+L,i=void 0!==t.rows?Math.round(f*p):l.height+N,A=s.ImageRenderer.createCanvas(window.document,e,i),r=A.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(l,L,N);const o=await createImageBitmap(A);if(A.width=A.height=0,l.close(),l=o,D=l.width,y=l.height,D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(l.width/u)),void 0===t.rows&&(f=Math.ceil(l.height/p))}if(d!==this._generation)throw new Error("image decode canceled");const G=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,l,!0,S,G),l=void 0,1===t.cursorMovement){const e=v.ybase-b;v.x=k,v.y=Math.max(M-e,0)}else v.x=Math.min(k+w,this._coreTerminal.cols)}catch(e){throw null==l||l.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=(0,o.imageType)(t);if("image/png"!==e.mime||!(e.width>0)||!(e.height>0)||e.width*e.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");const i=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const e=URL.createObjectURL(i),t=new Image;return new Promise((i,A)=>{t.addEventListener("load",()=>{var r;URL.revokeObjectURL(e);const o=s.ImageRenderer.createCanvas(window.document,t.width,t.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(t,0,0),createImageBitmap(o).then(i).catch(A)}),t.addEventListener("error",()=>{URL.revokeObjectURL(e),A(new Error("Failed to load image"))}),t.src=e})}return createImageBitmap(i)}const i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");const r=i*A*(32===e.format?4:3);if(t.length>>24|t<<8,g[I++]=4278190080|t>>>16|i<<16,g[I++]=4278190080|i>>>8}let c=3*d,_=4*d;for(let e=d;e=e.length)return void t.close();const i=Math.min(A+4096,e.length);t.enqueue(new Uint8Array(e.subarray(A,i))),A=i}}).pipeThrough(new DecompressionStream(t)).getReader(),r=[];let o=0;try{for(;;){const{done:e,value:t}=await s.read();if(e)break;if(o+=t.byteLength,o>i)throw await s.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");r.push(t)}}finally{s.releaseLock()}const a=new Uint8Array(o);let n=0;for(const e of r)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},i=e.split(",");for(const e of i){const i=e.indexOf("=");if(-1===i)continue;const A=e.substring(0,i),s=e.substring(i+1);if("a"===A){t.action=s;continue}if("o"===A){t.compression=s;continue}if("t"===A){t.transmission=s;continue}if("d"===A){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(A){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class i{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const A=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(A);void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(A),this._storageIdToKittyId.delete(s)),!this._images.has(A)&&this._images.size>=i._maxStoredImages&&this._evictUndisplayedImages();const r=1e6*this._storage.getLimit();this._images.delete(A);let o=0;for(const e of this._images.values())o+=e.data.size;for(const e of[!1,!0])for(const[i,A]of this._images){if(o+t.data.size<=r)break;this._kittyIdToStorageId.has(i)===e&&(o-=A.data.size,this.deleteById(i))}return this._images.set(A,Object.assign(Object.assign({},t),{id:A})),A}addImage(e,t,i,A,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=i._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=i,i._maxStoredImages=256},669(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,i=4*t,s=e.length,r=Math.max(i,s)+(Math.min(i,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:A,d:s}=e;let r,o;const a=WebAssembly;return 0===t?A?e=>new a.Instance(o||(o=new a.Module(r||(r=i(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=i(s)),e).then(e=>(o=e.module)&&e.instance):1===t?A?()=>o||(o=new a.Module(r||(r=i(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=i(s))).then(e=>o=e):A?()=>r||(r=i(s)):()=>Promise.resolve(r||(r=i(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let i=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e{var e=A;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=i(414),s=i(795),r=i(463),o=i(699),a=i(23),n=i(235),h=i(566),g=i(994),d=i(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._handlers.values())e.reset();for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const i=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};i.getWinSizePixels=!0,i.getCellSizePixels=!0,i.getWinSizeChars=!0,e.options.windowOptions=i}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),i=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),i=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),i=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(i=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===i?void 0:i.css.canvas.width,h=null===(s=null===(A=this._renderer)||void 0===A?void 0:A.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h(()=>{"use strict";var e={939(e,t){function i(e){return 255&e}function A(e){return e>>>8&255}function s(e){return e>>>16&255}function r(e,t,i,A=255){return((255&A)<<24|(255&i)<<16|(255&t)<<8|255&e)>>>0}function o(e,t,i){return Math.max(e,Math.min(i,t))}function a(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(4-6*i):t}function n(e,t,i){return(4278190080|Math.round(i/100*255)<<16|Math.round(t/100*255)<<8|Math.round(e/100*255))>>>0}Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_FOREGROUND=t.DEFAULT_BACKGROUND=t.PALETTE_ANSI_256=t.PALETTE_VT340_GREY=t.PALETTE_VT340_COLOR=t.normalizeHLS=t.normalizeRGB=t.nearestColorIndex=t.fromRGBA8888=t.toRGBA8888=t.alpha=t.blue=t.green=t.red=t.BIG_ENDIAN=void 0,t.BIG_ENDIAN=255===new Uint8Array(new Uint32Array([4278190080]).buffer)[0],t.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly."),t.red=i,t.green=A,t.blue=s,t.alpha=function(e){return e>>>24&255},t.toRGBA8888=r,t.fromRGBA8888=function(e){return[255&e,e>>8&255,e>>16&255,e>>>24]},t.nearestColorIndex=function(e,t){const r=i(e),o=A(e),a=s(e);let n=Number.MAX_SAFE_INTEGER,h=-1;for(let e=0;e{const e=[r(0,0,0),r(205,0,0),r(0,205,0),r(205,205,0),r(0,0,238),r(205,0,205),r(0,250,205),r(229,229,229),r(127,127,127),r(255,0,0),r(0,255,0),r(255,255,0),r(92,92,255),r(255,0,255),r(0,255,255),r(255,255,255)],t=[0,95,135,175,215,255];for(let i=0;i<6;++i)for(let A=0;A<6;++A)for(let s=0;s<6;++s)e.push(r(t[i],t[A],t[s]));for(let t=8;t<=238;t+=10)e.push(r(t,t,t));return new Uint32Array(e)})(),t.DEFAULT_BACKGROUND=r(0,0,0,255),t.DEFAULT_FOREGROUND=r(255,255,255,255)},591(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.decodeAsync=t.decode=t.Decoder=t.DecoderAsync=void 0;const A=i(939),s=i(199),r=function(e){if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}}const h={memoryLimit:134217728,sixelColor:A.DEFAULT_FOREGROUND,fillColor:A.DEFAULT_BACKGROUND,palette:A.PALETTE_VT340_COLOR,paletteLimit:s.LIMITS.PALETTE_SIZE,truncate:!0};function g(e){const t=new n,i={env:{handle_band:t.handle_band.bind(t),mode_parsed:t.mode_parsed.bind(t)}};return WebAssembly.instantiate(o||r,i).then(i=>(o=o||i.module,new d(e,i.instance||i,t)))}t.DecoderAsync=g;class d{constructor(e,t,i){if(this._PIXEL_OFFSET=s.LIMITS.MAX_WIDTH+4,this._canvas=a,this._bandWidths=[],this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},h,e),this._opts.paletteLimit>s.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${s.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{const e=o||(o=new WebAssembly.Module(r));t=new WebAssembly.Instance(e,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),s.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),s.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(A.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(2===e){const e=this.width*this.height;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}this._maxWidth=this._width}else if(1===e)if(2===this._level){const e=Math.min(this._rasterWidth,s.LIMITS.MAX_WIDTH)*this._rasterHeight;if(e>this._canvas.length){if(this._opts.memoryLimit&&4*e>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(e)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){const i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&4*i>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");const e=new Uint32Array(65536*Math.ceil(i/65536));e.set(this._canvas),this._canvas=e}}_handle_band(e){const t=this._PIXEL_OFFSET;let i=this._lastOffset;if(2===this._mode){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(1===this._mode){this._realloc(i,6*e),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=6*e,this._currentHeight+=6}return 0}get width(){return 1!==this._mode?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return 1!==this._mode?this._height:this._wasm.current_width()?6*this._bandWidths.length+this._wasm.current_height():6*this._bandWidths.length}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,s.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=s.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){const i=this._PIXEL_OFFSET;let A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(1===this._mode){if(this._minWidth===this._maxWidth){let t=!1;if(e)if(e!==this._minWidth)t=!0;else{const t=this._PIXEL_OFFSET;let i=this._lastOffset;this._realloc(i,6*e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A)}if(!t)return this._canvas.subarray(0,this.width*this.height)}const t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let e=0;e{if(this._disposed)return(0,A.toDisposable)(()=>{});const s={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(s);const r=(0,A.toDisposable)(()=>{const e=this._listeners.indexOf(s);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let i=0,A=t.length;it.fire(e))},e.map=function(e,t){return(i,A,s)=>e(e=>i.call(A,t(e)),void 0,s)},e.any=function(...e){return(t,i,s)=>{const r=new A.DisposableStore;for(const A of e)r.add(A(e=>t.call(i,e)));return s&&(Array.isArray(s)?s.push(r):s.add(r)),r}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(s||(t.EventUtils=s={}))},426(e,t){function i(e){return{dispose:e}}function A(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=A,t.combinedDisposable=function(...e){return i(()=>A(e))};class s{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=s;class r{constructor(){this._store=new s}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=r,r.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},795(e,t,i){var A=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.IIPHandler=void 0;const s=i(463),r=i(699),o=A(i(669)),a=A(i(61)),n=i(389),h=i(462),g={type:0,name:"Unnamed file",size:0,width:"auto",height:"auto",preserveAspectRatio:1,inline:0};t.IIPHandler=class{constructor(e,t,i,A){this._opts=e,this._renderer=t,this._storage=i,this._coreTerminal=A,this._generation=0,this._aborted=!1,this._hp=new n.HeaderParser,this._header=g,this._isMultipart=!1,this._abortMulti=!1;const s=Math.ceil(4*this._opts.iipSizeLimit/3),r=Math.min(1048576,s);this._dec=new o.default(0,s,r),this._qoiDec=new a.default(0)}reset(){this._generation++,this._hp.reset(),this._dec.release(),this._qoiDec.release()}start(){this._aborted=!1,this._hp.reset()}put(e,t,i){if(!this._aborted)if(4===this._hp.state)0!==this._dec.put(e.subarray(t,i))&&(this._dec.release(),this._aborted=!0);else{const A=this._hp.parse(e,t,i);if(-1===A)return void(this._aborted=!0);if(A>0){if(1===this._hp.fields.type){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},g,this._hp.fields),!this._header.inline)return void(this._aborted=!0);if(!this._initDecoder())return void(this._aborted=!0)}else if(this._abortMulti)return void(this._aborted=!0);0!==this._dec.put(e.subarray(A,i))&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){var t,i,A;if(this._aborted)return!0;if(4!==this._hp.state&&this._hp.end())return!0;const o=this._hp.fields.type;if(3===o)return!0;if(5===o){let e=r.CELL_SIZE_DEFAULT.width,i=r.CELL_SIZE_DEFAULT.height;this._renderer.dimensions&&(e=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,i=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);const s=null!==(A=null===(t=this._coreTerminal._core._coreBrowserService)||void 0===t?void 0:t.dpr)&&void 0!==A?A:1,o=`]1337;ReportCellSize=${i.toFixed(3)};${e.toFixed(3)};${s.toFixed(3)}\\`;return this._coreTerminal.input(o,!1),!0}if(2===o)return this._header=Object.assign({},g,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._initDecoder()||(this._abortMulti=!0),!0;if(4===o){if(!this._isMultipart)return!0;if(this._isMultipart=!1,this._abortMulti||2!==this._header.type)return!0}let a,n,d=0,l=0,I=h.UNSUPPORTED_TYPE;if((a=e)&&((a=!this._dec.end())?(I=(0,h.imageType)(this._dec.data8),(a="unsupported"!==I.mime)?(d=I.width,l=I.height,(a=d&&l&&d*lc!==this._generation?(e.close(),!0):(this._storage.addImage(e),!0)).catch(e=>(console.warn(`IIP: decoding error ${I.mime} ${I.width}x${I.height}`,e),!0))}_initDecoder(){try{return this._dec.init(),!0}catch(e){return console.warn("IIP: could not allocate decoder",e),this._dec.release(),!1}}_resize(e,t){var i,A,s,o;const a=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,n=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height,h=(null===(s=this._renderer.dimensions)||void 0===s?void 0:s.css.canvas.width)||a*this._coreTerminal.cols,g=(null===(o=this._renderer.dimensions)||void 0===o?void 0:o.css.canvas.height)||n*this._coreTerminal.rows,d=this._dim(this._header.width,h,a),l=this._dim(this._header.height,g,n);if(!d&&!l){const i=h/e,A=(g-n)/t,s=Math.min(i,A);return s<1?[e*s,t*s]:[e,t]}return d?!this._header.preserveAspectRatio&&d&&l?[d,l]:[d,t*d/e]:[e*l/t,l]}_dim(e,t,i){return"auto"===e?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}}},389(e,t){function i(e){let t="";for(let i=0;i57)throw new Error("illegal char");t=10*t+e[i]-48}return t}function s(e){const t=i(e);if(!t.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return t}Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderParser=void 0;const r={inline:A,size:A,name:function(e){if("undefined"!=typeof Buffer)return Buffer.from(i(e),"base64").toString();const t=atob(i(e)),A=new Uint8Array(t.length);for(let e=0;e14)return-1;for(let h=t;h=d)return this._a();r[s++]=t}break;case 58:return 3!==A||this._storeValue(s)?(this.state=4,h+1):this._a();default:if(s>=d)return this._a();r[s++]=t}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){const t=i(this._buffer.subarray(0,e));return!!t&&(this._key=t,this.fields[t]=null,!0)}_storeValue(e){if(this._key){try{const t=this._buffer.slice(0,e);this.fields[this._key]=r[this._key]?r[this._key](t):t}catch(e){return!1}return!0}return!1}}},649(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.IIPImageStorage=void 0,t.IIPImageStorage=class{constructor(e){this._storage=e,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"}}addImage(e){this._storage.addImage(e,this._addImageOpts)}}},462(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UNSUPPORTED_TYPE=void 0,t.imageType=function(e){if(e.length<32)return t.UNSUPPORTED_TYPE;const i=new Uint32Array(e.buffer,e.byteOffset,8);if(1196314761===i[0]&&169478669===i[1]&&1380206665===i[3])return{mime:"image/png",width:e[16]<<24|e[17]<<16|e[18]<<8|e[19],height:e[20]<<24|e[21]<<16|e[22]<<8|e[23]};if(255===e[0]&&216===e[1]&&255===e[2]){const[t,i]=function(e){const t=e.length;let i=4,A=e[i]<<8|e[i+1];for(;;){if(i+=A,i>=t)return[0,0];if(255!==e[i])return[0,0];if(192===e[i+1]||194===e[i+1])return i+8>>14&16383)};case 32:return 157!==e[23]||1!==e[24]||42!==e[25]?t.UNSUPPORTED_TYPE:{mime:"image/webp",width:16383&(e[26]|e[27]<<8),height:16383&(e[28]|e[29]<<8)}}return t.UNSUPPORTED_TYPE}if(1887007846===i[1]&&(1718187617===i[2]||1936291425===i[2])){let i=-1;const A=Math.min(e.length-16,1024);for(let t=8;t0&&A>0)return{mime:"image/avif",width:t,height:A}}return t.UNSUPPORTED_TYPE}return t.UNSUPPORTED_TYPE},t.UNSUPPORTED_TYPE={mime:"unsupported",width:0,height:0}},463(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageRenderer=void 0;const A=i(939),s=i(426);class r extends s.Disposable{get canvas(){var e;return null===(e=this._layers.get("top"))||void 0===e?void 0:e.canvas}static createCanvas(e,t,i){const A=(null!=e?e:document).createElement("canvas");return A.width=0|t,A.height=0|i,A}static createImageData(e,t,i,A){if("function"!=typeof ImageData){const s=e.createImageData(t,i);return A&&s.data.set(new Uint8ClampedArray(A,0,t*i*4)),s}return A?new ImageData(new Uint8ClampedArray(A,0,t*i*4),t,i):new ImageData(t,i)}static createImageBitmap(e){return"function"!=typeof createImageBitmap?Promise.resolve(void 0):createImageBitmap(e)}constructor(e){super(),this._terminal=e,this._layers=new Map,this._optionsRefresh=this._register(new s.MutableDisposable),this._oldOpen=this._terminal._core.open,this._terminal._core.open=e=>{var t;null===(t=this._oldOpen)||void 0===t||t.call(this._terminal._core,e),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(e=>{var t;"fontSize"===e&&(this.rescaleCanvas(),null===(t=this._renderService)||void 0===t||t.refreshRows(0,this._terminal.rows))}),this._register((0,s.toDisposable)(()=>{var e;this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),null===(e=this._placeholderBitmap)||void 0===e||e.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}showPlaceholder(e){var t,i;e?this._placeholder||-1===this.cellSize.height||this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),null===(i=this._renderService)||void 0===i||i.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){var e,t;return{width:(null===(e=this.dimensions)||void 0===e?void 0:e.css.cell.width)||-1,height:(null===(t=this.dimensions)||void 0===t?void 0:t.css.cell.height)||-1}}clearLines(e,t,i){var A,s,r,o,a;const n=e*((null===(A=this.dimensions)||void 0===A?void 0:A.css.cell.height)||0),h=(null===(s=this.dimensions)||void 0===s?void 0:s.css.canvas.width)||0,g=(t+1-e)*((null===(r=this.dimensions)||void 0===r?void 0:r.css.cell.height)||0);i&&"top"!==i||null===(o=this._layers.get("top"))||void 0===o||o.clearRect(0,n,h,g),i&&"bottom"!==i||null===(a=this._layers.get("bottom"))||void 0===a||a.clearRect(0,n,h,g)}clearAll(e){if(!e||"top"===e){const e=this._layers.get("top");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}if(!e||"bottom"===e){const e=this._layers.get("bottom");null==e||e.clearRect(0,0,e.canvas.width,e.canvas.height)}}draw(e,t,i,A,s=1){const r=this._layers.get(e.layer);if(!r)return;const{width:o,height:a}=this.cellSize;if(-1===o||-1===a)return;this._rescaleImage(e,o,a);const n=e.actual,{width:h,height:g}=e.actualCellSize,d=Math.ceil(n.width/h),l=t%d*h,I=Math.floor(t/d)*g,c=i*o,_=A*a,C=s*h+l>n.width?n.width-l:s*h,B=I+g>n.height?n.height-I:g;r.drawImage(n,Math.floor(l),Math.floor(I),Math.ceil(C),Math.ceil(B),Math.floor(c),Math.floor(_),Math.ceil(C*o/h),Math.ceil(B*a/g))}extractTile(e,t){const{width:i,height:A}=this.cellSize;if(-1===i||-1===A)return;this._rescaleImage(e,i,A);const s=e.actual,{width:o,height:a}=e.actualCellSize,n=Math.ceil(s.width/o),h=t%n*o,g=Math.floor(t/n)*a,d=o+h>s.width?s.width-h:o,l=g+a>s.height?s.height-g:a,I=r.createCanvas(this.document,Math.ceil(d*i/o),Math.ceil(l*A/a)),c=I.getContext("2d");return c?(c.drawImage(s,Math.floor(h),Math.floor(g),Math.floor(d),Math.floor(l),0,0,I.width,I.height),I):void 0}drawPlaceholder(e,t,i=1){var A;const s=this._layers.get("top");if(s){const{width:r,height:o}=this.cellSize;if(-1===r||-1===o)return;if(this._placeholder?o>=this._placeholder.height&&this._createPlaceHolder(o+1):this._createPlaceHolder(Math.max(o+1,24)),!this._placeholder)return;s.drawImage(null!==(A=this._placeholderBitmap)&&void 0!==A?A:this._placeholder,e*r,t*o%2?0:1,r*i,o,e*r,t*o,r*i,o)}}rescaleCanvas(){var e,t;const i=(null===(e=this.dimensions)||void 0===e?void 0:e.css.canvas.width)||0,A=(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.height)||0;for(const e of this._layers.values())e.canvas.width===i&&e.canvas.height===A||(e.canvas.width=i,e.canvas.height=A)}_rescaleImage(e,t,i){if(t===e.actualCellSize.width&&i===e.actualCellSize.height)return;const{width:A,height:s}=e.origCellSize;if(t===A&&i===s)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const o=Math.ceil(e.orig.width*t/A),a=Math.ceil(e.orig.height*i/s);if(o*a>e.orig.width*e.orig.height)return e.actual=e.orig,e.actualCellSize.width=A,void(e.actualCellSize.height=s);const n=r.createCanvas(this.document,o,a),h=n.getContext("2d");h&&(h.drawImage(e.orig,0,0,n.width,n.height),e.actual=n,e.actualCellSize.width=t,e.actualCellSize.height=i)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=e=>{var t;for(const e of[...this._layers.keys()])this.removeLayerFromDom(e);null===(t=this._oldSetRenderer)||void 0===t||t.call(this._renderService,e)}}insertLayerToDom(e="top"){var t,i;if(!this.document||!this._terminal._core.screenElement)return void console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");if(this._layers.has(e))return;const A=r.createCanvas(this.document,(null===(t=this.dimensions)||void 0===t?void 0:t.css.canvas.width)||0,(null===(i=this.dimensions)||void 0===i?void 0:i.css.canvas.height)||0);A.classList.add(`xterm-image-layer-${e}`);const s=this._terminal._core.screenElement;s.style.isolation="isolate","bottom"===e?(A.style.zIndex="-1",s.insertBefore(A,s.firstChild)):(A.style.zIndex="0",s.appendChild(A));const o=A.getContext("2d",{alpha:!0});o?(this._layers.set(e,o),this.clearAll(e)):A.remove()}removeLayerFromDom(e="top"){const t=this._layers.get(e);t&&(t.canvas.remove(),this._layers.delete(e))}hasLayer(e){return this._layers.has(e)}_createPlaceHolder(e=24){var t;null===(t=this._placeholderBitmap)||void 0===t||t.close(),this._placeholderBitmap=void 0;const i=32,s=r.createCanvas(this.document,i,e),o=s.getContext("2d",{alpha:!1});if(!o)return;const a=r.createImageData(o,i,e),n=new Uint32Array(a.data.buffer),h=(0,A.toRGBA8888)(0,0,0),g=(0,A.toRGBA8888)(255,255,255);n.fill(h);for(let t=0;t{this._placeholder!==I?null==e||e.close():this._placeholderBitmap=e}).catch(()=>{})}get document(){var e;return null===(e=this._terminal._core._coreBrowserService)||void 0===e?void 0:e.window.document}}t.ImageRenderer=r},699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ImageStorage=t.CELL_SIZE_DEFAULT=void 0;const A=i(463);t.CELL_SIZE_DEFAULT={width:7,height:14};class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0,i=-1,A=-1){this.imageId=i,this.tileId=A,this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId&&-1===this.imageId}}const r=new s;t.ImageStorage=class{constructor(e,t,i){this._terminal=e,this._renderer=t,this._opts=i,this._images=new Map,this._lastId=0,this._lowestId=0,this._fullyCleared=!1,this._needsFullClear=!1,this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(e){e instanceof Error&&console.error(e.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){var e;for(const t of this._images.values())null===(e=t.marker)||void 0===e||e.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return 4*this._pixelLimit/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return 4*this._getStoredPixels()/1e6}_getStoredPixels(){let e=0;for(const t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){var t;const i=this._images.get(e);i&&(this._images.delete(e),window.ImageBitmap&&i.orig instanceof ImageBitmap&&i.orig.close(),null===(t=this.onImageDeleted)||void 0===t||t.call(this,e))}wipeAlternate(){var e;const t=[];for(const[i,A]of this._images.entries())"alternate"===A.bufferType&&(null===(e=A.marker)||void 0===e||e.dispose(),t.push(i));for(const e of t)this._delImg(e);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){var t;const i=this._images.get(e);i&&(null===(t=i.marker)||void 0===t||t.dispose(),this._delImg(e))}addImage(e,i){var A,s;this._evictOldest(e.width*e.height);let r=this._renderer.cellSize;-1!==r.width&&-1!==r.height||(r=t.CELL_SIZE_DEFAULT);const o=Math.ceil(e.width/r.width),a=Math.ceil(e.height/r.height),n=++this._lastId,h=this._terminal._core.buffer,g=this._terminal.cols,d=this._terminal.rows,l=h.x,I=h.y;let c=l,_=0;i.scrolling||(h.x=0,h.y=0,c=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y);for(let e=0;e=g);++i)this._writeToCell(t,c+i,n,e*o+i),_++;if(i.scrolling)e=d)break;h.x=c}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(h.y),i.scrolling?"iip"===i.cursorPos?h.x=Math.min(c+o,g):h.x=c:(h.x=l,h.y=I);const C=[];for(const[e,t]of this._images.entries())t.tileCount<1&&(null===(A=t.marker)||void 0===A||A.dispose(),C.push(e));for(const e of C)this._delImg(e);const B=this._terminal.registerMarker(0);null==B||B.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),"alternate"===this._terminal.buffer.active.type&&this._evictOnAlternate();const Q={orig:e,origCellSize:r,actual:e,actualCellSize:Object.assign({},r),marker:B||void 0,tileCount:_,bufferType:this._terminal.buffer.active.type,layer:i.layer,zIndex:i.zIndex};return this._images.set(n,Q),null===(s=this.onImageAdded)||void 0===s||s.call(this),n}render(e){var t,i;let A=!1,s=!1;for(const e of this._images.values())if("bottom"===e.layer?s=!0:A=!0,A&&s)break;if(A&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(s&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size)return this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),void(this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom"));!A&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!s&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);const{start:o,end:a}=e,n=this._terminal._core.buffer,h=this._terminal._core.cols;this._renderer.clearLines(o,a);const g=[],d=[];for(let e=o;e<=a;++e){const A=n.lines.get(e+n.ydisp);if(!A)return;for(let s=0;se.imgSpec.zIndex-t.imgSpec.zIndex);for(const e of d)this._renderer.drawPlaceholder(e.col,e.row,e.count);for(const e of g)this._renderer.draw(e.imgSpec,e.tileId,e.col,e.row,e.count)}viewportResize(e){var t,i;if(!this._images.size)return void(this._viewportMetrics=e);if(this._viewportMetrics.cols>=e.cols)return void(this._viewportMetrics=e);const A=this._terminal._core.buffer,s=A.lines.length,o=this._viewportMetrics.cols-1;for(let a=0;a=h)continue;let g=!1;for(let t=o+1;t>e.cols;++t)if(4194303&s._data[3*t+0]){g=!0;break}if(g)continue;const d=Math.min(e.cols,h-A.tileId%h+o);let l=A.tileId;for(let e=o+1;e4194304&&e.release();const A=null!==(i=n.get(t))&&void 0!==i?i:[];A.length<2&&(A.push(e),n.set(t,A))}function d(e){return A.BIG_ENDIAN?e:(255&e)<<24|(e>>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255}t.SixelHandler=class{constructor(e,t,i){var A;this._opts=e,this._storage=t,this._coreTerminal=i,this._size=0,this._aborted=!1,this._decMemoryLimit=0,this._palette=new Uint32Array(o.LIMITS.PALETTE_SIZE),this._palette.set(a),A=4*this._opts.pixelLimit,h||(h=!0,(0,r.DecoderAsync)({memoryLimit:A,palette:a}).then(e=>g(e,A),()=>{h=!1}))}reset(){this._returnDecoder(),this._palette.fill(0),this._palette.set(a)}hook(e){var t;this._size=0,this._aborted=!1,this._returnDecoder();const i=4*this._opts.pixelLimit;try{this._dec=function(e){var t,i;return null!==(i=null===(t=n.get(e))||void 0===t?void 0:t.pop())&&void 0!==i?i:new r.Decoder({memoryLimit:e,palette:a})}(i)}catch(e){return console.warn(`SIXEL: could not allocate decoder - ${e}`),void(this._aborted=!0)}this._decMemoryLimit=i;const s=1===e.params[1]?0:function(e,t){let i=0;if(!t)return i;if(e.isInverse())if(e.isFgDefault())i=d(t.foreground.rgba);else if(e.isFgRGB()){const t=e.constructor.toColorRGB(e.getFgColor());i=(0,A.toRGBA8888)(...t)}else i=d(t.ansi[e.getFgColor()].rgba);else if(e.isBgDefault())i=d(t.background.rgba);else if(e.isBgRGB()){const t=e.constructor.toColorRGB(e.getBgColor());i=(0,A.toRGBA8888)(...t)}else i=d(t.ansi[e.getBgColor()].rgba);return i}(this._coreTerminal._core._inputHandler._curAttrData,null===(t=this._coreTerminal._core._themeService)||void 0===t?void 0:t.colors);this._dec.init(s,this._palette,this._opts.sixelPaletteLimit)}_returnDecoder(){const e=this._dec;e&&(this._dec=void 0,this._palette.set(e.palette),g(e,this._decMemoryLimit))}put(e,t,i){if(!this._aborted&&this._dec){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit)return console.warn("SIXEL: too much data, aborting"),this._aborted=!0,void this._dec.release();try{this._dec.decode(e,t,i)}catch(e){console.warn(`SIXEL: error while decoding image - ${e}`),this._aborted=!0,this._dec.release()}}}unhook(e){try{return this._unhook(e)}finally{this._returnDecoder()}}_unhook(e){var t;if(this._aborted||!e||!this._dec)return!0;const i=this._dec.width,A=this._dec.height;if(!i||!A)return A&&this._storage.advanceCursor(A),!0;const r=s.ImageRenderer.createCanvas(void 0,i,A);return null===(t=r.getContext("2d"))||void 0===t||t.putImageData(new ImageData(this._dec.data8,i,A),0,0),this._storage.addImage(r),!0}}},994(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SixelImageStorage=void 0;const A=i(699);t.SixelImageStorage=class{constructor(e,t,i,A){this._storage=e,this._opts=t,this._renderer=i,this._terminal=A,this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;-1!==t.width&&-1!==t.height||(t=A.CELL_SIZE_DEFAULT);const i=Math.ceil(e/t.height);for(let e=1;e512)return void(this._aborted=!0);if(this._controlData.set(e.subarray(t,s),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=(0,n.parseKittyCommand)(this._parseControlDataString()),void 0!==this._parsedCommand.id&&void 0!==this._parsedCommand.imageNumber)return this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",null!==(A=this._parsedCommand.quiet)&&void 0!==A?A:0),void(this._aborted=!0);if("d"===this._parsedCommand.action)return;const t=s+1;tthis._encodedSizeLimit){const e=null!==(g=this._activeDecoder)&&void 0!==g?g:null==_?void 0:_.decoder;return e&&e.release(),this._activeDecoder=null,_&&this._removePendingEntry(c),void(this._aborted=!0)}if(!this._decodeError){if((null==_?void 0:_.decoder)&&!this._activeDecoder&&(this._activeDecoder=_.decoder),!this._activeDecoder){const e=this._maxEncodedBytes+131072;if(e>1e6*this._opts.storageLimit)return this._aborted=!0,void(void 0!==(null===(s=this._parsedCommand)||void 0===s?void 0:s.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",null!==(d=this._parsedCommand.quiet)&&void 0!==d?d:0));const t=Math.max(1,Math.floor(1e6*this._opts.storageLimit/e));for(;this._pendingTransmissions.size>=t;){const e=this._pendingTransmissions.entries().next().value;if(!e)break;e[1].decoder.release(),this._removePendingEntry(e[0]),void 0!==e[1].cmd.id&&this._sendResponse(e[1].cmd.id,"ENOMEM:pending image budget exceeded",null!==(l=e[1].cmd.quiet)&&void 0!==l?l:0)}const i=new a.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes);try{i.init()}catch(e){return console.warn("KITTY: could not allocate decoder",e),this._aborted=!0,void(void 0!==(null===(r=this._parsedCommand)||void 0===r?void 0:r.id)&&this._sendResponse(this._parsedCommand.id,"ENOMEM:could not allocate decoder",null!==(I=this._parsedCommand.quiet)&&void 0!==I?I:0))}this._activeDecoder=i}0!==this._activeDecoder.put(e.subarray(t,i))&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,_&&this._removePendingEntry(c))}}end(e){var t,i;if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();const A=this._parsedCommand;if("d"===A.action)return this._handleDelete(A);const s=null!==(i=null!==(t=A.id)&&void 0!==t?t:this._lastPendingKey)&&void 0!==i?i:0,r=1===A.more,o=this._pendingTransmissions.get(s);if(r)return this._activeDecoder&&(o?(o.totalEncodedSize+=this._totalEncodedSize,o.decodeError=o.decodeError||this._decodeError):this._pendingTransmissions.set(s,{cmd:Object.assign({},A),decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=s,this._activeDecoder=null),!0;o&&(this._lastPendingKey=void 0);let a=this._decodeError,n=A,h=this._activeDecoder;o&&(n=o.cmd,h=o.decoder,a=a||o.decodeError,this._pendingTransmissions.delete(s));let g=new Uint8Array(0);h&&(0!==h.end()&&(a=!0),g=h.data8),this._activeDecoder=null;const d=this._handleCommandWithBytesAndCmd(n,g,a);return h&&h.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",null!==(o=e.quiet)&&void 0!==o?o:0)),A}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported action",null!==(a=e.quiet)&&void 0!==a?a:0),!0}}_handlePlacement(e){var t;if(void 0===e.id)return!0;const i=e.id,A=this._kittyStorage.getImage(i);return A?this._displayImage(A,e).then(t=>{var A;return this._sendResponse(i,t?"OK":"EINVAL:image rendering failed",null!==(A=e.quiet)&&void 0!==A?A:0,e.placementId),!0}):(this._sendResponse(i,"ENOENT:image not found",null!==(t=e.quiet)&&void 0!==t?t:0,e.placementId),!0)}_handleTransmit(e,t,i){var A,s,r,o,a,n;return"d"!==(null!==(A=e.transmission)&&void 0!==A?A:"d")?(void 0!==e.id&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",null!==(s=e.quiet)&&void 0!==s?s:0),!0):(i||0===t.length||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:null!==(r=e.width)&&void 0!==r?r:0,height:null!==(o=e.height)&&void 0!==o?o:0,format:null!==(a=e.format)&&void 0!==a?a:32,compression:null!==(n=e.compression)&&void 0!==n?n:""}),!0)}_handleTransmitDisplay(e,t,i){var A,s;if(i)return void 0!==e.id&&this._sendResponse(e.id,"EINVAL:invalid base64 data",null!==(A=e.quiet)&&void 0!==A?A:0),!0;this._handleTransmit(e,t,i);const r=null!==(s=e.id)&&void 0!==s?s:this._kittyStorage.lastImageId,o=this._kittyStorage.getImage(r);if(o){const t=this._displayImage(o,e);return void 0!==e.id?t.then(t=>{var i;return this._sendResponse(r,t?"OK":"EINVAL:image rendering failed",null!==(i=e.quiet)&&void 0!==i?i:0),!0}):t.then(()=>!0)}return!0}_handleQuery(e,t,i){var A,s,r,o,a,n;const h=null!==(A=e.id)&&void 0!==A?A:0,g=null!==(s=e.quiet)&&void 0!==s?s:0;if("d"!==(null!==(r=e.transmission)&&void 0!==r?r:"d"))return this._sendResponse(h,"EINVAL:unsupported transmission medium",g),!0;if(i)return this._sendResponse(h,"EINVAL:invalid base64 data",g),!0;if(0===t.length)return this._sendResponse(h,"OK",g),!0;const d=null!==(o=e.format)&&void 0!==o?o:32;if(100===d)this._sendResponse(h,"OK",g);else{const i=null!==(a=e.width)&&void 0!==a?a:0,A=null!==(n=e.height)&&void 0!==n?n:0;if(!i||!A)return this._sendResponse(h,"EINVAL:width and height required for raw pixel data",g),!0;const s=i*A*(32===d?4:3);if(t.length=1)return;if(!s&&i>=2)return;const r=`_Gi=${e}${A?`,p=${A}`:""};${t}\\`;this._coreTerminal._core.coreService.triggerDataEvent(r)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){var i,A,o,a,n,h,g;const d=this._generation;let l=await this._createBitmap(e);try{if(d!==this._generation)throw new Error("image decode canceled");const I=Math.max(0,null!==(o=t.x)&&void 0!==o?o:0),c=Math.max(0,null!==(a=t.y)&&void 0!==a?a:0),_=t.sourceWidth||l.width-I,C=t.sourceHeight||l.height-c,B=Math.max(0,l.width-I),Q=Math.max(0,l.height-c),m=Math.max(0,Math.min(_,B)),u=Math.max(0,Math.min(C,Q));if(0===m||0===u)throw new Error("invalid source rectangle");if(0!==I||0!==c||m!==l.width||u!==l.height){const e=await createImageBitmap(l,I,c,m,u);l.close(),l=e}const E=(null===(i=this._renderer.dimensions)||void 0===i?void 0:i.css.cell.width)||r.CELL_SIZE_DEFAULT.width,p=(null===(A=this._renderer.dimensions)||void 0===A?void 0:A.css.cell.height)||r.CELL_SIZE_DEFAULT.height;let w,f;void 0!==t.columns&&void 0!==t.rows?(w=t.columns,f=t.rows):void 0!==t.columns?(w=t.columns,f=Math.max(1,Math.ceil(l.height/l.width*(w*E)/p))):void 0!==t.rows?(f=t.rows,w=Math.max(1,Math.ceil(l.width/l.height*(f*p)/E))):(w=Math.ceil(l.width/E),f=Math.ceil(l.height/p));let D=l.width,y=l.height;if(void 0===t.columns&&void 0===t.rows||(D=Math.round(w*E),y=Math.round(f*p)),D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");const v=this._coreTerminal._core.buffer,M=v.x,k=v.y,b=v.ybase,S=void 0!==t.zIndex&&t.zIndex<0?"bottom":"top";if(D!==l.width||y!==l.height){const e=await createImageBitmap(l,{resizeWidth:D,resizeHeight:y});l.close(),l=e}const L=Math.min(Math.max(0,null!==(n=t.xOffset)&&void 0!==n?n:0),E-1),N=Math.min(Math.max(0,null!==(h=t.yOffset)&&void 0!==h?h:0),p-1);if(0!==L||0!==N){const e=void 0!==t.columns?Math.round(w*E):l.width+L,i=void 0!==t.rows?Math.round(f*p):l.height+N,A=s.ImageRenderer.createCanvas(window.document,e,i),r=A.getContext("2d");if(!r)throw new Error("Failed to create offset canvas context");r.drawImage(l,L,N);const o=await createImageBitmap(A);if(A.width=A.height=0,l.close(),l=o,D=l.width,y=l.height,D*y>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");void 0===t.columns&&(w=Math.ceil(l.width/E)),void 0===t.rows&&(f=Math.ceil(l.height/p))}if(d!==this._generation)throw new Error("image decode canceled");const G=null!==(g=t.zIndex)&&void 0!==g?g:0;if(this._kittyStorage.addImage(e.id,l,!0,S,G),l=void 0,1===t.cursorMovement){const e=v.ybase-b;v.x=M,v.y=Math.max(k-e,0)}else v.x=Math.min(M+w,this._coreTerminal.cols)}catch(e){throw null==l||l.close(),e}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if("z"===e.compression&&(t=await this._decompressZlib(t)),100===e.format){const e=(0,o.imageType)(t);if("image/png"!==e.mime||!(e.width>0)||!(e.height>0)||e.width*e.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");const i=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){const e=URL.createObjectURL(i),t=new Image;return new Promise((i,A)=>{t.addEventListener("load",()=>{var r;URL.revokeObjectURL(e);const o=s.ImageRenderer.createCanvas(window.document,t.width,t.height);null===(r=o.getContext("2d"))||void 0===r||r.drawImage(t,0,0),createImageBitmap(o).then(i).catch(A)}),t.addEventListener("error",()=>{URL.revokeObjectURL(e),A(new Error("Failed to load image"))}),t.src=e})}return createImageBitmap(i)}const i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");const r=i*A*(32===e.format?4:3);if(t.length>>24|t<<8,g[I++]=4278190080|t>>>16|i<<16,g[I++]=4278190080|i>>>8}let c=3*d,_=4*d;for(let e=d;e=e.length)return void t.close();const i=Math.min(A+4096,e.length);t.enqueue(new Uint8Array(e.subarray(A,i))),A=i}}).pipeThrough(new DecompressionStream(t)).getReader(),r=[];let o=0;try{for(;;){const{done:e,value:t}=await s.read();if(e)break;if(o+=t.byteLength,o>i)throw await s.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");r.push(t)}}finally{s.releaseLock()}const a=new Uint8Array(o);let n=0;for(const e of r)a.set(e,n),n+=e.length;return a}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}}},470(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseKittyCommand=function(e){const t={},i=e.split(",");for(const e of i){const i=e.indexOf("=");if(-1===i)continue;const A=e.substring(0,i),s=e.substring(i+1);if("a"===A){t.action=s;continue}if("o"===A){t.compression=s;continue}if("t"===A){t.transmission=s;continue}if("d"===A){t.deleteSelector=s;continue}const r=parseInt(s,10);switch(A){case"f":t.format=r;break;case"i":t.id=r;break;case"I":t.imageNumber=r;break;case"s":t.width=r;break;case"v":t.height=r;break;case"x":t.x=r;break;case"y":t.y=r;break;case"w":t.sourceWidth=r;break;case"h":t.sourceHeight=r;break;case"X":t.xOffset=r;break;case"Y":t.yOffset=r;break;case"c":t.columns=r;break;case"r":t.rows=r;break;case"m":t.more=r;break;case"q":t.quiet=r;break;case"C":t.cursorMovement=r;break;case"z":t.zIndex=r;break;case"p":t.placementId=r}}return t}},235(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyImageStorage=void 0;class i{constructor(e){this._storage=e,this._nextImageId=1,this._images=new Map,this._kittyIdToStorageId=new Map,this._storageIdToKittyId=new Map,this._handleStorageImageDeleted=e=>{const t=this._storageIdToKittyId.get(e);void 0!==t&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))},this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"},this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=e=>{var t;null===(t=this._previousOnImageDeleted)||void 0===t||t.call(this,e),this._handleStorageImageDeleted(e)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){const A=null!=e?e:this._nextImageId++,s=this._kittyIdToStorageId.get(A);void 0!==s&&(this._storage.deleteImage(s),this._kittyIdToStorageId.delete(A),this._storageIdToKittyId.delete(s)),!this._images.has(A)&&this._images.size>=i._maxStoredImages&&this._evictUndisplayedImages();const r=1e6*this._storage.getLimit();this._images.delete(A);let o=0;for(const e of this._images.values())o+=e.data.size;for(const e of[!1,!0])for(const[i,A]of this._images){if(o+t.data.size<=r)break;this._kittyIdToStorageId.has(i)===e&&(o-=A.data.size,this.deleteById(i))}return this._images.set(A,Object.assign(Object.assign({},t),{id:A})),A}addImage(e,t,i,A,s){const r=this._kittyIdToStorageId.get(e);void 0!==r&&this._storageIdToKittyId.delete(r),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;const o=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,o),this._storageIdToKittyId.set(o,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);const t=this._kittyIdToStorageId.get(e);void 0!==t&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(const e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(const[e]of this._images){if(this._images.size<=i._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}}t.KittyImageStorage=i,i._maxStoredImages=256},669(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),s=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(e=>e.charCodeAt(0))),r=new Uint32Array(1024);r.fill(4278190080);for(let e=0;e>4|(e<<4&255)<<8;for(let e=0;e>2<<8|(e<<6&255)<<16;for(let e=0;ethis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):o}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=null!=e?e:this.maxBytes,this._bytes=null!=t?t:Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32;const s=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let e=this._bytes;for(;(e*=2)this._mem.buffer.byteLength){const t=Math.ceil((e+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(t),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=e}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;const t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}}},61(e,t,i){Object.defineProperty(t,"__esModule",{value:!0});const A=(0,i(552).InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="});t.default=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];const t=this.width*this.height,i=4*t,s=e.length,r=Math.max(i,s)+(Math.min(i,s)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}}},552(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.InWasm=function(e){if(e.d){const{t,s:A,d:s}=e;let r,o;const a=WebAssembly;return 0===t?A?e=>new a.Instance(o||(o=new a.Module(r||(r=i(s)))),e):e=>o?a.instantiate(o,e):a.instantiate(r||(r=i(s)),e).then(e=>(o=e.module)&&e.instance):1===t?A?()=>o||(o=new a.Module(r||(r=i(s)))):()=>o?Promise.resolve(o):a.compile(r||(r=i(s))).then(e=>o=e):A?()=>r||(r=i(s)):()=>Promise.resolve(r||(r=i(s)))}if("undefined"==typeof _wasmCtx)throw new Error('must run "inwasm"');_wasmCtx.add(e)};let i=e=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);if("undefined"!=typeof Buffer)return Buffer.from(e,"base64");const t=atob(e),i=new Uint8Array(t.length);for(let e=0;e{var e=A;Object.defineProperty(e,"__esModule",{value:!0}),e.ImageAddon=void 0;const t=i(414),s=i(795),r=i(463),o=i(699),a=i(23),n=i(235),h=i(566),g=i(994),d=i(649),l={enableSizeReports:!0,pixelLimit:16777216,sixelSupport:!0,sixelScrolling:!0,sixelPaletteLimit:4096,sixelSizeLimit:33554432,storageLimit:128,showPlaceholder:!0,iipSupport:!0,iipSizeLimit:33554432,kittySupport:!0,kittySizeLimit:33554432};e.ImageAddon=class{constructor(e){this._disposables=[],this._handlers=new Map,this._onImageAdded=new t.Emitter,this.onImageAdded=this._onImageAdded.event,this._opts=Object.assign({},l,e),this._defaultOpts=Object.assign({},l,e)}dispose(){for(const e of this._handlers.values())e.reset();for(const e of this._disposables)e.dispose();this._disposables.length=0,this._handlers.clear(),this._onImageAdded.dispose()}_disposeLater(...e){for(const t of e)this._disposables.push(t)}activate(e){var t;if(this._terminal=e,this._renderer=new r.ImageRenderer(e),this._storage=new o.ImageStorage(e,this._renderer,this._opts),this._storage.onImageAdded=()=>this._onImageAdded.fire(),this._opts.enableSizeReports){const i=null!==(t=e.options.windowOptions)&&void 0!==t?t:{};i.getWinSizePixels=!0,i.getCellSizePixels=!0,i.getWinSizeChars=!0,e.options.windowOptions=i}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},e=>this._decset(e)),e.parser.registerCsiHandler({prefix:"?",final:"l"},e=>this._decrst(e)),e.parser.registerCsiHandler({final:"c"},e=>this._da1(e)),e.parser.registerCsiHandler({prefix:"?",final:"S"},e=>this._xtermGraphicsAttributes(e)),e.onRender(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.render(e)}),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>{var e;return null===(e=this._storage)||void 0===e?void 0:e.wipeAlternate()}),e.onResize(e=>{var t;return null===(t=this._storage)||void 0===t?void 0:t.viewportResize(e)})),this._opts.sixelSupport){const t=new g.SixelImageStorage(this._storage,this._opts,this._renderer,e),i=new h.SixelHandler(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){const t=new d.IIPImageStorage(this._storage),i=new s.IIPHandler(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){const t=new n.KittyImageStorage(this._storage),i=new a.KittyGraphicsHandler(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){var e;this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,null===(e=this._storage)||void 0===e||e.reset();for(const e of this._handlers.values())e.reset();return!1}get storageLimit(){var e;return(null===(e=this._storage)||void 0===e?void 0:e.getLimit())||-1}set storageLimit(e){var t;null===(t=this._storage)||void 0===t||t.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){var t;this._opts.showPlaceholder=e,null===(t=this._renderer)||void 0===t||t.showPlaceholder(e)}getImageAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){var i;return null===(i=this._storage)||void 0===i?void 0:i.extractTileAtBufferCell(e,t)}_report(e){var t;null===(t=this._terminal)||void 0===t||t._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=4096?(this._opts.sixelPaletteLimit=e[2],this._report(`[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`[?${e[0]};2S`),!0;case 4:return this._report(`[?${e[0]};0;4096S`),!0;default:return this._report(`[?${e[0]};2S`),!0}if(2===e[0])switch(e[1]){case 1:let n=null===(i=null===(t=this._renderer)||void 0===t?void 0:t.dimensions)||void 0===i?void 0:i.css.canvas.width,h=null===(s=null===(A=this._renderer)||void 0===A?void 0:A.dimensions)||void 0===s?void 0:s.css.canvas.height;if(!n||!h){const e=o.CELL_SIZE_DEFAULT;n=((null===(r=this._terminal)||void 0===r?void 0:r.cols)||80)*e.width,h=((null===(a=this._terminal)||void 0===a?void 0:a.rows)||24)*e.height}if(n*h {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: '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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJDmpBsIkCQYAE/AoAAAsgBEEBSA0AIANBAXEhBSAHBH8gA0H+////B3EhAkEAIQEDQCABQQl0IgZBEHJB4IkSakGwiQJBgAT8CgAAIAZBkARyQeCJEmpBsIkCQYAE/AoAACABQQJqIQEgAkECayICDQALIAFBB3RBBHIFQQQLIQEgBQRAIAFBAnRB4IkSakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEEIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiA0EQckHwiRZqQbCJAkGABPwKAAAgA0GQBHJB8IkWakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAERQ0AIAFBAnRB8IkWakGwiQJBgAT8CgAAC0HUCCAIQQd0QQRyNgIAC58TAgh/AX5B5AgoAgAhA0HgCCgCACECQegIKAIAIQcgAUGQiQFqIglB/wE6AAAgACABSARAIABBkIkBaiEIA0AgAiEEIAhBAWohAQJAIAgtAABB/wBxIgJBMGtBCUsEQCABIQgMAQtB7AgoAgBBAnRB7AhqIgUoAgAhAANAIAUgAiAAQQpsakEwayIANgIAIAEtAAAhAiABQQFqIgghASACQf8AcSICQTBrQQpJDQALCwJAAkACQAJAAkACQAJ/AkAgAkE/ayIAQT9NBEAgBEUNASAEQSFGBEBB8AgoAgAiAUEBIAEbIgQgB2ohAQJAIABFDQAgB0H//wBLDQBBgIABIAdrIAQgAUH//wBLGyEFAkAgAEEBcUUNACAHQQJ0QaCJAmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEECcUUNACAHQQJ0QbCJBmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEEcUUNACAHQQJ0QcCJCmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEIcUUNACAHQQJ0QdCJDmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLAkAgAEEQcUUNACAHQQJ0QeCJEmohAiAFIgRBB3EiBgRAA0AgAiADNgIAIAJBBGohAiAEQQFrIQQgBkEBayIGDQALCyAFQQFrQQdJDQADQCACIAM2AhwgAiADNgIYIAIgAzYCFCACIAM2AhAgAiADNgIMIAIgAzYCCCACIAM2AgQgAiADNgIAIAJBIGohAiAEQQhrIgQNAAsLIABBIHFFDQAgBUEBayEEIAdBAnRB8IkWaiEAIAVBB3EiAgRAA0AgACADNgIAIABBBGohACAFQQFrIQUgAkEBayICDQALCyAEQQdJDQADQCAAIAM2AhwgACADNgIYIAAgAzYCFCAAIAM2AhAgACADNgIMIAAgAzYCCCAAIAM2AgQgACADNgIAIABBIGohACAFQQhrIgUNAAsLIAhBAWoiBSAILQAAQf8AcSICQT9rIgBBP00NAxoMBAsCQEHsCCgCACIFQQFGBEBB8AgoAgAiAUHMCCgCACIESQ0BIAEgBHAhAQwBC0H4CCgCACEEQfQIKAIAIQECQAJAIAVBBUcNACABQQFHDQAgBEHpAk4NBAwBCyAEQeQASg0DQfwIKAIAQeQASg0DQYAJKAIAQeQASg0DCwJAIAFFDQAgAUECSg0AIARB/AgoAgBBgAkoAgAgAUECdEGICGooAgARAgAhBEHwCCgCACIBQcwIKAIAIgVPBH8gASAFcAUgAQtBAnRBkAlqIAQ2AgALQfAIKAIAIgFBzAgoAgAiBEkNACABIARwIQELIAFBAnRBkAlqKAIAIQMMAQsgAkH9AHFBIUcEQCAHIQEgAiEADAQLIARBI0cNBAJAQewIKAIAIgRBAUYEQEHwCCgCACIBQcwIKAIAIgBJDQEgASAAcCEBDAELQfgIKAIAIQFB9AgoAgAhAAJAAkAgBEEFRw0AIABBAUcNACABQekCSA0BDAcLIAFB5ABKDQZB/AgoAgBB5ABKDQZBgAkoAgBB5ABKDQYLAkAgAEUNACAAQQJKDQAgAUH8CCgCAEGACSgCACAAQQJ0QYgIaigCABECACEAQfAIKAIAIgFBzAgoAgAiBE8EfyABIARwBSABC0ECdEGQCWogADYCAAtB8AgoAgAiAUHMCCgCACIASQ0AIAEgAHAhAQsgAUECdEGQCWooAgAhAwwECyAHIQEgCAshBQNAIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiADNgIAIABBAXZBAXEgAWxBAnRBsIkGaiADNgIAIABBAnZBAXEgAWxBAnRBwIkKaiADNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiADNgIAIABBBHZBAXEgAWxBAnRB4IkSaiADNgIAIABBBXYgAWxBAnRB8IkWaiADNgIACyABQQFqIQEgBS0AACEAIAVBAWoiBCEFIABB/wBxIgJBP2siAEHAAEkNAAsgBCEFC0EAIQQgBSEIIAEhByACIQAgAkH9AHFBIUYNAQtBBCEHIAQhAiAAQSRrDgoDAgICAgICAgIBAgtB7AhCATcCAAwCC0GoCCgCAEEEaxAABEBB0AhBATYCAA8LAkBBqAgoAgAiBkEFSA0AQaAIKQMAIQogBkEDa0EBdiIBQQdxIQJBACEAIAFBAWtBB08EQCABQfj///8HcSEFA0AgAEEDdCIBQbCJAmogCjcDACABQQhyQbCJAmogCjcDACABQRByQbCJAmogCjcDACABQRhyQbCJAmogCjcDACABQSByQbCJAmogCjcDACABQShyQbCJAmogCjcDACABQTByQbCJAmogCjcDACABQThyQbCJAmogCjcDACAAQQhqIQAgBUEIayIFDQALCyACRQ0AA0AgAEEDdEGwiQJqIAo3AwAgAEEBaiEAIAJBAWsiAg0ACwtBwIkGQbCJAiAGQQJ0IgD8CgAAQdCJCkGwiQIgAPwKAABB4IkOQbCJAiAA/AoAAEHwiRJBsIkCIAD8CgAAQYCKFkGwiQIgAPwKAAAgBCECDAELAkAgAEE7Rw0AQewIKAIAIgBBB0oNAEHsCCAAQQFqNgIAIABBAnRB8AhqQQA2AgALIAEhBwsgCCAJSQ0ACwtB5AggAzYCAEHgCCACNgIAQegIIAc2AgAL4gcCBX8BfgJAQdAIAn8CQAJAIAAgAU4NACABQZCJAWohBiAAQZCJAWohBQNAIAUtAAAiA0H/AHEhAgJAAkACQAJAAkACQAJAQeAIKAIAIgRBIkcEQCAEDQcgAkEiRgRAQewIQgE3AgBB4AhBIjYCAAwICyACQT9rQcAASQ0GIANBIWsiAkEMTQ0BDAULAkAgAkEwayIEQQlNBEBB7AgoAgBBAnRB7AhqIgIgBCACKAIAQQpsajYCAAwBC0HsCCgCACEEIAJBO0YEQCAEQQdKDQFB7AggBEEBajYCACAEQQJ0QfAIakEANgIADAELIARBBEYEQEHECEECNgIAQbAIQfAIKQMANwMAQbgIQfgIKAIAIgI2AgBBvAhB/AgoAgAiBDYCAEHICEECQQFBwAgoAgAiAxs2AgBBrAggBEEAIAMbNgIAQagIIAJBgIABIAJBgIABSBtBBGpBACADGzYCAEHgCEEANgIADAoLIAJBP2tBwABJDQQLIANBIWsiAkEMTQ0BDAILQQEgAnRBjSBxRQ0DDAQLQQEgAnRBjSBxDQELIANBoQFrIgJBDEsNA0EBIAJ0QY0gcUUNAwtBxAhCgYCAgBA3AgBBsAhB8AgoAgBBAEHsCCgCACICQQBKGzYCAEG0CEH0CCgCAEEAIAJBAUobNgIAQbgIQfgIKAIAQQAgAkECShs2AgBB4AhBADYCAEG8CEEANgIADAQLIANBoQFrIgJBDEsNAUEBIAJ0QY0gcUUNAQtBxAhCgYCAgBA3AgBBsAhCADcDAEG4CEIANwMADAMLIAVBAWoiBSAGSQ0ACwsCQEHICCgCAA4DAwEAAQsCQEGoCCgCACIFQQVIDQBBoAgpAwAhByAFQQNrQQF2IgNBB3EhBEEAIQIgA0EBa0EHTwRAIANB+P///wdxIQYDQCACQQN0IgNBsIkCaiAHNwMAIANBCHJBsIkCaiAHNwMAIANBEHJBsIkCaiAHNwMAIANBGHJBsIkCaiAHNwMAIANBIHJBsIkCaiAHNwMAIANBKHJBsIkCaiAHNwMAIANBMHJBsIkCaiAHNwMAIANBOHJBsIkCaiAHNwMAIAJBCGohAiAGQQhrIgYNAAsLIARFDQADQCACQQN0QbCJAmogBzcDACACQQFqIQIgBEEBayIEDQALC0HAiQZBsIkCIAVBAnQiA/wKAABB0IkKQbCJAiAD/AoAAEHgiQ5BsIkCIAP8CgAAQfCJEkGwiQIgA/wKAABBgIoWQbCJAiAD/AoAAEECDAELEAhByAgoAgALEAEiAjYCACACDQAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLdABB6AhBBDYCAEHkCCAANgIAQewIQgE3AgBBxAhCADcCAEHACCADNgIAQdwIQgA3AgBBqAhCADcDAEGwCEIANwMAQbgIQgA3AwBBzAggAkGAICACQYAgSRs2AgBBoAggAa1CgYCAgBB+NwMAQdAIQQA2AgALIwBB0AgoAgBFBEAgACABQcgIKAIAQQJ0QYAIaigCABEBAAsLWgECfwJAAkACQEHICCgCAEEBaw4CAAECC0HYCEHoCCgCACIAQdgIKAIAIgEgACABShsiAEGAgAEgAEGAgAFIGyIANgIAIABBBGsPC0GoCCgCAEEEayEACyAAC0IBAX8Cf0EGQdwIKAIAIgBBIHENABpBBSAAQRBxDQAaQQQgAEEIcQ0AGkEDIABBBHENABpBAiAAQQFxIABBAnEbCwu9BQEFfQJ/IAJFBEAgAUH/AWxBMmpB5ABtIgBBCHQgAHIgAEEQdHIMAQsgArJDAADIQpUhBiAAQfABarJDAAC0Q5UhBQJ9IAGyQwAAyEKVIgNDAAAAP10EQCADIAZDAACAP5KUDAELIAYgA0MAAIA/IAaTlJILIQcgAyADkiEGAkAgBUOrqqo+kiIEQwAAAABdBEAgBEMAAIA/kiEEDAELIARDAACAP15FDQAgBEMAAIC/kiEECyAGIAeTIQMgBUMAAAAAXSEAAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQECQCAABEAgBUMAAIA/kiEEDAELIAUiBEMAAIA/XkUNACAFQwAAgL+SIQQLIAVDq6qqvpIiBUMAAAAAXSECAn8CfSADIAcgA5NDAADAQJQgBJSSIARDq6oqPl0NABogByAEQwAAAD9dDQAaIAMgBEOrqio/XUUNABogAyAHIAOTIARDAADAwJRDAACAQJKUkgtDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALIQACQCACBEAgBUMAAIA/kiEFDAELIAVDAACAP15FDQAgBUMAAIC/kiEFCwJAIAVDq6oqPl0EQCADIAcgA5NDAADAQJQgBZSSIQcMAQsgBUMAAAA/XQ0AIAVDq6oqP11FBEAgAyEHDAELIAMgByADkyAFQwAAwMCUQwAAgECSlJIhBwsgAEEIdAJ/IAdDAAB/Q5RDAAAAP5IiBkMAAIBPXSAGQwAAAABgcQRAIAapDAELQQALQRB0ciABcgtBgICAeHILNwAgAEH/AWxBMmpB5ABtIAFB/wFsQTJqQeQAbUEIdHIgAkH/AWxBMmpB5ABtQRB0ckGAgIB4cgsEACMACwYAIAAkAAsQACMAIABrQXBxIgAkACAACwsYAQBBgAgLEQEAAAACAAAAAwAAAAQAAAAF'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const finalWidth = width + sx > img.width ? img.width - sx : width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document,\n Math.ceil(spec.orig!.width * currentWidth / originalWidth),\n Math.ceil(spec.orig!.height * currentHeight / originalHeight)\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch {\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const ds = new DecompressionStream(format);\n const writer = ds.writable.getWriter();\n writer.write(compressed as BufferSource);\n writer.close();\n\n const chunks: Uint8Array[] = [];\n const reader = ds.readable.getReader();\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","createImageBitmap","resizeWidth","resizeHeight","bm","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","close","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","actualCellSize","originalWidth","originalHeight","origCellSize","orig","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","convertLe","_size","sixelPaletteLimit","hook","params","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelSizeLimit","unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","totalEncodedSize","decoderToRelease","_handleNoPayloadCommand","cmd","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","_f","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","sourceWidth","cropH","sourceHeight","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","_g","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","ds","DecompressionStream","writer","writable","getWriter","write","chunks","reader","readable","getReader","done","read","totalLength","reduce","sum","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} \ No newline at end of file -+{"version":3,"file":"addon-image.js","mappings":"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","sources":["webpack://ImageAddon/webpack/universalModuleDefinition","webpack://ImageAddon/../node_modules/sixel/lib/Colors.js","webpack://ImageAddon/../node_modules/sixel/lib/Decoder.js","webpack://ImageAddon/../node_modules/sixel/lib/wasm.js","webpack://ImageAddon/../src/common/Event.ts","webpack://ImageAddon/../src/common/Lifecycle.ts","webpack://ImageAddon/./src/IIPHandler.ts","webpack://ImageAddon/./src/IIPHeaderParser.ts","webpack://ImageAddon/./src/IIPImageStorage.ts","webpack://ImageAddon/./src/IIPMetrics.ts","webpack://ImageAddon/./src/ImageRenderer.ts","webpack://ImageAddon/./src/ImageStorage.ts","webpack://ImageAddon/./src/SixelHandler.ts","webpack://ImageAddon/./src/SixelImageStorage.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsHandler.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsTypes.ts","webpack://ImageAddon/./src/kitty/KittyImageStorage.ts","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/base64/Base64Decoder.wasm.js","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/qoi/QoiDecoder.wasm.js","webpack://ImageAddon/javascript/node_modules/inwasm-runtime/lib/index.cjs","webpack://ImageAddon/webpack/bootstrap","webpack://ImageAddon/./src/ImageAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ImageAddon\"] = factory();\n\telse\n\t\troot[\"ImageAddon\"] = factory();\n})(globalThis, () => {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: '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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_generation","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","generation","createImageBitmap","resizeWidth","resizeHeight","bm","close","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sourceWidth","sourceHeight","actualCellSize","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","originalWidth","originalHeight","origCellSize","orig","scaledWidth","scaledHeight","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","placeholder","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","convertLe","_size","sixelPaletteLimit","hook","params","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelSizeLimit","unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","totalEncodedSize","decoderToRelease","_f","decoderCapacity","_g","maxPending","oldest","next","cmd","_h","_handleNoPayloadCommand","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","cropH","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","offsetIn","reader","ReadableStream","pull","controller","enqueue","pipeThrough","DecompressionStream","getReader","chunks","totalLength","done","read","cancel","releaseLock","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","byteLimit","retainedBytes","evictPlaced","oldestId","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} ++{"version":3,"file":"addon-image.js","mappings":"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","sources":["webpack://ImageAddon/webpack/universalModuleDefinition","webpack://ImageAddon/../node_modules/sixel/lib/Colors.js","webpack://ImageAddon/../node_modules/sixel/lib/Decoder.js","webpack://ImageAddon/../node_modules/sixel/lib/wasm.js","webpack://ImageAddon/../src/common/Event.ts","webpack://ImageAddon/../src/common/Lifecycle.ts","webpack://ImageAddon/./src/IIPHandler.ts","webpack://ImageAddon/./src/IIPHeaderParser.ts","webpack://ImageAddon/./src/IIPImageStorage.ts","webpack://ImageAddon/./src/IIPMetrics.ts","webpack://ImageAddon/./src/ImageRenderer.ts","webpack://ImageAddon/./src/ImageStorage.ts","webpack://ImageAddon/./src/SixelHandler.ts","webpack://ImageAddon/./src/SixelImageStorage.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsHandler.ts","webpack://ImageAddon/./src/kitty/KittyGraphicsTypes.ts","webpack://ImageAddon/./src/kitty/KittyImageStorage.ts","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/base64/Base64Decoder.wasm.js","webpack://ImageAddon/../node_modules/xterm-wasm-parts/lib/qoi/QoiDecoder.wasm.js","webpack://ImageAddon/javascript/node_modules/inwasm-runtime/lib/index.cjs","webpack://ImageAddon/webpack/bootstrap","webpack://ImageAddon/./src/ImageAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ImageAddon\"] = factory();\n\telse\n\t\troot[\"ImageAddon\"] = factory();\n})(globalThis, () => {\nreturn ","\"use strict\";\n/**\n * Copyright (c) 2019 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.DEFAULT_FOREGROUND = exports.DEFAULT_BACKGROUND = exports.PALETTE_ANSI_256 = exports.PALETTE_VT340_GREY = exports.PALETTE_VT340_COLOR = exports.normalizeHLS = exports.normalizeRGB = exports.nearestColorIndex = exports.fromRGBA8888 = exports.toRGBA8888 = exports.alpha = exports.blue = exports.green = exports.red = exports.BIG_ENDIAN = void 0;\n// FIXME: cleanup this mess, move things either to decoder/encoder, keep only shared things\n// system endianess\nexports.BIG_ENDIAN = new Uint8Array(new Uint32Array([0xFF000000]).buffer)[0] === 0xFF;\nif (exports.BIG_ENDIAN) {\n console.warn('BE platform detected. This version of node-sixel works only on LE properly.');\n}\n// channel values\nfunction red(n) {\n return n & 0xFF;\n}\nexports.red = red;\nfunction green(n) {\n return (n >>> 8) & 0xFF;\n}\nexports.green = green;\nfunction blue(n) {\n return (n >>> 16) & 0xFF;\n}\nexports.blue = blue;\nfunction alpha(n) {\n return (n >>> 24) & 0xFF;\n}\nexports.alpha = alpha;\n/**\n * Convert RGB channels to native color RGBA8888.\n */\nfunction toRGBA8888(r, g, b, a = 255) {\n return ((a & 0xFF) << 24 | (b & 0xFF) << 16 | (g & 0xFF) << 8 | (r & 0xFF)) >>> 0; // ABGR32\n}\nexports.toRGBA8888 = toRGBA8888;\n/**\n * Convert native color to [r, g, b, a].\n */\nfunction fromRGBA8888(color) {\n return [color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF, color >>> 24];\n}\nexports.fromRGBA8888 = fromRGBA8888;\n/**\n * Get index of nearest color in `palette` for `color`.\n * Uses euclidean distance without any luminescence correction.\n */\nfunction nearestColorIndex(color, palette) {\n const r = red(color);\n const g = green(color);\n const b = blue(color);\n let min = Number.MAX_SAFE_INTEGER;\n let idx = -1;\n // use euclidean distance (manhattan gives very poor results)\n for (let i = 0; i < palette.length; ++i) {\n const dr = r - palette[i][0];\n const dg = g - palette[i][1];\n const db = b - palette[i][2];\n const d = dr * dr + dg * dg + db * db;\n if (!d)\n return i;\n if (d < min) {\n min = d;\n idx = i;\n }\n }\n return idx;\n}\nexports.nearestColorIndex = nearestColorIndex;\n// color conversions\n// HLS taken from: http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl\nfunction clamp(low, high, value) {\n return Math.max(low, Math.min(value, high));\n}\nfunction h2c(t1, t2, c) {\n if (c < 0)\n c += 1;\n if (c > 1)\n c -= 1;\n return c * 6 < 1\n ? t2 + (t1 - t2) * 6 * c\n : c * 2 < 1\n ? t1\n : c * 3 < 2\n ? t2 + (t1 - t2) * (4 - c * 6)\n : t2;\n}\nfunction HLStoRGB(h, l, s) {\n if (!s) {\n const v = Math.round(l * 255);\n return toRGBA8888(v, v, v);\n }\n const t1 = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const t2 = 2 * l - t1;\n return toRGBA8888(clamp(0, 255, Math.round(h2c(t1, t2, h + 1 / 3) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h) * 255)), clamp(0, 255, Math.round(h2c(t1, t2, h - 1 / 3) * 255)));\n}\n/**\n * Normalize SIXEL RGB values (percent based, 0-100) to RGBA8888.\n */\nfunction normalizeRGB(r, g, b) {\n return (0xFF000000 | Math.round(b / 100 * 255) << 16 | Math.round(g / 100 * 255) << 8 | Math.round(r / 100 * 255)) >>> 0; // ABGR32\n}\nexports.normalizeRGB = normalizeRGB;\n/**\n * Normalize SIXEL HLS values to RGBA8888. Applies hue correction of +240°.\n */\nfunction normalizeHLS(h, l, s) {\n // Note: hue value is turned by 240° in VT340, all values given as fractions\n return HLStoRGB((h + 240 % 360) / 360, l / 100, s / 100);\n}\nexports.normalizeHLS = normalizeHLS;\n/**\n * default palettes\n */\n// FIXME: move palettes to Decoder.ts\n/**\n * 16 predefined color registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Blue 20 20 80\n * 2 Red 80 13 13\n * 3 Green 20 80 20\n * 4 Magenta 80 20 80\n * 5 Cyan 20 80 80\n * 6 Yellow 80 80 20\n * 7 Gray 50% 53 53 53\n * 8 Gray 25% 26 26 26\n * 9 Blue* 33 33 60\n * 10 Red* 60 26 26\n * 11 Green* 33 60 33\n * 12 Magenta* 60 33 60\n * 13 Cyan* 33 60 60\n * 14 Yellow* 60 60 33\n * 15 Gray 75% 80 80 80\n * ```\n * (*) less saturated\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n*/\nexports.PALETTE_VT340_COLOR = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(20, 20, 80),\n normalizeRGB(80, 13, 13),\n normalizeRGB(20, 80, 20),\n normalizeRGB(80, 20, 80),\n normalizeRGB(20, 80, 80),\n normalizeRGB(80, 80, 20),\n normalizeRGB(53, 53, 53),\n normalizeRGB(26, 26, 26),\n normalizeRGB(33, 33, 60),\n normalizeRGB(60, 26, 26),\n normalizeRGB(33, 60, 33),\n normalizeRGB(60, 33, 60),\n normalizeRGB(33, 60, 60),\n normalizeRGB(60, 60, 33),\n normalizeRGB(80, 80, 80)\n]);\n/**\n * 16 predefined monochrome registers of VT340 (values in %):\n * ```\n * R G B\n * 0 Black 0 0 0\n * 1 Gray-2 13 13 13\n * 2 Gray-4 26 26 26\n * 3 Gray-6 40 40 40\n * 4 Gray-1 6 6 6\n * 5 Gray-3 20 20 20\n * 6 Gray-5 33 33 33\n * 7 White 7 46 46 46\n * 8 Black 0 0 0 0\n * 9 Gray-2 13 13 13\n * 10 Gray-4 26 26 26\n * 11 Gray-6 40 40 40\n * 12 Gray-1 6 6 6\n * 13 Gray-3 20 20 20\n * 14 Gray-5 33 33 33\n * 15 White 7 46 46 46\n * ```\n *\n * @see https://vt100.net/docs/vt3xx-gp/chapter2.html#S2.4\n */\nexports.PALETTE_VT340_GREY = new Uint32Array([\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46),\n normalizeRGB(0, 0, 0),\n normalizeRGB(13, 13, 13),\n normalizeRGB(26, 26, 26),\n normalizeRGB(40, 40, 40),\n normalizeRGB(6, 6, 6),\n normalizeRGB(20, 20, 20),\n normalizeRGB(33, 33, 33),\n normalizeRGB(46, 46, 46)\n]);\n/**\n * 256 predefined ANSI colors.\n *\n * @see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit\n */\nexports.PALETTE_ANSI_256 = (() => {\n // 16 lower colors (taken from xterm)\n const p = [\n toRGBA8888(0, 0, 0),\n toRGBA8888(205, 0, 0),\n toRGBA8888(0, 205, 0),\n toRGBA8888(205, 205, 0),\n toRGBA8888(0, 0, 238),\n toRGBA8888(205, 0, 205),\n toRGBA8888(0, 250, 205),\n toRGBA8888(229, 229, 229),\n toRGBA8888(127, 127, 127),\n toRGBA8888(255, 0, 0),\n toRGBA8888(0, 255, 0),\n toRGBA8888(255, 255, 0),\n toRGBA8888(92, 92, 255),\n toRGBA8888(255, 0, 255),\n toRGBA8888(0, 255, 255),\n toRGBA8888(255, 255, 255),\n ];\n // colors up to 232\n const d = [0, 95, 135, 175, 215, 255];\n for (let r = 0; r < 6; ++r) {\n for (let g = 0; g < 6; ++g) {\n for (let b = 0; b < 6; ++b) {\n p.push(toRGBA8888(d[r], d[g], d[b]));\n }\n }\n }\n // grey scale to up 255\n for (let v = 8; v <= 238; v += 10) {\n p.push(toRGBA8888(v, v, v));\n }\n return new Uint32Array(p);\n})();\n/**\n * Background: Black by default.\n * Foreground: White by default.\n *\n * Background color is used whenever a fill color is needed and not explicitly set.\n * Foreground color is used as default initial sixel color.\n */\nexports.DEFAULT_BACKGROUND = toRGBA8888(0, 0, 0, 255);\nexports.DEFAULT_FOREGROUND = toRGBA8888(255, 255, 255, 255);\n//# sourceMappingURL=Colors.js.map","\"use strict\";\n/**\n * Copyright (c) 2021 Joerg Breitbart.\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.decodeAsync = exports.decode = exports.Decoder = exports.DecoderAsync = void 0;\nconst Colors_1 = require(\"./Colors\");\nconst wasm_1 = require(\"./wasm\");\n/* istanbul ignore next */\nfunction decodeBase64(s) {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(s, 'base64');\n }\n const bytestring = atob(s);\n const result = new Uint8Array(bytestring.length);\n for (let i = 0; i < result.length; ++i) {\n result[i] = bytestring.charCodeAt(i);\n }\n return result;\n}\nconst WASM_BYTES = decodeBase64(wasm_1.LIMITS.BYTES);\nlet WASM_MODULE;\n// empty canvas\nconst NULL_CANVAS = new Uint32Array();\n// proxy for lazy binding of decoder methods to wasm env callbacks\nclass CallbackProxy {\n constructor() {\n this.bandHandler = (width) => 1;\n this.modeHandler = (mode) => 1;\n }\n handle_band(width) {\n return this.bandHandler(width);\n }\n mode_parsed(mode) {\n return this.modeHandler(mode);\n }\n}\n// default decoder options\nconst DEFAULT_OPTIONS = {\n memoryLimit: 2048 * 65536,\n sixelColor: Colors_1.DEFAULT_FOREGROUND,\n fillColor: Colors_1.DEFAULT_BACKGROUND,\n palette: Colors_1.PALETTE_VT340_COLOR,\n paletteLimit: wasm_1.LIMITS.PALETTE_SIZE,\n truncate: true\n};\n/**\n * Create a decoder instance asynchronously.\n * To be used in the browser main thread.\n */\nfunction DecoderAsync(opts) {\n const cbProxy = new CallbackProxy();\n const importObj = {\n env: {\n handle_band: cbProxy.handle_band.bind(cbProxy),\n mode_parsed: cbProxy.mode_parsed.bind(cbProxy)\n }\n };\n return WebAssembly.instantiate(WASM_MODULE || WASM_BYTES, importObj)\n .then((inst) => {\n WASM_MODULE = WASM_MODULE || inst.module;\n return new Decoder(opts, inst.instance || inst, cbProxy);\n });\n}\nexports.DecoderAsync = DecoderAsync;\n/**\n * Decoder - web assembly based sixel stream decoder.\n *\n * Usage pattern:\n * - call `init` to initialize decoder for new image\n * - feed data chunks to `decode` or `decodeString`\n * - grab pixels from `data32`\n * - optional: call `release` to free memory (e.g. after big images)\n * - start over with next image by calling `init`\n *\n * Properties:\n * - max width of 2^14 - 4 pixels (compile time setting in wasm)\n * - no explicit height limit (only limited by memory)\n * - max 4096 colors palette (compile time setting in wasm)\n *\n * Explanation operation modes:\n * - M1 Mode chosen for level 1 images (no raster attributes),\n * or for level 2 images with `truncate=false`.\n * - M2 Mode chosen for level 2 images with `truncate=true` (default).\n * While this mode is not fully spec conform (decoder not expected to truncate),\n * it is what spec conform encoders should create (should not excess raster).\n * This mode has several advantages:\n * - ~15% faster decoding speed\n * - image dimensions can be evaluated early without processing the whole data\n * - faster pixel access in `data32` (precalulated)\n * - image height is not reported as multiple of 6 pixels\n * - M0 Undecided mode state after `init`.\n * The level of an image is determined during early decoding based on the fact,\n * whether the data contains valid raster attributes before any sixel data.\n * Until then the mode of an image is marked as M0, meaning the real operation mode\n * could not be decided yet.\n */\nclass Decoder {\n /**\n * Synchonous ctor. Can be called from nodejs or a webworker context.\n * For instantiation in the browser main thread use `WasmDecoderAsync` instead.\n */\n constructor(opts, _instance, _cbProxy) {\n this._PIXEL_OFFSET = wasm_1.LIMITS.MAX_WIDTH + 4;\n this._canvas = NULL_CANVAS;\n this._bandWidths = [];\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n if (this._opts.paletteLimit > wasm_1.LIMITS.PALETTE_SIZE) {\n throw new Error(`DecoderOptions.paletteLimit must not exceed ${wasm_1.LIMITS.PALETTE_SIZE}`);\n }\n if (!_instance) {\n const module = WASM_MODULE || (WASM_MODULE = new WebAssembly.Module(WASM_BYTES));\n _instance = new WebAssembly.Instance(module, {\n env: {\n handle_band: this._handle_band.bind(this),\n mode_parsed: this._initCanvas.bind(this)\n }\n });\n }\n else {\n _cbProxy.bandHandler = this._handle_band.bind(this);\n _cbProxy.modeHandler = this._initCanvas.bind(this);\n }\n this._instance = _instance;\n this._wasm = this._instance.exports;\n this._chunk = new Uint8Array(this._wasm.memory.buffer, this._wasm.get_chunk_address(), wasm_1.LIMITS.CHUNK_SIZE);\n this._states = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_state_address(), 12);\n this._palette = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_palette_address(), wasm_1.LIMITS.PALETTE_SIZE);\n this._palette.set(this._opts.palette);\n this._pSrc = new Uint32Array(this._wasm.memory.buffer, this._wasm.get_p0_address());\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n // some readonly parser states for internal usage\n get _fillColor() { return this._states[0]; }\n get _truncate() { return this._states[8]; }\n get _rasterWidth() { return this._states[6]; }\n get _rasterHeight() { return this._states[7]; }\n get _width() { return this._states[2] ? this._states[2] - 4 : 0; }\n get _height() { return this._states[3]; }\n get _level() { return this._states[9]; }\n get _mode() { return this._states[10]; }\n get _paletteLimit() { return this._states[11]; }\n _initCanvas(mode) {\n if (mode === 2 /* M2 */) {\n const pixels = this.width * this.height;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n this._maxWidth = this._width;\n }\n else if (mode === 1 /* M1 */) {\n if (this._level === 2) {\n // got raster attributes, use them as initial size hint\n const pixels = Math.min(this._rasterWidth, wasm_1.LIMITS.MAX_WIDTH) * this._rasterHeight;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n this._canvas = new Uint32Array(pixels);\n }\n }\n else {\n // else fallback to generic resizing, starting with 256*256 pixels\n if (this._canvas.length < 65536) {\n this._canvas = new Uint32Array(65536);\n }\n }\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n _realloc(offset, additionalPixels) {\n const pixels = offset + additionalPixels;\n if (pixels > this._canvas.length) {\n if (this._opts.memoryLimit && pixels * 4 > this._opts.memoryLimit) {\n this.release();\n throw new Error('image exceeds memory limit');\n }\n // extend in 65536 pixel blocks\n const newCanvas = new Uint32Array(Math.ceil(pixels / 65536) * 65536);\n newCanvas.set(this._canvas);\n this._canvas = newCanvas;\n }\n }\n _handle_band(width) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + width), offset + width * c);\n c++;\n remaining--;\n }\n this._lastOffset += width * c;\n this._currentHeight += c;\n }\n else if (this._mode === 1 /* M1 */) {\n this._realloc(offset, width * 6);\n this._maxWidth = Math.max(this._maxWidth, width);\n this._minWidth = Math.min(this._minWidth, width);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + width), offset + width * i);\n }\n this._bandWidths.push(width);\n this._lastOffset += width * 6;\n this._currentHeight += 6;\n }\n return 0; // 0 - continue, 1 - abort right away\n }\n /**\n * Width of the image data.\n * Returns the rasterWidth in level2/truncating mode,\n * otherwise the max width, that has been seen so far.\n */\n get width() {\n return this._mode !== 1 /* M1 */\n ? this._width\n : Math.max(this._maxWidth, this._wasm.current_width());\n }\n /**\n * Height of the image data.\n * Returns the rasterHeight in level2/truncating mode,\n * otherwise height touched by sixels.\n */\n get height() {\n return this._mode !== 1 /* M1 */\n ? this._height\n : this._wasm.current_width()\n ? this._bandWidths.length * 6 + this._wasm.current_height()\n : this._bandWidths.length * 6;\n }\n /**\n * Get active palette colors as RGBA8888[] (borrowed).\n */\n get palette() {\n return this._palette.subarray(0, this._paletteLimit);\n }\n /**\n * Get the memory used by the decoder.\n *\n * This is a rough estimate accounting the wasm instance memory\n * and pixel buffers held on JS side (real value will be slightly\n * higher due to JS book-keeping).\n * Note that the decoder does not free ressources on its own,\n * call `release` to free excess memory.\n */\n get memoryUsage() {\n return this._canvas.byteLength + this._wasm.memory.buffer.byteLength + 8 * this._bandWidths.length;\n }\n /**\n * Get various properties of the decoder and the current image.\n */\n get properties() {\n return {\n width: this.width,\n height: this.height,\n mode: this._mode,\n level: this._level,\n truncate: !!this._truncate,\n paletteLimit: this._paletteLimit,\n fillColor: this._fillColor,\n memUsage: this.memoryUsage,\n rasterAttributes: {\n numerator: this._states[4],\n denominator: this._states[5],\n width: this._rasterWidth,\n height: this._rasterHeight,\n }\n };\n }\n /**\n * Initialize decoder for next image. Must be called before\n * any calls to `decode` or `decodeString`.\n */\n // FIXME: reorder arguments, better palette handling\n init(fillColor = this._opts.fillColor, palette = this._opts.palette, paletteLimit = this._opts.paletteLimit, truncate = this._opts.truncate) {\n this._wasm.init(this._opts.sixelColor, fillColor, paletteLimit, truncate ? 1 : 0);\n if (palette) {\n this._palette.set(palette.subarray(0, wasm_1.LIMITS.PALETTE_SIZE));\n }\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n this._lastOffset = 0;\n this._currentHeight = 0;\n }\n /**\n * Decode next chunk of data from start to end index (exclusive).\n * @throws Will throw if the image exceeds the memory limit.\n */\n decode(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n this._chunk.set(data.subarray(p, p += length));\n this._wasm.decode(0, length);\n }\n }\n /**\n * Decode next chunk of string data from start to end index (exclusive).\n * Note: Decoding from string data is rather slow, use `decode` with byte data instead.\n * @throws Will throw if the image exceeds the memory limit.\n */\n decodeString(data, start = 0, end = data.length) {\n let p = start;\n while (p < end) {\n const length = Math.min(end - p, wasm_1.LIMITS.CHUNK_SIZE);\n for (let i = 0, j = p; i < length; ++i, ++j) {\n this._chunk[i] = data.charCodeAt(j);\n }\n p += length;\n this._wasm.decode(0, length);\n }\n }\n /**\n * Get current pixel data as 32-bit typed array (RGBA8888).\n * Also peeks into pixel data of the current band, that got not pushed yet.\n */\n get data32() {\n if (this._mode === 0 /* M0 */ || !this.width || !this.height) {\n return NULL_CANVAS;\n }\n // get width of pending band to peek into left-over data\n const currentWidth = this._wasm.current_width();\n if (this._mode === 2 /* M2 */) {\n let remaining = this.height - this._currentHeight;\n if (remaining > 0) {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n let c = 0;\n while (c < 6 && remaining > 0) {\n this._canvas.set(this._pSrc.subarray(adv * c, adv * c + currentWidth), offset + currentWidth * c);\n c++;\n remaining--;\n }\n if (remaining) {\n this._canvas.fill(this._fillColor, offset + currentWidth * c);\n }\n }\n return this._canvas.subarray(0, this.width * this.height);\n }\n if (this._mode === 1 /* M1 */) {\n if (this._minWidth === this._maxWidth) {\n let escape = false;\n if (currentWidth) {\n if (currentWidth !== this._minWidth) {\n escape = true;\n }\n else {\n const adv = this._PIXEL_OFFSET;\n let offset = this._lastOffset;\n this._realloc(offset, currentWidth * 6);\n for (let i = 0; i < 6; ++i) {\n this._canvas.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), offset + currentWidth * i);\n }\n }\n }\n if (!escape) {\n return this._canvas.subarray(0, this.width * this.height);\n }\n }\n // worst case: re-align pixels if we have bands with different width\n // This is somewhat allocation intensive, any way to do that in-place, and just once?\n const final = new Uint32Array(this.width * this.height);\n final.fill(this._fillColor);\n let finalOffset = 0;\n let start = 0;\n for (let i = 0; i < this._bandWidths.length; ++i) {\n const bw = this._bandWidths[i];\n for (let p = 0; p < 6; ++p) {\n final.set(this._canvas.subarray(start, start += bw), finalOffset);\n finalOffset += this.width;\n }\n }\n // also handle left-over pixels of the current band\n if (currentWidth) {\n const adv = this._PIXEL_OFFSET;\n // other than finished bands, this runs only up to currentHeight\n const currentHeight = this._wasm.current_height();\n for (let i = 0; i < currentHeight; ++i) {\n final.set(this._pSrc.subarray(adv * i, adv * i + currentWidth), finalOffset + this.width * i);\n }\n }\n return final;\n }\n // fallthrough for all not handled cases\n return NULL_CANVAS;\n }\n /**\n * Same as `data32`, but returning pixel data as Uint8ClampedArray suitable\n * for direct usage with `ImageData`.\n */\n get data8() {\n return new Uint8ClampedArray(this.data32.buffer, 0, this.width * this.height * 4);\n }\n /**\n * Release image ressources on JS side held by the decoder.\n *\n * The decoder tries to re-use memory ressources of a previous image\n * to lower allocation and GC pressure. Decoding a single big image\n * will grow the memory usage of the decoder permanently.\n * Call `release` to reset the internal buffers and free the memory.\n * Note that this destroys the image data, call it when done processing\n * a rather big image, otherwise it is not needed. Use `memoryUsage`\n * to decide, whether the held memory is still within your limits.\n * This does not affect the wasm module (operates on static memory).\n */\n release() {\n this._canvas = NULL_CANVAS;\n this._bandWidths.length = 0;\n this._maxWidth = 0;\n this._minWidth = wasm_1.LIMITS.MAX_WIDTH;\n // also nullify parser states in wasm to avoid\n // width/height reporting potential out-of-bound values\n this._wasm.init(Colors_1.DEFAULT_FOREGROUND, 0, this._opts.paletteLimit, 0);\n }\n}\nexports.Decoder = Decoder;\n/**\n * Convenient decoding functions for easier usage.\n *\n * These can be used for casual decoding of sixel images,\n * that dont come in as stream chunks.\n * Note that the functions instantiate a stream decoder for every call,\n * which comes with a performance penalty of ~25%.\n */\n/**\n * Decode function with synchronous wasm loading.\n * Can be used in a web worker or in nodejs. Does not work reliable in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nfunction decode(data, opts) {\n const dec = new Decoder(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decode = decode;\n/**\n * Decode function with asynchronous wasm loading.\n * Use this version in normal browser context.\n * @throws Will throw if the image exceeds the memory limit.\n */\nasync function decodeAsync(data, opts) {\n const dec = await DecoderAsync(opts);\n dec.init();\n typeof data === 'string' ? dec.decodeString(data) : dec.decode(data);\n return {\n width: dec.width,\n height: dec.height,\n data32: dec.data32,\n data8: dec.data8\n };\n}\nexports.decodeAsync = decodeAsync;\n//# sourceMappingURL=Decoder.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.LIMITS = void 0;\nexports.LIMITS = {\n CHUNK_SIZE: 16384,\n PALETTE_SIZE: 4096,\n MAX_WIDTH: 16384,\n BYTES: 'AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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'\n};\n//# sourceMappingURL=wasm.js.map","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Held memory in base64/QOI decoders between images. Zero because each kept\n // decoder pins a wasm memory, and V8 caps those per process (~124 in a\n // sandboxed renderer), so idle terminals must not hold one.\n KEEP_DATA = 0,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n if (!this._initDecoder()) {\n this._aborted = true;\n return;\n }\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n if (!this._initDecoder()) {\n this._abortMulti = true;\n }\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n let data: Uint8Array;\n try {\n data = this._qoiDec.decode(this._dec.data8);\n } catch (e) {\n console.warn('IIP: could not decode QOI image', e);\n this._dec.release();\n this._qoiDec.release();\n return true;\n }\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n private _initDecoder(): boolean {\n try {\n this._dec.init();\n return true;\n } catch (e) {\n console.warn('IIP: could not allocate decoder', e);\n this._dec.release();\n return false;\n }\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode — cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n","/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat — points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n","/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\nimport { LIMITS } from 'sixel/lib/wasm';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories\n// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only\n// while a SIXEL sequence is open, so idle terminals hold none.\nconst MAX_IDLE_DECODERS = 2;\nconst idleDecoders = new Map();\nlet poolPrimed = false;\n\nfunction primeDecoderPool(memoryLimit: number): void {\n if (poolPrimed) return;\n poolPrimed = true;\n // Async compile once, off the parser's hot path; later decoders reuse the cached module.\n DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(\n d => releaseDecoder(d, memoryLimit),\n () => { poolPrimed = false; }\n );\n}\n\nfunction acquireDecoder(memoryLimit: number): Decoder {\n return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });\n}\n\nfunction releaseDecoder(dec: Decoder, memoryLimit: number): void {\n if (dec.memoryUsage > MEM_PERMA_LIMIT) {\n dec.release();\n }\n const idle = idleDecoders.get(memoryLimit) ?? [];\n if (idle.length < MAX_IDLE_DECODERS) {\n idle.push(dec);\n idleDecoders.set(memoryLimit, idle);\n }\n}\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n private _decMemoryLimit = 0;\n // Color registers outlive a single image, so they live here rather than in a pooled decoder.\n private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n this._palette.set(DEFAULT_PALETTE);\n primeDecoderPool(this._opts.pixelLimit * 4);\n }\n\n public reset(): void {\n this._returnDecoder();\n this._palette.fill(0);\n this._palette.set(DEFAULT_PALETTE);\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n this._returnDecoder();\n const memoryLimit = this._opts.pixelLimit * 4;\n try {\n this._dec = acquireDecoder(memoryLimit);\n } catch (e) {\n // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.\n console.warn(`SIXEL: could not allocate decoder - ${e}`);\n this._aborted = true;\n return;\n }\n this._decMemoryLimit = memoryLimit;\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);\n }\n\n private _returnDecoder(): void {\n const dec = this._dec;\n if (!dec) return;\n this._dec = undefined;\n this._palette.set(dec.palette);\n releaseDecoder(dec, this._decMemoryLimit);\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n try {\n return this._unhook(success);\n } finally {\n this._returnDecoder();\n }\n }\n\n private _unhook(success: boolean): boolean {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n try {\n decoder.init();\n } catch (e) {\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n console.warn('KITTY: could not allocate decoder', e);\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n this._activeDecoder = decoder;\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received — clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first — handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload — image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency —\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw × r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly — no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1–3 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair — same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId → placementId → storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map","\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","red","n","green","blue","toRGBA8888","r","g","b","a","clamp","low","high","value","Math","max","min","h2c","t1","t2","c","normalizeRGB","round","Object","defineProperty","DEFAULT_FOREGROUND","DEFAULT_BACKGROUND","PALETTE_ANSI_256","PALETTE_VT340_GREY","PALETTE_VT340_COLOR","normalizeHLS","nearestColorIndex","fromRGBA8888","alpha","BIG_ENDIAN","Uint8Array","Uint32Array","buffer","console","warn","color","palette","Number","MAX_SAFE_INTEGER","idx","i","length","dr","dg","db","d","h","l","s","v","HLStoRGB","p","push","decodeAsync","decode","Decoder","DecoderAsync","Colors_1","__webpack_require__","wasm_1","WASM_BYTES","Buffer","from","bytestring","atob","result","charCodeAt","decodeBase64","LIMITS","BYTES","WASM_MODULE","NULL_CANVAS","CallbackProxy","constructor","this","bandHandler","width","modeHandler","mode","handle_band","mode_parsed","DEFAULT_OPTIONS","memoryLimit","sixelColor","fillColor","paletteLimit","PALETTE_SIZE","truncate","opts","cbProxy","importObj","env","bind","WebAssembly","instantiate","then","inst","instance","_instance","_cbProxy","_PIXEL_OFFSET","MAX_WIDTH","_canvas","_bandWidths","_maxWidth","_minWidth","_lastOffset","_currentHeight","_opts","assign","Error","_handle_band","_initCanvas","Module","Instance","_wasm","_chunk","memory","get_chunk_address","CHUNK_SIZE","_states","get_state_address","_palette","get_palette_address","set","_pSrc","get_p0_address","init","_fillColor","_truncate","_rasterWidth","_rasterHeight","_width","_height","_level","_mode","_paletteLimit","pixels","height","release","_realloc","offset","additionalPixels","newCanvas","ceil","adv","remaining","subarray","current_width","current_height","memoryUsage","byteLength","properties","level","memUsage","rasterAttributes","numerator","denominator","data","start","end","decodeString","j","data32","currentWidth","fill","escape","final","finalOffset","bw","currentHeight","data8","Uint8ClampedArray","dec","async","Lifecycle_1","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","toDisposable","entry","fn","slice","indexOf","splice","Array","isArray","add","fire","call","listeners","len","dispose","forward","to","e","map","undefined","any","events","store","DisposableStore","runAndSubscribe","handler","initial","arg","_disposables","Set","_isDisposed","isDisposed","o","clear","Disposable","_store","_register","None","freeze","_value","ImageRenderer_1","ImageStorage_1","Base64Decoder_wasm_1","__importDefault","QoiDecoder_wasm_1","IIPHeaderParser_1","IIPMetrics_1","DEFAULT_HEADER","type","name","size","preserveAspectRatio","inline","_renderer","_storage","_coreTerminal","_generation","_aborted","_hp","HeaderParser","_header","_isMultipart","_abortMulti","maxEncodedBytes","iipSizeLimit","initialBytes","_dec","default","_qoiDec","reset","put","state","dataPos","parse","fields","_initDecoder","success","seqType","w","CELL_SIZE_DEFAULT","dimensions","css","canvas","cols","rows","scale","_c","_a","_core","_coreBrowserService","dpr","report","toFixed","input","cond","blob","metrics","UNSUPPORTED_TYPE","imageType","mime","pixelLimit","_resize","floor","ImageData","byteOffset","ImageRenderer","createCanvas","_b","getContext","putImageData","addImage","Blob","generation","createImageBitmap","resizeWidth","resizeHeight","bm","close","catch","cw","cell","ch","_d","rw","_dim","rh","wf","hf","f","total","cdim","endsWith","parseInt","toStr","String","fromCharCode","toInt","toSize","match","DECODERS","toString","bs","TextDecoder","FILE_MARKER","MULTIPARTFILE_MARKER","FILEPART_MARKER","FILEEND_MARKER","REPORTCELLSIZE_MARKER","MAX_FIELDCHARS","_buffer","_position","_key","k","_storeValue","pos","_storeKey","_addImageOpts","scrolling","layer","zIndex","cursorPos","img","d32","blockLength","jpgSize","dim","limit","_layers","get","localDocument","document","createElement","createImageData","ctx","imgData","Promise","resolve","_terminal","super","Map","_optionsRefresh","MutableDisposable","_oldOpen","open","parent","_open","screenElement","optionsService","onOptionChange","option","rescaleCanvas","_renderService","refreshRows","removeLayerFromDom","_oldSetRenderer","setRenderer","_placeholderBitmap","_placeholder","showPlaceholder","cellSize","_createPlaceHolder","clearLines","y","clearRect","_e","clearAll","draw","imgSpec","tileId","col","row","count","_rescaleImage","actual","sourceWidth","sourceHeight","actualCellSize","sx","sy","dx","dy","finalWidth","finalHeight","drawImage","extractTile","drawPlaceholder","values","spec","originalWidth","originalHeight","origCellSize","orig","scaledWidth","scaledHeight","renderer","key","keys","insertLayerToDom","has","classList","style","isolation","insertBefore","firstChild","appendChild","remove","delete","hasLayer","bWidth","blueprint","black","white","shift","x","screen","ctx2","placeholder","bitmap","window","ExtendedAttrsImage","ext","_urlId","_ext","underlineStyle","underlineColor","underlineVariantOffset","val","urlId","imageId","clone","isEmpty","EMPTY_ATTRS","_images","_lastId","_lowestId","_fullyCleared","_needsFullClear","_pixelLimit","setLimit","storageLimit","error","message","getLimit","_viewportMetrics","marker","RangeError","_evictOldest","getUsage","_getStoredPixels","storedPixels","_delImg","id","ImageBitmap","onImageDeleted","wipeAlternate","zero","entries","bufferType","deleteImage","termCols","termRows","originX","originY","tileCount","_inputHandler","_dirtyRowTracker","markDirty","line","lines","ybase","_writeToCell","lineFeed","endMarker","registerMarker","onDispose","active","_evictOnAlternate","onImageAdded","render","range","hasTopImages","hasBottomImages","drawCalls","placeholderCalls","ydisp","getBg","_extendedAttrs","startTile","startCol","sort","viewportResize","oldCol","tilesPerRow","hasData","rightCol","_data","lastTile","expandCol","getImageAtBufferCell","extractTileAtBufferCell","room","used","current","old","oldSpec","imgId","Decoder_1","DEFAULT_PALETTE","idleDecoders","poolPrimed","releaseDecoder","idle","convertLe","_size","_decMemoryLimit","_returnDecoder","hook","params","pop","acquireDecoder","attr","colors","bg","isInverse","isFgDefault","foreground","rgba","isFgRGB","t","toColorRGB","getFgColor","ansi","isBgDefault","background","isBgRGB","getBgColor","extractActiveBg","_curAttrData","_themeService","sixelPaletteLimit","sixelSizeLimit","unhook","_unhook","advanceCursor","sixelScrolling","KittyGraphicsTypes_1","_kittyStorage","_decodeError","_activeDecoder","_inControlData","_controlData","_controlLength","_encodedSizeLimit","_totalEncodedSize","_parsedCommand","_pendingTransmissions","_maxEncodedBytes","kittySizeLimit","_initialEncodedBytes","_cleanupAllPending","_removePendingEntry","_lastPendingKey","pending","decoder","controlEnd","copyLength","parseKittyCommand","_parseControlDataString","imageNumber","_sendResponse","quiet","action","payloadStart","_streamPayload","pendingKey","previousEncodedSize","_f","totalEncodedSize","decoderToRelease","_g","decoderCapacity","_h","maxPending","oldest","next","cmd","_j","_k","_handleNoPayloadCommand","_handleDelete","isMoreComing","more","decodeError","finalCmd","imageBytes","_handleCommandWithBytesAndCmd","str","fromCodePoint","_handlePlacement","bytes","_handleTransmit","transmission","_handleTransmitDisplay","_handleQuery","image","getImage","_displayImage","placementId","storeImage","format","compression","lastImageId","expectedBytes","deleteSelector","deleteAll","deleteById","isOk","response","coreService","triggerDataEvent","_decodeAndDisplay","_createBitmap","cropX","cropY","cropW","cropH","maxCropW","maxCropH","finalCropW","finalCropH","cropped","imgCols","imgRows","columns","savedX","savedY","savedYbase","scaled","xOffset","yOffset","canvasW","canvasH","offsetCanvas","offsetCtx","offsetBitmap","cursorMovement","scrolled","arrayBuffer","_decompressZlib","url","URL","createObjectURL","Image","reject","addEventListener","revokeObjectURL","src","pixelCount","src32","dst32","alignedPixels","srcOffset","dstOffset","b0","b1","b2","srcByte","dstByte","compressed","_decompress","offsetIn","reader","ReadableStream","pull","controller","enqueue","pipeThrough","DecompressionStream","getReader","chunks","totalLength","done","read","cancel","releaseLock","chunk","images","_kittyIdToStorageId","kittyIdToStorageId","pendingTransmissions","parts","split","part","eqIdx","substring","numValue","KittyImageStorage","_nextImageId","_storageIdToKittyId","_handleStorageImageDeleted","storageId","kittyId","_previousOnImageDeleted","_wrappedOnImageDeleted","imageData","oldStorageId","_maxStoredImages","_evictUndisplayedImages","byteLimit","retainedBytes","evictPlaced","oldestId","wasmDecode","InWasm","MAP","el","D","EMPTY","keepSize","maxBytes","_inst","_ended","_bytes","_m32","_mem","m","grow","Memory","requested","needed","newSize","addPages","loadedBytes","freeBytes","wasmQoiDecode","ib","dl","chunkP","def","W","z","compile","_wasmCtx","fromBase64","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","Event_1","IIPHandler_1","KittyGraphicsHandler_1","KittyImageStorage_1","SixelHandler_1","SixelImageStorage_1","IIPImageStorage_1","enableSizeReports","sixelSupport","iipSupport","kittySupport","_handlers","_onImageAdded","Emitter","_defaultOpts","obj","_disposeLater","args","activate","terminal","ImageStorage","windowOps","options","windowOptions","getWinSizePixels","getCellSizePixels","getWinSizeChars","parser","registerCsiHandler","prefix","_decset","_decrst","_da1","_xtermGraphicsAttributes","onRender","intermediates","registerEscHandler","onRequestReset","onBufferChange","onResize","sixelStorage","SixelImageStorage","sixelHandler","SixelHandler","_parser","registerDcsHandler","iipStorage","IIPImageStorage","iipHandler","IIPHandler","registerOscHandler","kittyStorage","kittyHandler","KittyGraphicsHandler","registerApcHandler","storageUsage","_report","sqrt"],"sourceRoot":""} \ No newline at end of file diff --git a/lib/addon-image.mjs b/lib/addon-image.mjs -index 753a4f833c038a3f45802377dcb16a93695f2161..29c3984db57b888a058165c8aa02ccbcf8efcd2f 100644 +index 753a4f833c038a3f45802377dcb16a93695f2161..aca74ad0c769e8525bc5e4ab8cc6dda03959f760 100644 --- a/lib/addon-image.mjs +++ b/lib/addon-image.mjs @@ -14,5 +14,5 @@ @@ -26,10 +26,10 @@ index 753a4f833c038a3f45802377dcb16a93695f2161..29c3984db57b888a058165c8aa02ccbc * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -var it=Object.create;var Te=Object.defineProperty;var At=Object.getOwnPropertyDescriptor;var rt=Object.getOwnPropertyNames;var st=Object.getPrototypeOf,nt=Object.prototype.hasOwnProperty;var x=(r,e)=>()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var at=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of rt(e))!nt.call(r,A)&&A!==t&&Te(r,A,{get:()=>e[A],enumerable:!(i=At(e,A))||i.enumerable});return r};var M=(r,e,t)=>(t=r!=null?it(st(r)):{},at(e||!r||!r.__esModule?Te(t,"default",{value:r,enumerable:!0}):t,r));var X=x(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function xe(r){return r&255}_.red=xe;function Me(r){return r>>>8&255}_.green=Me;function ve(r){return r>>>16&255}_.blue=ve;function ht(r){return r>>>24&255}_.alpha=ht;function B(r,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|r&255)>>>0}_.toRGBA8888=B;function gt(r){return[r&255,r>>8&255,r>>16&255,r>>>24]}_.fromRGBA8888=gt;function It(r,e){let t=xe(r),i=Me(r),A=ve(r),s=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(r-e)*6*t:t*2<1?r:t*3<2?e+(r-e)*(4-t*6):e}function dt(r,e,t){if(!t){let s=Math.round(e*255);return B(s,s,s)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return B(ge(0,255,Math.round(Ie(i,A,r+1/3)*255)),ge(0,255,Math.round(Ie(i,A,r)*255)),ge(0,255,Math.round(Ie(i,A,r-1/3)*255)))}function m(r,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(r/100*255))>>>0}_.normalizeRGB=m;function lt(r,e,t){return dt((r+240%360)/360,e/100,t/100)}_.normalizeHLS=lt;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let r=[B(0,0,0),B(205,0,0),B(0,205,0),B(205,205,0),B(0,0,238),B(205,0,205),B(0,250,205),B(229,229,229),B(127,127,127),B(255,0,0),B(0,255,0),B(255,255,0),B(92,92,255),B(255,0,255),B(0,255,255),B(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)r.push(B(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)r.push(B(t,t,t));return new Uint32Array(r)})();_.DEFAULT_BACKGROUND=B(0,0,0,255);_.DEFAULT_FOREGROUND=B(255,255,255,255)});var ce=x(le=>{"use strict";Object.defineProperty(le,"__esModule",{value:!0});le.InWasm=Ct;var v=r=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(r);if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(s||(s=new n.Module(A||(A=v(i)))),a):a=>s?n.instantiate(s,a):n.instantiate(A||(A=v(i)),a).then(o=>(s=o.module)&&o.instance):e===1?t?()=>s||(s=new n.Module(A||(A=v(i)))):()=>s?Promise.resolve(s):n.compile(A||(A=v(i))).then(a=>s=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(r)}});var ue=x(_e=>{"use strict";Object.defineProperty(_e,"__esModule",{value:!0});var pt=ce(),Bt=(0,pt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(r=>r.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let r=0;r>4|(r<<4&255)<<8;for(let r=0;r>2<<8|(r<<6&255)<<16;for(let r=0;rthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):ft}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};_e.default=me});var Re=x(Ce=>{"use strict";Object.defineProperty(Ce,"__esModule",{value:!0});var Qt=ce(),Dt=(0,Qt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),Ee=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,s=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Ce.default=Ee});var Ke=x(ie=>{"use strict";Object.defineProperty(ie,"__esModule",{value:!0});ie.LIMITS=void 0;ie.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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"}});var We=x(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=X(),f=Ke();function yt(r){if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},St={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:f.LIMITS.PALETTE_SIZE,truncate:!0};function qe(r){let e=new ye,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||Ye,t).then(i=>(J=J||i.module,new P(r,i.instance||i,e)))}b.DecoderAsync=qe;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=f.LIMITS.MAX_WIDTH+4,this._canvas=Ae,this._bandWidths=[],this._maxWidth=0,this._minWidth=f.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},St,e),this._opts.paletteLimit>f.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${f.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(Ye));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),f.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),f.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,f.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,f.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=f.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let s=!1;if(e)if(e!==this._minWidth)s=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!s)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let s=0;s{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let s=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(s):i.add(s)),s},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function r(s,n){return s(a=>n.fire(a))}A.forward=r;function e(s,n){return(a,o,g)=>s(h=>a.call(o,n(h)),void 0,g)}A.map=e;function t(...s){return(n,a,o)=>{let g=new N;for(let h of s)g.add(h(I=>n.call(a,I)));return o&&(Array.isArray(o)?o.push(g):o.add(g)),g}}A.any=t;function i(s,n,a){return n(a),s(o=>n(o))}A.runAndSubscribe=i})(ot||={});var de=M(X());var D=class r extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new W);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let s=(t??document).createElement("canvas");return s.width=i|0,s.height=A|0,s}static createImageData(t,i,A,s){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return s&&n.data.set(new Uint8ClampedArray(s,0,i*A*4)),n}return s?new ImageData(new Uint8ClampedArray(s,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let s=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,s,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,s,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,s,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:g}=this.cellSize;if(o===-1||g===-1)return;this._rescaleImage(t,o,g);let h=t.actual,I=Math.ceil(h.width/o),d=i%I*o,c=Math.floor(i/I)*g,l=A*o,E=s*g,p=n*o+d>h.width?h.width-d:n*o,u=c+g>h.height?h.height-c:g;a.drawImage(h,Math.floor(d),Math.floor(c),Math.ceil(p),Math.ceil(u),Math.floor(l),Math.floor(E),Math.ceil(p),Math.ceil(u))}extractTile(t,i){let{width:A,height:s}=this.cellSize;if(A===-1||s===-1)return;this._rescaleImage(t,A,s);let n=t.actual,a=Math.ceil(n.width/A),o=i%a*A,g=Math.floor(i/a)*s,h=A+o>n.width?n.width-o:A,I=g+s>n.height?n.height-g:s,d=r.createCanvas(this.document,h,I),c=d.getContext("2d");if(c)return c.drawImage(n,Math.floor(o),Math.floor(g),Math.floor(h),Math.floor(I),0,0,Math.floor(h),Math.floor(I)),d}drawPlaceholder(t,i,A=1){let s=this._layers.get("top");if(s){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;s.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:s,height:n}=t.origCellSize;if(i===s&&A===n){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let a=r.createCanvas(this.document,Math.ceil(t.orig.width*i/s),Math.ceil(t.orig.height*A/n)),o=a.getContext("2d");o&&(o.drawImage(t.orig,0,0,a.width,a.height),t.actual=a,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=r.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let s=i.getContext("2d",{alpha:!0});if(!s){i.remove();return}this._layers.set(t,s),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=r.createCanvas(this.document,i,t),s=A.getContext("2d",{alpha:!1});if(!s)return;let n=r.createImageData(s,i,t),a=new Uint32Array(n.data.buffer),o=(0,de.toRGBA8888)(0,0,0),g=(0,de.toRGBA8888)(255,255,255);a.fill(o);for(let d=0;dthis._placeholderBitmap=d)}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class r{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new r(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,Z=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),s=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,g=this._terminal.rows,h=a.x,I=a.y,d=h,c=0;t.scrolling||(a.x=0,a.y=0,d=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let u=0;u=o);++Q)this._writeToCell(C,d+Q,n,u*A+Q),c++;if(t.scrolling)u=g)break;a.x=d}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(d+A,o):a.x=d:(a.x=h,a.y=I);let l=[];for(let[u,C]of this._images.entries())C.tileCount<1&&(C.marker?.dispose(),l.push(u));for(let u of l)this._delImg(u);let E=this._terminal.registerMarker(0);E?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let p={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:E||void 0,tileCount:c,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,p),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let h of this._images.values())if(h.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:s}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,s);let o=[],g=[];for(let h=A;h<=s;++h){let I=n.lines.get(h+n.ydisp);if(!I)return;for(let d=0;dh.imgSpec.zIndex-I.imgSpec.zIndex);for(let h of g)this._renderer.drawPlaceholder(h.col,h.row,h.count);for(let h of o)this._renderer.draw(h.imgSpec,h.tileId,h.col,h.row,h.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let s=0;s=h)continue;let I=!1;for(let l=A+1;l>e.cols;++l)if(n._data[l*3+0]&4194303){I=!0;break}if(I)continue;let d=Math.min(e.cols,h-a.tileId%h+A),c=a.tileId;for(let l=A+1;l57)throw new Error("illegal char");e=e*10+r[t]-48}return e}function ke(r){let e=j(r);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function wt(r){if(typeof Buffer<"u")return Buffer.from(j(r),"base64").toString();let e=atob(j(r)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=De)return this._a();n[s++]=o}break;case 58:return A===3&&!this._storeValue(s)?this._a():(this.state=4,a+1);default:if(s>=De)return this._a();n[s++]=o}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=j(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Ne[this._key]?Ne[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function Ge(r){if(r.length<32)return L;let e=new Uint32Array(r.buffer,r.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:r[16]<<24|r[17]<<16|r[18]<<8|r[19],height:r[20]<<24|r[21]<<16|r[22]<<8|r[23]};if(r[0]===255&&r[1]===216&&r[2]===255){let[t,i]=bt(r);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(r[4]===55||r[4]===57)&&r[5]===97)return{mime:"image/gif",width:r[7]<<8|r[6],height:r[9]<<8|r[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:r[4]<<24|r[5]<<16|r[6]<<8|r[7],height:r[8]<<24|r[9]<<16|r[10]<<8|r[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(r[15]){case 88:return{mime:"image/webp",width:(r[24]|r[25]<<8|r[26]<<16)+1,height:(r[27]|r[28]<<8|r[29]<<16)+1};case 76:if(r[20]!==47)return L;let t=r[21]|r[22]<<8|r[23]<<16|r[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return r[23]!==157||r[24]!==1||r[25]!==42?L:{mime:"image/webp",width:(r[26]|r[27]<<8)&16383,height:(r[28]|r[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(r.length-16,1024);for(let A=8;A0&&s>0)return{mime:"image/avif",width:A,height:s}}return L}return L}function bt(r){let e=r.length,t=4,i=r[t]<<8|r[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(r[t]!==255)return[0,0];if(r[t+1]===192||r[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},we,this._hp.fields),!this._header.inline){this._aborted=!0;return}this._dec.init()}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let o=w.width,g=w.height;this._renderer.dimensions&&(o=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,g=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let h=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${g.toFixed(3)};${o.toFixed(3)};${h.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},we,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,s,n=L;if((s=e)&&((s=!this._dec.end())?(n=Ge(this._dec.data8),(s=n.mime!=="unsupported")?(i=n.width,A=n.height,(s=i&&A&&i*A(this._storage.addImage(o),!0)).catch(o=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,o),!0))}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,s=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,s,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let g=s/e,h=(n-A)/t,I=Math.min(g,h);return I<1?[e*I,t*I]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var Pe=M(ue());function be(r){let e={},t=r.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let s=i.substring(0,A),n=i.substring(A+1);if(s==="a"){e.action=n;continue}if(s==="o"){e.compression=n;continue}if(s==="t"){e.transmission=n;continue}if(s==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(s){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var Je=0,ee=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=s,!this._inControlData){if(this._parsedCommand=be(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??s?.decoder;o&&o.release(),this._activeDecoder=null,s&&this._removePendingEntry(A),this._aborted=!0;return}this._decodeError||(s?.decoder&&!this._activeDecoder&&(this._activeDecoder=s.decoder),this._activeDecoder||(this._activeDecoder=new Pe.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()),this._activeDecoder.put(e.subarray(t,i))!==Je&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,s&&this._removePendingEntry(A)))}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,s=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(s?(s.totalEncodedSize+=this._totalEncodedSize,s.decodeError=s.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;s&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;s&&(a=s.cmd,o=s.decoder,n=n||s.decodeError,this._pendingTransmissions.delete(i));let g=new Uint8Array(0);o&&(o.end()!==Je&&(n=!0),g=o.data8),this._activeDecoder=null;let h=this._handleCommandWithBytesAndCmd(a,g,n);return o&&o.release(),h}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),s}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(s=>(this._sendResponse(t,s?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,s=this._kittyStorage.getImage(A);if(s){let n=this._displayImage(s,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,s=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",s),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",s),!0;if(t.length===0)return this._sendResponse(A,"OK",s),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",s);else{let o=e.width??0,g=e.height??0;if(!o||!g)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",s),!0;let h=a===32?4:3,I=o*g*h;if(t.length=1||!s&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=await this._createBitmap(e);try{let A=Math.max(0,t.x??0),s=Math.max(0,t.y??0),n=t.sourceWidth||i.width-A,a=t.sourceHeight||i.height-s,o=Math.max(0,i.width-A),g=Math.max(0,i.height-s),h=Math.max(0,Math.min(n,o)),I=Math.max(0,Math.min(a,g));if(h===0||I===0)throw new Error("invalid source rectangle");if(A!==0||s!==0||h!==i.width||I!==i.height){let T=await createImageBitmap(i,A,s,h,I);i.close(),i=T}let d=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,l,E;t.columns!==void 0&&t.rows!==void 0?(l=t.columns,E=t.rows):t.columns!==void 0?(l=t.columns,E=Math.max(1,Math.ceil(i.height/i.width*(l*d)/c))):t.rows!==void 0?(E=t.rows,l=Math.max(1,Math.ceil(i.width/i.height*(E*c)/d))):(l=Math.ceil(i.width/d),E=Math.ceil(i.height/c));let p=i.width,u=i.height;if((t.columns!==void 0||t.rows!==void 0)&&(p=Math.round(l*d),u=Math.round(E*c)),p*u>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let C=this._coreTerminal._core.buffer,Q=C.x,K=C.y,Y=C.ybase,Ve=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(p!==i.width||u!==i.height){let T=await createImageBitmap(i,{resizeWidth:p,resizeHeight:u});i.close(),i=T}let oe=Math.min(Math.max(0,t.xOffset??0),d-1),he=Math.min(Math.max(0,t.yOffset??0),c-1);if(oe!==0||he!==0){let T=t.columns!==void 0?Math.round(l*d):i.width+oe,et=t.rows!==void 0?Math.round(E*c):i.height+he,q=D.createCanvas(window.document,T,et),Le=q.getContext("2d");if(!Le)throw new Error("Failed to create offset canvas context");Le.drawImage(i,oe,he);let tt=await createImageBitmap(q);if(q.width=q.height=0,i.close(),i=tt,p=i.width,u=i.height,p*u>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(l=Math.ceil(i.width/d)),t.rows===void 0&&(E=Math.ceil(i.height/c))}let $e=t.zIndex??0;if(this._kittyStorage.addImage(e.id,i,!0,Ve,$e),i=void 0,t.cursorMovement===1){let T=C.ybase-Y;C.x=Q,C.y=Math.max(K-T,0)}else C.x=Math.min(Q+l,this._coreTerminal.cols)}catch(A){throw i?.close(),A}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let p=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let u=URL.createObjectURL(p),C=new Image;return new Promise((Q,K)=>{C.addEventListener("load",()=>{URL.revokeObjectURL(u);let Y=D.createCanvas(window.document,C.width,C.height);Y.getContext("2d")?.drawImage(C,0,0),createImageBitmap(Y).then(Q).catch(K)}),C.addEventListener("error",()=>{URL.revokeObjectURL(u),K(new Error("Failed to load image"))}),C.src=u})}return createImageBitmap(p)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let s=e.format===32?4:3,n=i*A*s;if(t.length>>24|C<<8,h[c++]=4278190080|C>>>16|Q<<16,h[c++]=4278190080|Q>>>8}let l=I*3,E=I*4;for(let p=I;ph+I.length,0),o=new Uint8Array(a),g=0;for(let h of s)o.set(h,g),g+=h.length;return o}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);return A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages(),this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,s){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var te=U;var y=M(X());var ze=M(We());var xt=4194304,Se=y.PALETTE_ANSI_256;Se.set(y.PALETTE_VT340_COLOR);var se=class{constructor(e,t,i){this._opts=e;this._storage=t;this._coreTerminal=i;this._size=0;this._aborted=!1;(0,ze.DecoderAsync)({memoryLimit:this._opts.pixelLimit*4,palette:Se,paletteLimit:this._opts.sixelPaletteLimit}).then(A=>this._dec=A)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,Se,this._opts.sixelPaletteLimit))}hook(e){if(this._size=0,this._aborted=!1,this._dec){let t=e.params[1]===1?0:Mt(this._coreTerminal._core._inputHandler._curAttrData,this._coreTerminal._core._themeService?.colors);this._dec.init(t,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!(this._aborted||!this._dec)){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._dec.memoryUsage>xt&&this._dec.release(),this._storage.addImage(A),!0}};function Mt(r,e){let t=0;if(!e)return t;if(r.isInverse())if(r.isFgDefault())t=re(e.foreground.rgba);else if(r.isFgRGB()){let i=r.constructor.toColorRGB(r.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=re(e.ansi[r.getFgColor()].rgba);else if(r.isBgDefault())t=re(e.background.rgba);else if(r.isBgRGB()){let i=r.constructor.toColorRGB(r.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=re(e.ansi[r.getBgColor()].rgba);return t}function re(r){return y.BIG_ENDIAN?r:(r&255)<<24|(r>>>8&255)<<16|(r>>>16&255)<<8|r>>>24&255}var ne=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new ne(this._storage,this._opts,this._renderer,e),i=new se(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new ae(this._storage),i=new $(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new te(this._storage),i=new ee(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=Ze?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${Ze}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let s=w;t=(this._terminal?.cols||80)*s.width,i=(this._terminal?.rows||24)*s.height}if(t*i()=>{try{return e||s((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var ot=(s,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of rt(e))!at.call(s,A)&&A!==t&&Me(s,A,{get:()=>e[A],enumerable:!(i=st(e,A))||i.enumerable});return s};var M=(s,e,t)=>(t=s!=null?At(nt(s)):{},ot(e||!s||!s.__esModule?Me(t,"default",{value:s,enumerable:!0}):t,s));var Z=x(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function ve(s){return s&255}_.red=ve;function Re(s){return s>>>8&255}_.green=Re;function ke(s){return s>>>16&255}_.blue=ke;function gt(s){return s>>>24&255}_.alpha=gt;function f(s,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|s&255)>>>0}_.toRGBA8888=f;function dt(s){return[s&255,s>>8&255,s>>16&255,s>>>24]}_.fromRGBA8888=dt;function It(s,e){let t=ve(s),i=Re(s),A=ke(s),r=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(s-e)*6*t:t*2<1?s:t*3<2?e+(s-e)*(4-t*6):e}function lt(s,e,t){if(!t){let r=Math.round(e*255);return f(r,r,r)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return f(Ie(0,255,Math.round(le(i,A,s+1/3)*255)),Ie(0,255,Math.round(le(i,A,s)*255)),Ie(0,255,Math.round(le(i,A,s-1/3)*255)))}function m(s,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(s/100*255))>>>0}_.normalizeRGB=m;function ct(s,e,t){return lt((s+240%360)/360,e/100,t/100)}_.normalizeHLS=ct;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let s=[f(0,0,0),f(205,0,0),f(0,205,0),f(205,205,0),f(0,0,238),f(205,0,205),f(0,250,205),f(229,229,229),f(127,127,127),f(255,0,0),f(0,255,0),f(255,255,0),f(92,92,255),f(255,0,255),f(0,255,255),f(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)s.push(f(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)s.push(f(t,t,t));return new Uint32Array(s)})();_.DEFAULT_BACKGROUND=f(0,0,0,255);_.DEFAULT_FOREGROUND=f(255,255,255,255)});var _e=x(me=>{"use strict";Object.defineProperty(me,"__esModule",{value:!0});me.InWasm=pt;var v=s=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(s);if(typeof Buffer<"u")return Buffer.from(s,"base64");let e=atob(s),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(r||(r=new n.Module(A||(A=v(i)))),a):a=>r?n.instantiate(r,a):n.instantiate(A||(A=v(i)),a).then(o=>(r=o.module)&&o.instance):e===1?t?()=>r||(r=new n.Module(A||(A=v(i)))):()=>r?Promise.resolve(r):n.compile(A||(A=v(i))).then(a=>r=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(s)}});var Ce=x(Ee=>{"use strict";Object.defineProperty(Ee,"__esModule",{value:!0});var Bt=_e(),ft=(0,Bt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(s=>s.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let s=0;s>4|(s<<4&255)<<8;for(let s=0;s>2<<8|(s<<6&255)<<16;for(let s=0;sthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):Qt}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};Ee.default=ue});var Ne=x(Be=>{"use strict";Object.defineProperty(Be,"__esModule",{value:!0});var Dt=_e(),wt=(0,Dt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),pe=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,r=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Be.default=pe});var Ye=x(se=>{"use strict";Object.defineProperty(se,"__esModule",{value:!0});se.LIMITS=void 0;se.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"AGFzbQEAAAABJAdgAAF/YAJ/fwBgA39/fwF/YAF/AX9gAABgBH9/f38AYAF/AAIlAgNlbnYLaGFuZGxlX2JhbmQAAwNlbnYLbW9kZV9wYXJzZWQAAwMTEgQAAAAAAQQBAQUBAAACAgAGAwQFAXABBwcFBAEBBwcGCAF/AUGAihoLB9wBDgZtZW1vcnkCABFnZXRfc3RhdGVfYWRkcmVzcwADEWdldF9jaHVua19hZGRyZXNzAAQOZ2V0X3AwX2FkZHJlc3MABRNnZXRfcGFsZXR0ZV9hZGRyZXNzAAYEaW5pdAALBmRlY29kZQAMDWN1cnJlbnRfd2lkdGgADQ5jdXJyZW50X2hlaWdodAAOGV9faW5kaXJlY3RfZnVuY3Rpb25fdGFibGUBAAtfaW5pdGlhbGl6ZQACCXN0YWNrU2F2ZQARDHN0YWNrUmVzdG9yZQASCnN0YWNrQWxsb2MAEwkMAQBBAQsGCgcJDxACDAEBCq5UEgMAAQsFAEGgCAsGAEGQiQELBgBBsIkCCwUAQZAJC+okAQh/QeQIKAIAIQVB4AgoAgAhA0HoCCgCACEIIAFBkIkBaiIJQf8BOgAAIAAgAUgEQCAAQZCJAWohBgNAIAMhBCAGQQFqIQECQCAGLQAAQf8AcSIDQTBrQQlLBEAgASEGDAELQewIKAIAQQJ0QewIaiICKAIAIQADQCACIAMgAEEKbGpBMGsiADYCACABLQAAIQMgAUEBaiIGIQEgA0H/AHEiA0Ewa0EKSQ0ACwsCQAJAAkACQAJAAkACQAJ/AkACQCADQT9rIgBBP00EQCAERQ0BIARBIUYEQAJAQfAIKAIAIgFBASABGyIHIAhqIgFB1AgoAgAiA0gNACADQf//AEoNAANAIANBAnQiAkGgiQJqIgRBoAgpAwA3AwAgAkGoiQJqQaAIKQMANwMAIAJBsIkCakGgCCkDADcDACACQbiJAmpBoAgpAwA3AwAgAkHAiQJqQaAIKQMANwMAIAJByIkCakGgCCkDADcDACACQdCJAmpBoAgpAwA3AwAgAkHYiQJqQaAIKQMANwMAIAJB4IkCakGgCCkDADcDACACQeiJAmpBoAgpAwA3AwAgAkHwiQJqQaAIKQMANwMAIAJB+IkCakGgCCkDADcDACACQYCKAmpBoAgpAwA3AwAgAkGIigJqQaAIKQMANwMAIAJBkIoCakGgCCkDADcDACACQZiKAmpBoAgpAwA3AwAgAkGgigJqQaAIKQMANwMAIAJBqIoCakGgCCkDADcDACACQbCKAmpBoAgpAwA3AwAgAkG4igJqQaAIKQMANwMAIAJBwIoCakGgCCkDADcDACACQciKAmpBoAgpAwA3AwAgAkHQigJqQaAIKQMANwMAIAJB2IoCakGgCCkDADcDACACQeCKAmpBoAgpAwA3AwAgAkHoigJqQaAIKQMANwMAIAJB8IoCakGgCCkDADcDACACQfiKAmpBoAgpAwA3AwAgAkGAiwJqQaAIKQMANwMAIAJBiIsCakGgCCkDADcDACACQZCLAmpBoAgpAwA3AwAgAkGYiwJqQaAIKQMANwMAIAJBoIsCakGgCCkDADcDACACQaiLAmpBoAgpAwA3AwAgAkGwiwJqQaAIKQMANwMAIAJBuIsCakGgCCkDADcDACACQcCLAmpBoAgpAwA3AwAgAkHIiwJqQaAIKQMANwMAIAJB0IsCakGgCCkDADcDACACQdiLAmpBoAgpAwA3AwAgAkHgiwJqQaAIKQMANwMAIAJB6IsCakGgCCkDADcDACACQfCLAmpBoAgpAwA3AwAgAkH4iwJqQaAIKQMANwMAIAJBgIwCakGgCCkDADcDACACQYiMAmpBoAgpAwA3AwAgAkGQjAJqQaAIKQMANwMAIAJBmIwCakGgCCkDADcDACACQaCMAmpBoAgpAwA3AwAgAkGojAJqQaAIKQMANwMAIAJBsIwCakGgCCkDADcDACACQbiMAmpBoAgpAwA3AwAgAkHAjAJqQaAIKQMANwMAIAJByIwCakGgCCkDADcDACACQdCMAmpBoAgpAwA3AwAgAkHYjAJqQaAIKQMANwMAIAJB4IwCakGgCCkDADcDACACQeiMAmpBoAgpAwA3AwAgAkHwjAJqQaAIKQMANwMAIAJB+IwCakGgCCkDADcDACACQYCNAmpBoAgpAwA3AwAgAkGIjQJqQaAIKQMANwMAIAJBkI0CakGgCCkDADcDACACQZiNAmpBoAgpAwA3AwAgAkGwiQZqIARBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBEGABPwKAABB1AgoAgBBAnRB0IkOaiAEQYAE/AoAAEHUCCgCAEECdEHgiRJqIARBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBEGABPwKAABB1AhB1AgoAgAiAkGAAWoiAzYCACABIANIDQEgAkGA/wBIDQALCwJAIABFDQAgCEH//wBLDQBBgIABIAhrIAcgAUH//wBLGyECAkAgAEEBcUUNACACRQ0AIAhBAnRBoIkCaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBAnFFDQAgAkUNACAIQQJ0QbCJBmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsCQCAAQQRxRQ0AIAJFDQAgCEECdEHAiQpqIQMgAiEEIAJBB3EiBwRAA0AgAyAFNgIAIANBBGohAyAEQQFrIQQgB0EBayIHDQALCyACQQFrQQdJDQADQCADIAU2AhwgAyAFNgIYIAMgBTYCFCADIAU2AhAgAyAFNgIMIAMgBTYCCCADIAU2AgQgAyAFNgIAIANBIGohAyAEQQhrIgQNAAsLAkAgAEEIcUUNACACRQ0AIAhBAnRB0IkOaiEDIAIhBCACQQdxIgcEQANAIAMgBTYCACADQQRqIQMgBEEBayEEIAdBAWsiBw0ACwsgAkEBa0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgBEEIayIEDQALCwJAIABBEHFFDQAgAkUNACAIQQJ0QeCJEmohAyACIQQgAkEHcSIHBEADQCADIAU2AgAgA0EEaiEDIARBAWshBCAHQQFrIgcNAAsLIAJBAWtBB0kNAANAIAMgBTYCHCADIAU2AhggAyAFNgIUIAMgBTYCECADIAU2AgwgAyAFNgIIIAMgBTYCBCADIAU2AgAgA0EgaiEDIARBCGsiBA0ACwsgAEEgcUUNACACRQ0AIAJBAWshByAIQQJ0QfCJFmohAyACQQdxIgQEQANAIAMgBTYCACADQQRqIQMgAkEBayECIARBAWsiBA0ACwsgB0EHSQ0AA0AgAyAFNgIcIAMgBTYCGCADIAU2AhQgAyAFNgIQIAMgBTYCDCADIAU2AgggAyAFNgIEIAMgBTYCACADQSBqIQMgAkEIayICDQALC0HcCEHcCCgCACAAcjYCACAGQQFqIgIgBi0AAEH/AHEiA0E/ayIAQT9LDQQaDAMLAkBB7AgoAgAiBEEBRgRAQfAIKAIAIgNBzAgoAgAiAUkNASADIAFwIQMMAQtB+AgoAgAhAkH0CCgCACEBAkACQCAEQQVHDQAgAUEBRw0AIAJB6QJODQQMAQsgAkHkAEoNA0H8CCgCAEHkAEoNA0GACSgCAEHkAEoNAwsCQCABRQ0AIAFBAkoNACACQfwIKAIAQYAJKAIAIAFBAnRBiAhqKAIAEQIAIQFB8AgoAgAiA0HMCCgCACICTwR/IAMgAnAFIAMLQQJ0QZAJaiABNgIAC0HwCCgCACIDQcwIKAIAIgFJDQAgAyABcCEDCyADQQJ0QZAJaigCACEFDAELIANB/QBxQSFHBEAgCCEBIAYhAgwECyAEQSNHDQQCQEHsCCgCACICQQFGBEBB8AgoAgAiAUHMCCgCACIASQ0BIAEgAHAhAQwBC0H4CCgCACEBQfQIKAIAIQACQAJAIAJBBUcNACAAQQFHDQAgAUHpAkgNAQwHCyABQeQASg0GQfwIKAIAQeQASg0GQYAJKAIAQeQASg0GCwJAIABFDQAgAEECSg0AIAFB/AgoAgBBgAkoAgAgAEECdEGICGooAgARAgAhAEHwCCgCACIBQcwIKAIAIgJPBH8gASACcAUgAQtBAnRBkAlqIAA2AgALQfAIKAIAIgFBzAgoAgAiAEkNACABIABwIQELIAFBAnRBkAlqKAIAIQUMBAsgCCEBIAYhAgtB1AgoAgAhBgNAAkAgASAGSA0AIAZB//8ASg0AIAZBAnQiBEGgiQJqIgZBoAgpAwA3AwAgBEGoiQJqQaAIKQMANwMAIARBsIkCakGgCCkDADcDACAEQbiJAmpBoAgpAwA3AwAgBEHAiQJqQaAIKQMANwMAIARByIkCakGgCCkDADcDACAEQdCJAmpBoAgpAwA3AwAgBEHYiQJqQaAIKQMANwMAIARB4IkCakGgCCkDADcDACAEQeiJAmpBoAgpAwA3AwAgBEHwiQJqQaAIKQMANwMAIARB+IkCakGgCCkDADcDACAEQYCKAmpBoAgpAwA3AwAgBEGIigJqQaAIKQMANwMAIARBkIoCakGgCCkDADcDACAEQZiKAmpBoAgpAwA3AwAgBEGgigJqQaAIKQMANwMAIARBqIoCakGgCCkDADcDACAEQbCKAmpBoAgpAwA3AwAgBEG4igJqQaAIKQMANwMAIARBwIoCakGgCCkDADcDACAEQciKAmpBoAgpAwA3AwAgBEHQigJqQaAIKQMANwMAIARB2IoCakGgCCkDADcDACAEQeCKAmpBoAgpAwA3AwAgBEHoigJqQaAIKQMANwMAIARB8IoCakGgCCkDADcDACAEQfiKAmpBoAgpAwA3AwAgBEGAiwJqQaAIKQMANwMAIARBiIsCakGgCCkDADcDACAEQZCLAmpBoAgpAwA3AwAgBEGYiwJqQaAIKQMANwMAIARBoIsCakGgCCkDADcDACAEQaiLAmpBoAgpAwA3AwAgBEGwiwJqQaAIKQMANwMAIARBuIsCakGgCCkDADcDACAEQcCLAmpBoAgpAwA3AwAgBEHIiwJqQaAIKQMANwMAIARB0IsCakGgCCkDADcDACAEQdiLAmpBoAgpAwA3AwAgBEHgiwJqQaAIKQMANwMAIARB6IsCakGgCCkDADcDACAEQfCLAmpBoAgpAwA3AwAgBEH4iwJqQaAIKQMANwMAIARBgIwCakGgCCkDADcDACAEQYiMAmpBoAgpAwA3AwAgBEGQjAJqQaAIKQMANwMAIARBmIwCakGgCCkDADcDACAEQaCMAmpBoAgpAwA3AwAgBEGojAJqQaAIKQMANwMAIARBsIwCakGgCCkDADcDACAEQbiMAmpBoAgpAwA3AwAgBEHAjAJqQaAIKQMANwMAIARByIwCakGgCCkDADcDACAEQdCMAmpBoAgpAwA3AwAgBEHYjAJqQaAIKQMANwMAIARB4IwCakGgCCkDADcDACAEQeiMAmpBoAgpAwA3AwAgBEHwjAJqQaAIKQMANwMAIARB+IwCakGgCCkDADcDACAEQYCNAmpBoAgpAwA3AwAgBEGIjQJqQaAIKQMANwMAIARBkI0CakGgCCkDADcDACAEQZiNAmpBoAgpAwA3AwAgBEGwiQZqIAZBgAT8CgAAQdQIKAIAQQJ0QcCJCmogBkGABPwKAABB1AgoAgBBAnRB0IkOaiAGQYAE/AoAAEHUCCgCAEECdEHgiRJqIAZBgAT8CgAAQdQIKAIAQQJ0QfCJFmogBkGABPwKAABB1AhB1AgoAgBBgAFqIgY2AgALIAFB//8ATQRAIABBAXEgAWxBAnRBoIkCaiAFNgIAIABBAXZBAXEgAWxBAnRBsIkGaiAFNgIAIABBAnZBAXEgAWxBAnRBwIkKaiAFNgIAIABBA3ZBAXEgAWxBAnRB0IkOaiAFNgIAIABBBHZBAXEgAWxBAnRB4IkSaiAFNgIAIABBBXYgAWxBAnRB8IkWaiAFNgIAQdQIKAIAIQYLIAFBAWohAUHcCEHcCCgCACAAcjYCACACLQAAIQAgAkEBaiIEIQIgAEH/AHEiA0E/ayIAQcAASQ0ACyAECyECQQAhBCACIQYgASEIIANB/QBxQSFGDQELIANBJGsOCgEDAwMDAwMDAwIDC0HsCEIBNwIADAQLQdgIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbNgIADAILQegIIAFB2AgoAgAiACAAIAFIGyIAQYCAASAAQYCAAUgbIgA2AgBB2AggADYCACAAQQRrEAAEQEHoCEEENgIAQdgIQQQ2AgBB0AhBATYCAA8LEAgMAQsCQCADQTtHDQBB7AgoAgAiAEEHSg0AQewIIABBAWo2AgAgAEECdEHwCGpBADYCAAsgAiEGIAQhAyABIQgMAQtBBCEIIAIhBiAEIQMLIAYgCUkNAAsLQeQIIAU2AgBB4AggAzYCAEHoCCAINgIAC9ELAgF+CH9B2AhCBDcDAEGojQJBoAgpAwAiADcDAEGgjQIgADcDAEGYjQIgADcDAEGQjQIgADcDAEGIjQIgADcDAEGAjQIgADcDAEH4jAIgADcDAEHwjAIgADcDAEHojAIgADcDAEHgjAIgADcDAEHYjAIgADcDAEHQjAIgADcDAEHIjAIgADcDAEHAjAIgADcDAEG4jAIgADcDAEGwjAIgADcDAEGojAIgADcDAEGgjAIgADcDAEGYjAIgADcDAEGQjAIgADcDAEGIjAIgADcDAEGAjAIgADcDAEH4iwIgADcDAEHwiwIgADcDAEHoiwIgADcDAEHgiwIgADcDAEHYiwIgADcDAEHQiwIgADcDAEHIiwIgADcDAEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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"}});var We=x(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=Z(),Q=Ye();function St(s){if(typeof Buffer<"u")return Buffer.from(s,"base64");let e=atob(s),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},Lt={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:Q.LIMITS.PALETTE_SIZE,truncate:!0};function ze(s){let e=new Le,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||qe,t).then(i=>(J=J||i.module,new P(s,i.instance||i,e)))}b.DecoderAsync=ze;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=Q.LIMITS.MAX_WIDTH+4,this._canvas=re,this._bandWidths=[],this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},Lt,e),this._opts.paletteLimit>Q.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${Q.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(qe));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),Q.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),Q.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,Q.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,r=0;for(;r<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*r,t*r+e),i+e*r),r++,A--;this._lastOffset+=e*r,this._currentHeight+=r}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,Q.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,r=0;for(;r<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*r,i*r+e),A+e*r),r++,t--;t&&this._canvas.fill(this._fillColor,A+e*r)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let r=!1;if(e)if(e!==this._minWidth)r=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!r)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let r=0;r{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let r=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function s(r,n){return r(a=>n.fire(a))}A.forward=s;function e(r,n){return(a,o,h)=>r(g=>a.call(o,n(g)),void 0,h)}A.map=e;function t(...r){return(n,a,o)=>{let h=new N;for(let g of r)h.add(g(d=>n.call(a,d)));return o&&(Array.isArray(o)?o.push(h):o.add(h)),h}}A.any=t;function i(r,n,a){return n(a),r(o=>n(o))}A.runAndSubscribe=i})(ht||={});var ce=M(Z());var D=class s extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new W);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let r=(t??document).createElement("canvas");return r.width=i|0,r.height=A|0,r}static createImageData(t,i,A,r){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return r&&n.data.set(new Uint8ClampedArray(r,0,i*A*4)),n}return r?new ImageData(new Uint8ClampedArray(r,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let r=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,r,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,r,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,r,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:h}=this.cellSize;if(o===-1||h===-1)return;this._rescaleImage(t,o,h);let g=t.actual,{width:d,height:I}=t.actualCellSize,l=Math.ceil(g.width/d),c=i%l*d,u=Math.floor(i/l)*I,E=A*o,C=r*h,p=n*d+c>g.width?g.width-c:n*d,B=u+I>g.height?g.height-u:I;a.drawImage(g,Math.floor(c),Math.floor(u),Math.ceil(p),Math.ceil(B),Math.floor(E),Math.floor(C),Math.ceil(p*o/d),Math.ceil(B*h/I))}extractTile(t,i){let{width:A,height:r}=this.cellSize;if(A===-1||r===-1)return;this._rescaleImage(t,A,r);let n=t.actual,{width:a,height:o}=t.actualCellSize,h=Math.ceil(n.width/a),g=i%h*a,d=Math.floor(i/h)*o,I=a+g>n.width?n.width-g:a,l=d+o>n.height?n.height-d:o,c=s.createCanvas(this.document,Math.ceil(I*A/a),Math.ceil(l*r/o)),u=c.getContext("2d");if(u)return u.drawImage(n,Math.floor(g),Math.floor(d),Math.floor(I),Math.floor(l),0,0,c.width,c.height),c}drawPlaceholder(t,i,A=1){let r=this._layers.get("top");if(r){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;r.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:r,height:n}=t.origCellSize;if(i===r&&A===n){t.actual=t.orig,t.actualCellSize.width=r,t.actualCellSize.height=n;return}let a=Math.ceil(t.orig.width*i/r),o=Math.ceil(t.orig.height*A/n);if(a*o>t.orig.width*t.orig.height){t.actual=t.orig,t.actualCellSize.width=r,t.actualCellSize.height=n;return}let h=s.createCanvas(this.document,a,o),g=h.getContext("2d");g&&(g.drawImage(t.orig,0,0,h.width,h.height),t.actual=h,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=s.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let r=i.getContext("2d",{alpha:!0});if(!r){i.remove();return}this._layers.set(t,r),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=s.createCanvas(this.document,i,t),r=A.getContext("2d",{alpha:!1});if(!r)return;let n=s.createImageData(r,i,t),a=new Uint32Array(n.data.buffer),o=(0,ce.toRGBA8888)(0,0,0),h=(0,ce.toRGBA8888)(255,255,255);a.fill(o);for(let l=0;l{this._placeholder!==I?l?.close():this._placeholderBitmap=l}).catch(()=>{})}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class s{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new s(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,j=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),r=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,h=this._terminal.rows,g=a.x,d=a.y,I=g,l=0;t.scrolling||(a.x=0,a.y=0,I=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let C=0;C=o);++B)this._writeToCell(p,I+B,n,C*A+B),l++;if(t.scrolling)C=h)break;a.x=I}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(I+A,o):a.x=I:(a.x=g,a.y=d);let c=[];for(let[C,p]of this._images.entries())p.tileCount<1&&(p.marker?.dispose(),c.push(C));for(let C of c)this._delImg(C);let u=this._terminal.registerMarker(0);u?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let E={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:u||void 0,tileCount:l,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,E),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let g of this._images.values())if(g.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:r}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,r);let o=[],h=[];for(let g=A;g<=r;++g){let d=n.lines.get(g+n.ydisp);if(!d)return;for(let I=0;Ig.imgSpec.zIndex-d.imgSpec.zIndex);for(let g of h)this._renderer.drawPlaceholder(g.col,g.row,g.count);for(let g of o)this._renderer.draw(g.imgSpec,g.tileId,g.col,g.row,g.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let r=0;r=g)continue;let d=!1;for(let c=A+1;c>e.cols;++c)if(n._data[c*3+0]&4194303){d=!0;break}if(d)continue;let I=Math.min(e.cols,g-a.tileId%g+A),l=a.tileId;for(let c=A+1;c57)throw new Error("illegal char");e=e*10+s[t]-48}return e}function He(s){let e=V(s);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function bt(s){if(typeof Buffer<"u")return Buffer.from(V(s),"base64").toString();let e=atob(V(s)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=be)return this._a();n[r++]=o}break;case 58:return A===3&&!this._storeValue(r)?this._a():(this.state=4,a+1);default:if(r>=be)return this._a();n[r++]=o}}return this.state=A,this._position=r,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=V(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Fe[this._key]?Fe[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function ee(s){if(s.length<32)return L;let e=new Uint32Array(s.buffer,s.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:s[16]<<24|s[17]<<16|s[18]<<8|s[19],height:s[20]<<24|s[21]<<16|s[22]<<8|s[23]};if(s[0]===255&&s[1]===216&&s[2]===255){let[t,i]=yt(s);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(s[4]===55||s[4]===57)&&s[5]===97)return{mime:"image/gif",width:s[7]<<8|s[6],height:s[9]<<8|s[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:s[4]<<24|s[5]<<16|s[6]<<8|s[7],height:s[8]<<24|s[9]<<16|s[10]<<8|s[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(s[15]){case 88:return{mime:"image/webp",width:(s[24]|s[25]<<8|s[26]<<16)+1,height:(s[27]|s[28]<<8|s[29]<<16)+1};case 76:if(s[20]!==47)return L;let t=s[21]|s[22]<<8|s[23]<<16|s[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return s[23]!==157||s[24]!==1||s[25]!==42?L:{mime:"image/webp",width:(s[26]|s[27]<<8)&16383,height:(s[28]|s[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(s.length-16,1024);for(let A=8;A0&&r>0)return{mime:"image/avif",width:A,height:r}}return L}return L}function yt(s){let e=s.length,t=4,i=s[t]<<8|s[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(s[t]!==255)return[0,0];if(s[t+1]===192||s[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},ye,this._hp.fields),!this._header.inline){this._aborted=!0;return}this._dec.init()}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let h=w.width,g=w.height;this._renderer.dimensions&&(h=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,g=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let d=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${g.toFixed(3)};${h.toFixed(3)};${d.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},ye,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._dec.init(),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,r,n=L;if((r=e)&&((r=!this._dec.end())?(n=ee(this._dec.data8),(r=n.mime!=="unsupported")?(i=n.width,A=n.height,(r=i&&A&&i*Ao!==this._generation?(h.close(),!0):(this._storage.addImage(h),!0)).catch(h=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,h),!0))}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,r=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,r,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let h=r/e,g=(n-A)/t,d=Math.min(h,g);return d<1?[e*d,t*d]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var Ke=M(Ce());function Se(s){let e={},t=s.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let r=i.substring(0,A),n=i.substring(A+1);if(r==="a"){e.action=n;continue}if(r==="o"){e.compression=n;continue}if(r==="t"){e.transmission=n;continue}if(r==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(r){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var Pe=0,ie=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._generation=0;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._generation++,this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=r,!this._inControlData){if(this._parsedCommand=Se(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??r?.decoder;o&&o.release(),this._activeDecoder=null,r&&this._removePendingEntry(A),this._aborted=!0;return}if(!this._decodeError){if(r?.decoder&&!this._activeDecoder&&(this._activeDecoder=r.decoder),!this._activeDecoder){let o=this._maxEncodedBytes+131072;if(o>this._opts.storageLimit*1e6){this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",this._parsedCommand.quiet??0);return}let h=Math.max(1,Math.floor(this._opts.storageLimit*1e6/o));for(;this._pendingTransmissions.size>=h;){let g=this._pendingTransmissions.entries().next().value;if(!g)break;g[1].decoder.release(),this._removePendingEntry(g[0]),g[1].cmd.id!==void 0&&this._sendResponse(g[1].cmd.id,"ENOMEM:pending image budget exceeded",g[1].cmd.quiet??0)}this._activeDecoder=new Ke.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes),this._activeDecoder.init()}this._activeDecoder.put(e.subarray(t,i))!==Pe&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,r&&this._removePendingEntry(A))}}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,r=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(r?(r.totalEncodedSize+=this._totalEncodedSize,r.decodeError=r.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;r&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;r&&(a=r.cmd,o=r.decoder,n=n||r.decodeError,this._pendingTransmissions.delete(i));let h=new Uint8Array(0);o&&(o.end()!==Pe&&(n=!0),h=o.data8),this._activeDecoder=null;let g=this._handleCommandWithBytesAndCmd(a,h,n);return o&&o.release(),g}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),r}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(r=>(this._sendResponse(t,r?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,r=this._kittyStorage.getImage(A);if(r){let n=this._displayImage(r,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,r=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",r),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",r),!0;if(t.length===0)return this._sendResponse(A,"OK",r),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",r);else{let o=e.width??0,h=e.height??0;if(!o||!h)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",r),!0;let g=a===32?4:3,d=o*h*g;if(t.length=1||!r&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=this._generation,A=await this._createBitmap(e);try{if(i!==this._generation)throw new Error("image decode canceled");let r=Math.max(0,t.x??0),n=Math.max(0,t.y??0),a=t.sourceWidth||A.width-r,o=t.sourceHeight||A.height-n,h=Math.max(0,A.width-r),g=Math.max(0,A.height-n),d=Math.max(0,Math.min(a,h)),I=Math.max(0,Math.min(o,g));if(d===0||I===0)throw new Error("invalid source rectangle");if(r!==0||n!==0||d!==A.width||I!==A.height){let T=await createImageBitmap(A,r,n,d,I);A.close(),A=T}let l=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,u,E;t.columns!==void 0&&t.rows!==void 0?(u=t.columns,E=t.rows):t.columns!==void 0?(u=t.columns,E=Math.max(1,Math.ceil(A.height/A.width*(u*l)/c))):t.rows!==void 0?(E=t.rows,u=Math.max(1,Math.ceil(A.width/A.height*(E*c)/l))):(u=Math.ceil(A.width/l),E=Math.ceil(A.height/c));let C=A.width,p=A.height;if((t.columns!==void 0||t.rows!==void 0)&&(C=Math.round(u*l),p=Math.round(E*c)),C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let B=this._coreTerminal._core.buffer,K=B.x,Y=B.y,q=B.ybase,$e=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(C!==A.width||p!==A.height){let T=await createImageBitmap(A,{resizeWidth:C,resizeHeight:p});A.close(),A=T}let ge=Math.min(Math.max(0,t.xOffset??0),l-1),de=Math.min(Math.max(0,t.yOffset??0),c-1);if(ge!==0||de!==0){let T=t.columns!==void 0?Math.round(u*l):A.width+ge,tt=t.rows!==void 0?Math.round(E*c):A.height+de,z=D.createCanvas(window.document,T,tt),xe=z.getContext("2d");if(!xe)throw new Error("Failed to create offset canvas context");xe.drawImage(A,ge,de);let it=await createImageBitmap(z);if(z.width=z.height=0,A.close(),A=it,C=A.width,p=A.height,C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(u=Math.ceil(A.width/l)),t.rows===void 0&&(E=Math.ceil(A.height/c))}if(i!==this._generation)throw new Error("image decode canceled");let et=t.zIndex??0;if(this._kittyStorage.addImage(e.id,A,!0,$e,et),A=void 0,t.cursorMovement===1){let T=B.ybase-q;B.x=K,B.y=Math.max(Y-T,0)}else B.x=Math.min(K+u,this._coreTerminal.cols)}catch(r){throw A?.close(),r}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let E=ee(t);if(E.mime!=="image/png"||!(E.width>0)||!(E.height>0)||E.width*E.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");let C=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let p=URL.createObjectURL(C),B=new Image;return new Promise((K,Y)=>{B.addEventListener("load",()=>{URL.revokeObjectURL(p);let q=D.createCanvas(window.document,B.width,B.height);q.getContext("2d")?.drawImage(B,0,0),createImageBitmap(q).then(K).catch(Y)}),B.addEventListener("error",()=>{URL.revokeObjectURL(p),Y(new Error("Failed to load image"))}),B.src=p})}return createImageBitmap(C)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let r=e.format===32?4:3,n=i*A*r;if(t.length>>24|p<<8,g[l++]=4278190080|p>>>16|B<<16,g[l++]=4278190080|B>>>8}let c=d*3,u=d*4;for(let E=d;E=e.length){d.close();return}let I=Math.min(A+4096,e.length);d.enqueue(new Uint8Array(e.subarray(A,I))),A=I}}).pipeThrough(new DecompressionStream(t)).getReader(),a=[],o=0;try{for(;;){let{done:d,value:I}=await n.read();if(d)break;if(o+=I.byteLength,o>i)throw await n.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");a.push(I)}}finally{n.releaseLock()}let h=new Uint8Array(o),g=0;for(let d of a)h.set(d,g),g+=d.length;return h}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages();let r=this._storage.getLimit()*1e6;this._images.delete(i);let n=0;for(let a of this._images.values())n+=a.data.size;for(let a of[!1,!0])for(let[o,h]of this._images){if(n+t.data.size<=r)break;this._kittyIdToStorageId.has(o)===a&&(n-=h.data.size,this.deleteById(o))}return this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,r){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=r;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var Ae=U;var y=M(Z());var Xe=M(We());var Mt=4194304,Te=y.PALETTE_ANSI_256;Te.set(y.PALETTE_VT340_COLOR);var ae=class{constructor(e,t,i){this._opts=e;this._storage=t;this._coreTerminal=i;this._size=0;this._aborted=!1;(0,Xe.DecoderAsync)({memoryLimit:this._opts.pixelLimit*4,palette:Te,paletteLimit:this._opts.sixelPaletteLimit}).then(A=>this._dec=A)}reset(){this._dec&&(this._dec.release(),this._dec._palette.fill(0),this._dec.init(0,Te,this._opts.sixelPaletteLimit))}hook(e){if(this._size=0,this._aborted=!1,this._dec){let t=e.params[1]===1?0:vt(this._coreTerminal._core._inputHandler._curAttrData,this._coreTerminal._core._themeService?.colors);this._dec.init(t,null,this._opts.sixelPaletteLimit)}}put(e,t,i){if(!(this._aborted||!this._dec)){if(this._size+=i-t,this._size>this._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._dec.memoryUsage>Mt&&this._dec.release(),this._storage.addImage(A),!0}};function vt(s,e){let t=0;if(!e)return t;if(s.isInverse())if(s.isFgDefault())t=ne(e.foreground.rgba);else if(s.isFgRGB()){let i=s.constructor.toColorRGB(s.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=ne(e.ansi[s.getFgColor()].rgba);else if(s.isBgDefault())t=ne(e.background.rgba);else if(s.isBgRGB()){let i=s.constructor.toColorRGB(s.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=ne(e.ansi[s.getBgColor()].rgba);return t}function ne(s){return y.BIG_ENDIAN?s:(s&255)<<24|(s>>>8&255)<<16|(s>>>16&255)<<8|s>>>24&255}var oe=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new oe(this._storage,this._opts,this._renderer,e),i=new ae(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new he(this._storage),i=new te(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new Ae(this._storage),i=new ie(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=je?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${je}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let r=w;t=(this._terminal?.cols||80)*r.width,i=(this._terminal?.rows||24)*r.height}if(t*i()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}};var It=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let A of ht(e))!gt.call(r,A)&&A!==t&&Ne(r,A,{get:()=>e[A],enumerable:!(i=ot(e,A))||i.enumerable});return r};var x=(r,e,t)=>(t=r!=null?at(dt(r)):{},It(e||!r||!r.__esModule?Ne(t,"default",{value:r,enumerable:!0}):t,r));var j=M(_=>{"use strict";Object.defineProperty(_,"__esModule",{value:!0});_.DEFAULT_FOREGROUND=_.DEFAULT_BACKGROUND=_.PALETTE_ANSI_256=_.PALETTE_VT340_GREY=_.PALETTE_VT340_COLOR=_.normalizeHLS=_.normalizeRGB=_.nearestColorIndex=_.fromRGBA8888=_.toRGBA8888=_.alpha=_.blue=_.green=_.red=_.BIG_ENDIAN=void 0;_.BIG_ENDIAN=new Uint8Array(new Uint32Array([4278190080]).buffer)[0]===255;_.BIG_ENDIAN&&console.warn("BE platform detected. This version of node-sixel works only on LE properly.");function He(r){return r&255}_.red=He;function Fe(r){return r>>>8&255}_.green=Fe;function Ge(r){return r>>>16&255}_.blue=Ge;function ct(r){return r>>>24&255}_.alpha=ct;function f(r,e,t,i=255){return((i&255)<<24|(t&255)<<16|(e&255)<<8|r&255)>>>0}_.toRGBA8888=f;function mt(r){return[r&255,r>>8&255,r>>16&255,r>>>24]}_.fromRGBA8888=mt;function _t(r,e){let t=He(r),i=Fe(r),A=Ge(r),s=Number.MAX_SAFE_INTEGER,n=-1;for(let a=0;a1&&(t-=1),t*6<1?e+(r-e)*6*t:t*2<1?r:t*3<2?e+(r-e)*(4-t*6):e}function ut(r,e,t){if(!t){let s=Math.round(e*255);return f(s,s,s)}let i=e<.5?e*(1+t):e+t-e*t,A=2*e-i;return f(ce(0,255,Math.round(me(i,A,r+1/3)*255)),ce(0,255,Math.round(me(i,A,r)*255)),ce(0,255,Math.round(me(i,A,r-1/3)*255)))}function m(r,e,t){return(4278190080|Math.round(t/100*255)<<16|Math.round(e/100*255)<<8|Math.round(r/100*255))>>>0}_.normalizeRGB=m;function Et(r,e,t){return ut((r+240%360)/360,e/100,t/100)}_.normalizeHLS=Et;_.PALETTE_VT340_COLOR=new Uint32Array([m(0,0,0),m(20,20,80),m(80,13,13),m(20,80,20),m(80,20,80),m(20,80,80),m(80,80,20),m(53,53,53),m(26,26,26),m(33,33,60),m(60,26,26),m(33,60,33),m(60,33,60),m(33,60,60),m(60,60,33),m(80,80,80)]);_.PALETTE_VT340_GREY=new Uint32Array([m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46),m(0,0,0),m(13,13,13),m(26,26,26),m(40,40,40),m(6,6,6),m(20,20,20),m(33,33,33),m(46,46,46)]);_.PALETTE_ANSI_256=(()=>{let r=[f(0,0,0),f(205,0,0),f(0,205,0),f(205,205,0),f(0,0,238),f(205,0,205),f(0,250,205),f(229,229,229),f(127,127,127),f(255,0,0),f(0,255,0),f(255,255,0),f(92,92,255),f(255,0,255),f(0,255,255),f(255,255,255)],e=[0,95,135,175,215,255];for(let t=0;t<6;++t)for(let i=0;i<6;++i)for(let A=0;A<6;++A)r.push(f(e[t],e[i],e[A]));for(let t=8;t<=238;t+=10)r.push(f(t,t,t));return new Uint32Array(r)})();_.DEFAULT_BACKGROUND=f(0,0,0,255);_.DEFAULT_FOREGROUND=f(255,255,255,255)});var Ee=M(ue=>{"use strict";Object.defineProperty(ue,"__esModule",{value:!0});ue.InWasm=Dt;var v=r=>{if(Uint8Array.fromBase64)return Uint8Array.fromBase64(r);if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;inew n.Instance(s||(s=new n.Module(A||(A=v(i)))),a):a=>s?n.instantiate(s,a):n.instantiate(A||(A=v(i)),a).then(o=>(s=o.module)&&o.instance):e===1?t?()=>s||(s=new n.Module(A||(A=v(i)))):()=>s?Promise.resolve(s):n.compile(A||(A=v(i))).then(a=>s=a):t?()=>A||(A=v(i)):()=>Promise.resolve(A||(A=v(i)))}if(typeof _wasmCtx>"u")throw new Error('must run "inwasm"');_wasmCtx.add(r)}});var Be=M(pe=>{"use strict";Object.defineProperty(pe,"__esModule",{value:!0});var wt=Ee(),bt=(0,wt.InWasm)({s:1,t:0,d:"AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML"}),S=new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map(r=>r.charCodeAt(0))),R=new Uint32Array(1024);R.fill(4278190080);for(let r=0;r>4|(r<<4&255)<<8;for(let r=0;r>2<<8|(r<<6&255)<<16;for(let r=0;rthis.maxBytes||this.maxBytes>4294901760)throw new Error("invalid byte settings")}get data8(){return this._inst?this._d.subarray(0,this._m32[1282]):yt}release(){this._inst&&(this._bytes>this.keepSize?this._inst=this._m32=this._d=this._mem=null:(this._m32[1280]=0,this._m32[1281]=0,this._m32[1282]=0))}init(e,t){if(this.maxBytes=e??this.maxBytes,this._bytes=t??Math.min(this._bytes,this.maxBytes),this._bytes>this.maxBytes||this.maxBytes>4294901760)throw Error("invalid byte settings");let i=this._m32,A=this._bytes+5152;this._inst?this._mem.buffer.byteLengththis.maxBytes)return-3;let i=this._bytes;for(;(i*=2)this._mem.buffer.byteLength){let A=Math.ceil((i+5152-this._mem.buffer.byteLength)/65536);this._mem.grow(A),this._m32=new Uint32Array(this._mem.buffer,0),this._d=new Uint8Array(this._mem.buffer,5152)}this._bytes=i}return 0}put(e){if(!this._inst||this._ended)return-2;if(this._realloc(e.length))return-3;let t=this._m32;return this._d.set(e,t[1280]),t[1280]+=e.length,t[1280]-t[1281]>=131072?this._inst.exports.dec():0}end(){return this._ended=!0,this._inst?this._inst.exports.end():-2}get loadedBytes(){return this._inst?this._m32[1280]:0}get freeBytes(){return this._inst?this.maxBytes-this._m32[1280]:0}};pe.default=Ce});var Ue=M(Qe=>{"use strict";Object.defineProperty(Qe,"__esModule",{value:!0});var St=Ee(),Lt=(0,St.InWasm)({s:1,t:0,d:"AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=="}),fe=class{constructor(e){this.keepSize=e,this.width=0,this.height=0}decode(e){this.width=e[4]<<24|e[5]<<16|e[6]<<8|e[7],this.height=e[8]<<24|e[9]<<16|e[10]<<8|e[11];let t=this.width*this.height,i=t*4,A=e.length,s=Math.max(i,A)+(Math.min(i,A)>>1)+4096;this._inst?this._mem.buffer.byteLengththis.keepSize&&(this._inst=this._d=this._mem=null)}};Qe.default=fe});var xe=M(se=>{"use strict";Object.defineProperty(se,"__esModule",{value:!0});se.LIMITS=void 0;se.LIMITS={CHUNK_SIZE:16384,PALETTE_SIZE:4096,MAX_WIDTH:16384,BYTES:"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AEHAiwIgADcDAEG4iwIgADcDAEGwiwIgADcDAEGoiwIgADcDAEGgiwIgADcDAEGYiwIgADcDAEGQiwIgADcDAEGIiwIgADcDAEGAiwIgADcDAEH4igIgADcDAEHwigIgADcDAEHoigIgADcDAEHgigIgADcDAEHYigIgADcDAEHQigIgADcDAEHIigIgADcDAEHAigIgADcDAEG4igIgADcDAEGwigIgADcDAEGoigIgADcDAEGgigIgADcDAEGYigIgADcDAEGQigIgADcDAEGIigIgADcDAEGAigIgADcDAEH4iQIgADcDAEHwiQIgADcDAEHoiQIgADcDAEHgiQIgADcDAEHYiQIgADcDAEHQiQIgADcDAEHIiQIgADcDAEHAiQIgADcDAEG4iQIgADcDAEGwiQIgADcDAEGoCCgCACIEQf8AakGAAW0hCAJAIARBgQFIDQBBASEBIAhBAiAIQQJKG0EBayICQQFxIQMgBEGBAk4EQCACQX5xIQIDQCABQQl0IgdBEHJBoIkCakGwiQJBgAT8CgAAIAdBsI0CakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsLIANFDQAgAUEJdEEQckGgiQJqQbCJAkGABPwKAAALAkAgBEEBSA0AIAhBASAIQQFKGyIDQQFxIQUCQCADQQFrIgdFBEBBACEBDAELIANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQbCJBmpBsIkCQYAE/AoAACAGQZAEckGwiQZqQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACwsgBQRAIAFBCXRBEHJBsIkGakGwiQJBgAT8CgAACyAEQQFIDQAgA0EBcSEFIAcEfyADQf7///8HcSECQQAhAQNAIAFBCXQiBkEQckHAiQpqQbCJAkGABPwKAAAgBkGQBHJBwIkKakGwiQJBgAT8CgAAIAFBAmohASACQQJrIgINAAsgAUEHdEEEcgVBBAshASAFBEAgAUECdEHAiQpqQbCJAkGABPwKAAALIARBAUgNACADQQFxIQUgBwR/IANB/v///wdxIQJBACEBA0AgAUEJdCIGQRByQdCJDmpBsIkCQYAE/AoAACAGQZAEckHQiQ5qQbCJAkGABPwKAAAgAUECaiEBIAJBAmsiAg0ACyABQQd0QQRyBUEECyEBIAUEQCABQQJ0QdCJ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"}});var je=M(b=>{"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.decodeAsync=b.decode=b.Decoder=b.DecoderAsync=void 0;var O=j(),Q=xe();function Mt(r){if(typeof Buffer<"u")return Buffer.from(r,"base64");let e=atob(r),t=new Uint8Array(e.length);for(let i=0;i1,this.modeHandler=e=>1}handle_band(e){return this.bandHandler(e)}mode_parsed(e){return this.modeHandler(e)}},vt={memoryLimit:2048*65536,sixelColor:O.DEFAULT_FOREGROUND,fillColor:O.DEFAULT_BACKGROUND,palette:O.PALETTE_VT340_COLOR,paletteLimit:Q.LIMITS.PALETTE_SIZE,truncate:!0};function Ze(r){let e=new Me,t={env:{handle_band:e.handle_band.bind(e),mode_parsed:e.mode_parsed.bind(e)}};return WebAssembly.instantiate(J||Xe,t).then(i=>(J=J||i.module,new P(r,i.instance||i,e)))}b.DecoderAsync=Ze;var P=class{constructor(e,t,i){if(this._PIXEL_OFFSET=Q.LIMITS.MAX_WIDTH+4,this._canvas=ne,this._bandWidths=[],this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0,this._opts=Object.assign({},vt,e),this._opts.paletteLimit>Q.LIMITS.PALETTE_SIZE)throw new Error(`DecoderOptions.paletteLimit must not exceed ${Q.LIMITS.PALETTE_SIZE}`);if(t)i.bandHandler=this._handle_band.bind(this),i.modeHandler=this._initCanvas.bind(this);else{let A=J||(J=new WebAssembly.Module(Xe));t=new WebAssembly.Instance(A,{env:{handle_band:this._handle_band.bind(this),mode_parsed:this._initCanvas.bind(this)}})}this._instance=t,this._wasm=this._instance.exports,this._chunk=new Uint8Array(this._wasm.memory.buffer,this._wasm.get_chunk_address(),Q.LIMITS.CHUNK_SIZE),this._states=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_state_address(),12),this._palette=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_palette_address(),Q.LIMITS.PALETTE_SIZE),this._palette.set(this._opts.palette),this._pSrc=new Uint32Array(this._wasm.memory.buffer,this._wasm.get_p0_address()),this._wasm.init(O.DEFAULT_FOREGROUND,0,this._opts.paletteLimit,0)}get _fillColor(){return this._states[0]}get _truncate(){return this._states[8]}get _rasterWidth(){return this._states[6]}get _rasterHeight(){return this._states[7]}get _width(){return this._states[2]?this._states[2]-4:0}get _height(){return this._states[3]}get _level(){return this._states[9]}get _mode(){return this._states[10]}get _paletteLimit(){return this._states[11]}_initCanvas(e){if(e===2){let t=this.width*this.height;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}this._maxWidth=this._width}else if(e===1)if(this._level===2){let t=Math.min(this._rasterWidth,Q.LIMITS.MAX_WIDTH)*this._rasterHeight;if(t>this._canvas.length){if(this._opts.memoryLimit&&t*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");this._canvas=new Uint32Array(t)}}else this._canvas.length<65536&&(this._canvas=new Uint32Array(65536));return 0}_realloc(e,t){let i=e+t;if(i>this._canvas.length){if(this._opts.memoryLimit&&i*4>this._opts.memoryLimit)throw this.release(),new Error("image exceeds memory limit");let A=new Uint32Array(Math.ceil(i/65536)*65536);A.set(this._canvas),this._canvas=A}}_handle_band(e){let t=this._PIXEL_OFFSET,i=this._lastOffset;if(this._mode===2){let A=this.height-this._currentHeight,s=0;for(;s<6&&A>0;)this._canvas.set(this._pSrc.subarray(t*s,t*s+e),i+e*s),s++,A--;this._lastOffset+=e*s,this._currentHeight+=s}else if(this._mode===1){this._realloc(i,e*6),this._maxWidth=Math.max(this._maxWidth,e),this._minWidth=Math.min(this._minWidth,e);for(let A=0;A<6;++A)this._canvas.set(this._pSrc.subarray(t*A,t*A+e),i+e*A);this._bandWidths.push(e),this._lastOffset+=e*6,this._currentHeight+=6}return 0}get width(){return this._mode!==1?this._width:Math.max(this._maxWidth,this._wasm.current_width())}get height(){return this._mode!==1?this._height:this._wasm.current_width()?this._bandWidths.length*6+this._wasm.current_height():this._bandWidths.length*6}get palette(){return this._palette.subarray(0,this._paletteLimit)}get memoryUsage(){return this._canvas.byteLength+this._wasm.memory.buffer.byteLength+8*this._bandWidths.length}get properties(){return{width:this.width,height:this.height,mode:this._mode,level:this._level,truncate:!!this._truncate,paletteLimit:this._paletteLimit,fillColor:this._fillColor,memUsage:this.memoryUsage,rasterAttributes:{numerator:this._states[4],denominator:this._states[5],width:this._rasterWidth,height:this._rasterHeight}}}init(e=this._opts.fillColor,t=this._opts.palette,i=this._opts.paletteLimit,A=this._opts.truncate){this._wasm.init(this._opts.sixelColor,e,i,A?1:0),t&&this._palette.set(t.subarray(0,Q.LIMITS.PALETTE_SIZE)),this._bandWidths.length=0,this._maxWidth=0,this._minWidth=Q.LIMITS.MAX_WIDTH,this._lastOffset=0,this._currentHeight=0}decode(e,t=0,i=e.length){let A=t;for(;A0){let i=this._PIXEL_OFFSET,A=this._lastOffset,s=0;for(;s<6&&t>0;)this._canvas.set(this._pSrc.subarray(i*s,i*s+e),A+e*s),s++,t--;t&&this._canvas.fill(this._fillColor,A+e*s)}return this._canvas.subarray(0,this.width*this.height)}if(this._mode===1){if(this._minWidth===this._maxWidth){let s=!1;if(e)if(e!==this._minWidth)s=!0;else{let n=this._PIXEL_OFFSET,a=this._lastOffset;this._realloc(a,e*6);for(let o=0;o<6;++o)this._canvas.set(this._pSrc.subarray(n*o,n*o+e),a+e*o)}if(!s)return this._canvas.subarray(0,this.width*this.height)}let t=new Uint32Array(this.width*this.height);t.fill(this._fillColor);let i=0,A=0;for(let s=0;s{if(this._disposed)return H(()=>{});let A={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(A);let s=H(()=>{let n=this._listeners.indexOf(A);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return i&&(Array.isArray(i)?i.push(s):i.add(s)),s},this._event)}fire(e){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,e);return}let t=this._listeners;for(let i=0,A=t.length;i{function r(s,n){return s(a=>n.fire(a))}A.forward=r;function e(s,n){return(a,o,h)=>s(d=>a.call(o,n(d)),void 0,h)}A.map=e;function t(...s){return(n,a,o)=>{let h=new N;for(let d of s)h.add(d(g=>n.call(a,g)));return o&&(Array.isArray(o)?o.push(h):o.add(h)),h}}A.any=t;function i(s,n,a){return n(a),s(o=>n(o))}A.runAndSubscribe=i})(lt||={});var _e=x(j());var D=class r extends k{constructor(t){super();this._terminal=t;this._layers=new Map;this._optionsRefresh=this._register(new X);this._oldOpen=this._terminal._core.open,this._terminal._core.open=i=>{this._oldOpen?.call(this._terminal._core,i),this._open()},this._terminal._core.screenElement&&this._open(),this._optionsRefresh.value=this._terminal._core.optionsService.onOptionChange(i=>{i==="fontSize"&&(this.rescaleCanvas(),this._renderService?.refreshRows(0,this._terminal.rows))}),this._register(H(()=>{this.removeLayerFromDom(),this.removeLayerFromDom("bottom"),this._terminal._core&&this._oldOpen&&(this._terminal._core.open=this._oldOpen,this._oldOpen=void 0),this._renderService&&this._oldSetRenderer&&(this._renderService.setRenderer=this._oldSetRenderer,this._oldSetRenderer=void 0),this._renderService=void 0,this._layers.clear(),this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0}))}get canvas(){return this._layers.get("top")?.canvas}static createCanvas(t,i,A){let s=(t??document).createElement("canvas");return s.width=i|0,s.height=A|0,s}static createImageData(t,i,A,s){if(typeof ImageData!="function"){let n=t.createImageData(i,A);return s&&n.data.set(new Uint8ClampedArray(s,0,i*A*4)),n}return s?new ImageData(new Uint8ClampedArray(s,0,i*A*4),i,A):new ImageData(i,A)}static createImageBitmap(t){return typeof createImageBitmap!="function"?Promise.resolve(void 0):createImageBitmap(t)}showPlaceholder(t){t?!this._placeholder&&this.cellSize.height!==-1&&this._createPlaceHolder(Math.max(this.cellSize.height+1,24)):(this._placeholderBitmap?.close(),this._placeholderBitmap=void 0,this._placeholder=void 0),this._renderService?.refreshRows(0,this._terminal.rows)}get dimensions(){return this._terminal.dimensions}get cellSize(){return{width:this.dimensions?.css.cell.width||-1,height:this.dimensions?.css.cell.height||-1}}clearLines(t,i,A){let s=t*(this.dimensions?.css.cell.height||0),n=this.dimensions?.css.canvas.width||0,a=(i+1-t)*(this.dimensions?.css.cell.height||0);(!A||A==="top")&&this._layers.get("top")?.clearRect(0,s,n,a),(!A||A==="bottom")&&this._layers.get("bottom")?.clearRect(0,s,n,a)}clearAll(t){if(!t||t==="top"){let i=this._layers.get("top");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}if(!t||t==="bottom"){let i=this._layers.get("bottom");i?.clearRect(0,0,i.canvas.width,i.canvas.height)}}draw(t,i,A,s,n=1){let a=this._layers.get(t.layer);if(!a)return;let{width:o,height:h}=this.cellSize;if(o===-1||h===-1)return;this._rescaleImage(t,o,h);let d=t.actual,{width:g,height:I}=t.actualCellSize,l=Math.ceil(d.width/g),c=i%l*g,u=Math.floor(i/l)*I,E=A*o,C=s*h,p=n*g+c>d.width?d.width-c:n*g,B=u+I>d.height?d.height-u:I;a.drawImage(d,Math.floor(c),Math.floor(u),Math.ceil(p),Math.ceil(B),Math.floor(E),Math.floor(C),Math.ceil(p*o/g),Math.ceil(B*h/I))}extractTile(t,i){let{width:A,height:s}=this.cellSize;if(A===-1||s===-1)return;this._rescaleImage(t,A,s);let n=t.actual,{width:a,height:o}=t.actualCellSize,h=Math.ceil(n.width/a),d=i%h*a,g=Math.floor(i/h)*o,I=a+d>n.width?n.width-d:a,l=g+o>n.height?n.height-g:o,c=r.createCanvas(this.document,Math.ceil(I*A/a),Math.ceil(l*s/o)),u=c.getContext("2d");if(u)return u.drawImage(n,Math.floor(d),Math.floor(g),Math.floor(I),Math.floor(l),0,0,c.width,c.height),c}drawPlaceholder(t,i,A=1){let s=this._layers.get("top");if(s){let{width:n,height:a}=this.cellSize;if(n===-1||a===-1||(this._placeholder?a>=this._placeholder.height&&this._createPlaceHolder(a+1):this._createPlaceHolder(Math.max(a+1,24)),!this._placeholder))return;s.drawImage(this._placeholderBitmap??this._placeholder,t*n,i*a%2?0:1,n*A,a,t*n,i*a,n*A,a)}}rescaleCanvas(){let t=this.dimensions?.css.canvas.width||0,i=this.dimensions?.css.canvas.height||0;for(let A of this._layers.values())(A.canvas.width!==t||A.canvas.height!==i)&&(A.canvas.width=t,A.canvas.height=i)}_rescaleImage(t,i,A){if(i===t.actualCellSize.width&&A===t.actualCellSize.height)return;let{width:s,height:n}=t.origCellSize;if(i===s&&A===n){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let a=Math.ceil(t.orig.width*i/s),o=Math.ceil(t.orig.height*A/n);if(a*o>t.orig.width*t.orig.height){t.actual=t.orig,t.actualCellSize.width=s,t.actualCellSize.height=n;return}let h=r.createCanvas(this.document,a,o),d=h.getContext("2d");d&&(d.drawImage(t.orig,0,0,h.width,h.height),t.actual=h,t.actualCellSize.width=i,t.actualCellSize.height=A)}_open(){this._renderService=this._terminal._core._renderService,this._oldSetRenderer=this._renderService.setRenderer.bind(this._renderService),this._renderService.setRenderer=t=>{for(let i of[...this._layers.keys()])this.removeLayerFromDom(i);this._oldSetRenderer?.call(this._renderService,t)}}insertLayerToDom(t="top"){if(!this.document||!this._terminal._core.screenElement){console.warn("image addon: cannot insert output canvas to DOM, missing document or screenElement");return}if(this._layers.has(t))return;let i=r.createCanvas(this.document,this.dimensions?.css.canvas.width||0,this.dimensions?.css.canvas.height||0);i.classList.add(`xterm-image-layer-${t}`);let A=this._terminal._core.screenElement;A.style.isolation="isolate",t==="bottom"?(i.style.zIndex="-1",A.insertBefore(i,A.firstChild)):(i.style.zIndex="0",A.appendChild(i));let s=i.getContext("2d",{alpha:!0});if(!s){i.remove();return}this._layers.set(t,s),this.clearAll(t)}removeLayerFromDom(t="top"){let i=this._layers.get(t);i&&(i.canvas.remove(),this._layers.delete(t))}hasLayer(t){return this._layers.has(t)}_createPlaceHolder(t=24){this._placeholderBitmap?.close(),this._placeholderBitmap=void 0;let i=32,A=r.createCanvas(this.document,i,t),s=A.getContext("2d",{alpha:!1});if(!s)return;let n=r.createImageData(s,i,t),a=new Uint32Array(n.data.buffer),o=(0,_e.toRGBA8888)(0,0,0),h=(0,_e.toRGBA8888)(255,255,255);a.fill(o);for(let l=0;l{this._placeholder!==I?l?.close():this._placeholderBitmap=l}).catch(()=>{})}get document(){return this._terminal._core._coreBrowserService?.window.document}};var w={width:7,height:14},G=class r{constructor(e=0,t=0,i=-1,A=-1){this.imageId=i;this.tileId=A;this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}get urlId(){return this._urlId}set urlId(e){this._urlId=e}clone(){return new r(this._ext,this._urlId,this.imageId,this.tileId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0&&this.imageId===-1}},F=new G,V=class{constructor(e,t,i){this._terminal=e;this._renderer=t;this._opts=i;this._images=new Map;this._lastId=0;this._lowestId=0;this._fullyCleared=!1;this._needsFullClear=!1;this._pixelLimit=25e5;try{this.setLimit(this._opts.storageLimit)}catch(A){A instanceof Error&&console.error(A.message),console.warn(`storageLimit is set to ${this.getLimit()} MB`)}this._viewportMetrics={cols:this._terminal.cols,rows:this._terminal.rows}}dispose(){this.reset()}reset(){for(let e of this._images.values())e.marker?.dispose();this._images.clear(),this._renderer.clearAll()}getLimit(){return this._pixelLimit*4/1e6}setLimit(e){if(e<.5||e>1e3)throw RangeError("invalid storageLimit, should be at least 0.5 MB and not exceed 1G");this._pixelLimit=e/4*1e6>>>0,this._evictOldest(0)}getUsage(){return this._getStoredPixels()*4/1e6}_getStoredPixels(){let e=0;for(let t of this._images.values())t.orig&&(e+=t.orig.width*t.orig.height,t.actual&&t.actual!==t.orig&&(e+=t.actual.width*t.actual.height));return e}_delImg(e){let t=this._images.get(e);t&&(this._images.delete(e),window.ImageBitmap&&t.orig instanceof ImageBitmap&&t.orig.close(),this.onImageDeleted?.(e))}wipeAlternate(){let e=[];for(let[t,i]of this._images.entries())i.bufferType==="alternate"&&(i.marker?.dispose(),e.push(t));for(let t of e)this._delImg(t);this._needsFullClear=!0,this._fullyCleared=!1}deleteImage(e){let t=this._images.get(e);t&&(t.marker?.dispose(),this._delImg(e))}addImage(e,t){this._evictOldest(e.width*e.height);let i=this._renderer.cellSize;(i.width===-1||i.height===-1)&&(i=w);let A=Math.ceil(e.width/i.width),s=Math.ceil(e.height/i.height),n=++this._lastId,a=this._terminal._core.buffer,o=this._terminal.cols,h=this._terminal.rows,d=a.x,g=a.y,I=d,l=0;t.scrolling||(a.x=0,a.y=0,I=0),this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y);for(let C=0;C=o);++B)this._writeToCell(p,I+B,n,C*A+B),l++;if(t.scrolling)C=h)break;a.x=I}this._terminal._core._inputHandler._dirtyRowTracker.markDirty(a.y),t.scrolling?t.cursorPos==="iip"?a.x=Math.min(I+A,o):a.x=I:(a.x=d,a.y=g);let c=[];for(let[C,p]of this._images.entries())p.tileCount<1&&(p.marker?.dispose(),c.push(C));for(let C of c)this._delImg(C);let u=this._terminal.registerMarker(0);u?.onDispose(()=>{this._images.get(n)&&this._delImg(n)}),this._terminal.buffer.active.type==="alternate"&&this._evictOnAlternate();let E={orig:e,origCellSize:i,actual:e,actualCellSize:{...i},marker:u||void 0,tileCount:l,bufferType:this._terminal.buffer.active.type,layer:t.layer,zIndex:t.zIndex};return this._images.set(n,E),this.onImageAdded?.(),n}render(e){let t=!1,i=!1;for(let d of this._images.values())if(d.layer==="bottom"?i=!0:t=!0,t&&i)break;if(t&&!this._renderer.hasLayer("top")&&(this._renderer.insertLayerToDom("top"),!this._renderer.hasLayer("top")))return;if(i&&!this._renderer.hasLayer("bottom")&&this._renderer.insertLayerToDom("bottom"),this._renderer.rescaleCanvas(),!this._images.size){this._fullyCleared||(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1),this._renderer.hasLayer("top")&&this._renderer.removeLayerFromDom("top"),this._renderer.hasLayer("bottom")&&this._renderer.removeLayerFromDom("bottom");return}!t&&this._renderer.hasLayer("top")&&(this._renderer.clearAll("top"),this._renderer.removeLayerFromDom("top")),!i&&this._renderer.hasLayer("bottom")&&(this._renderer.clearAll("bottom"),this._renderer.removeLayerFromDom("bottom")),this._needsFullClear&&(this._renderer.clearAll(),this._fullyCleared=!0,this._needsFullClear=!1);let{start:A,end:s}=e,n=this._terminal._core.buffer,a=this._terminal._core.cols;this._renderer.clearLines(A,s);let o=[],h=[];for(let d=A;d<=s;++d){let g=n.lines.get(d+n.ydisp);if(!g)return;for(let I=0;Id.imgSpec.zIndex-g.imgSpec.zIndex);for(let d of h)this._renderer.drawPlaceholder(d.col,d.row,d.count);for(let d of o)this._renderer.draw(d.imgSpec,d.tileId,d.col,d.row,d.count)}viewportResize(e){if(!this._images.size){this._viewportMetrics=e;return}if(this._viewportMetrics.cols>=e.cols){this._viewportMetrics=e;return}let t=this._terminal._core.buffer,i=t.lines.length,A=this._viewportMetrics.cols-1;for(let s=0;s=d)continue;let g=!1;for(let c=A+1;c>e.cols;++c)if(n._data[c*3+0]&4194303){g=!0;break}if(g)continue;let I=Math.min(e.cols,d-a.tileId%d+A),l=a.tileId;for(let c=A+1;c57)throw new Error("illegal char");e=e*10+r[t]-48}return e}function Oe(r){let e=$(r);if(!e.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/))throw new Error("illegal size");return e}function Tt(r){if(typeof Buffer<"u")return Buffer.from($(r),"base64").toString();let e=atob($(r)),t=new Uint8Array(e.length);for(let i=0;i14)return-1;for(let a=t;a=Se)return this._a();n[s++]=o}break;case 58:return A===3&&!this._storeValue(s)?this._a():(this.state=4,a+1);default:if(s>=Se)return this._a();n[s++]=o}}return this.state=A,this._position=s,-2}_a(){return this.fields.type=0,this.state=1,-1}_storeKey(e){let t=$(this._buffer.subarray(0,e));return t?(this._key=t,this.fields[t]=null,!0):!1}_storeValue(e){if(this._key){try{let t=this._buffer.slice(0,e);this.fields[this._key]=Je[this._key]?Je[this._key](t):t}catch{return!1}return!0}return!1}};var L={mime:"unsupported",width:0,height:0};function te(r){if(r.length<32)return L;let e=new Uint32Array(r.buffer,r.byteOffset,8);if(e[0]===1196314761&&e[1]===169478669&&e[3]===1380206665)return{mime:"image/png",width:r[16]<<24|r[17]<<16|r[18]<<8|r[19],height:r[20]<<24|r[21]<<16|r[22]<<8|r[23]};if(r[0]===255&&r[1]===216&&r[2]===255){let[t,i]=xt(r);return{mime:"image/jpeg",width:t,height:i}}if(e[0]===944130375&&(r[4]===55||r[4]===57)&&r[5]===97)return{mime:"image/gif",width:r[7]<<8|r[6],height:r[9]<<8|r[8]};if(e[0]===1718185841)return{mime:"image/qoi",width:r[4]<<24|r[5]<<16|r[6]<<8|r[7],height:r[8]<<24|r[9]<<16|r[10]<<8|r[11]};if(e[0]===1179011410&&e[2]===1346520407&&(e[3]&16777215)===3690582){switch(r[15]){case 88:return{mime:"image/webp",width:(r[24]|r[25]<<8|r[26]<<16)+1,height:(r[27]|r[28]<<8|r[29]<<16)+1};case 76:if(r[20]!==47)return L;let t=r[21]|r[22]<<8|r[23]<<16|r[24]<<24;return{mime:"image/webp",width:(t&16383)+1,height:(t>>>14&16383)+1};case 32:return r[23]!==157||r[24]!==1||r[25]!==42?L:{mime:"image/webp",width:(r[26]|r[27]<<8)&16383,height:(r[28]|r[29]<<8)&16383}}return L}if(e[1]===1887007846&&(e[2]===1718187617||e[2]===1936291425)){let t=-1,i=Math.min(r.length-16,1024);for(let A=8;A0&&s>0)return{mime:"image/avif",width:A,height:s}}return L}return L}function xt(r){let e=r.length,t=4,i=r[t]<<8|r[t+1];for(;;){if(t+=i,t>=e)return[0,0];if(r[t]!==255)return[0,0];if(r[t+1]===192||r[t+1]===194)return t+80){if(this._hp.fields.type===1){if(this._isMultipart&&(this._isMultipart=!1,this._abortMulti=!1,this._dec.release()),this._header=Object.assign({},Le,this._hp.fields),!this._header.inline){this._aborted=!0;return}if(!this._initDecoder()){this._aborted=!0;return}}else if(this._abortMulti){this._aborted=!0;return}this._dec.put(e.subarray(A,i))!==0&&(this._dec.release(),this._aborted=!0,this._isMultipart&&(this._abortMulti=!0))}}}end(e){if(this._aborted||this._hp.state!==4&&this._hp.end())return!0;let t=this._hp.fields.type;if(t===3)return!0;if(t===5){let h=w.width,d=w.height;this._renderer.dimensions&&(h=this._renderer.dimensions.css.canvas.width/this._coreTerminal.cols,d=this._renderer.dimensions.css.canvas.height/this._coreTerminal.rows);let g=this._coreTerminal._core._coreBrowserService?.dpr??1,I=`\x1B]1337;ReportCellSize=${d.toFixed(3)};${h.toFixed(3)};${g.toFixed(3)}\x1B\\`;return this._coreTerminal.input(I,!1),!0}if(t===2)return this._header=Object.assign({},Le,this._hp.fields),this._isMultipart=!0,this._abortMulti=!1,this._dec.release(),this._initDecoder()||(this._abortMulti=!0),!0;if(t===4&&(!this._isMultipart||(this._isMultipart=!1,this._abortMulti||this._header.type!==2)))return!0;let i=0,A=0,s,n=L;if((s=e)&&((s=!this._dec.end())?(n=te(this._dec.data8),(s=n.mime!=="unsupported")?(i=n.width,A=n.height,(s=i&&A&&i*Ao!==this._generation?(h.close(),!0):(this._storage.addImage(h),!0)).catch(h=>(console.warn(`IIP: decoding error ${n.mime} ${n.width}x${n.height}`,h),!0))}_initDecoder(){try{return this._dec.init(),!0}catch(e){return console.warn("IIP: could not allocate decoder",e),this._dec.release(),!1}}_resize(e,t){let i=this._renderer.dimensions?.css.cell.width||w.width,A=this._renderer.dimensions?.css.cell.height||w.height,s=this._renderer.dimensions?.css.canvas.width||i*this._coreTerminal.cols,n=this._renderer.dimensions?.css.canvas.height||A*this._coreTerminal.rows,a=this._dim(this._header.width,s,i),o=this._dim(this._header.height,n,A);if(!a&&!o){let h=s/e,d=(n-A)/t,g=Math.min(h,d);return g<1?[e*g,t*g]:[e,t]}return a?this._header.preserveAspectRatio||!a||!o?[a,t*a/e]:[a,o]:[e*o/t,o]}_dim(e,t,i){return e==="auto"?0:e.endsWith("%")?parseInt(e.slice(0,-1),10)*t/100:e.endsWith("px")?parseInt(e.slice(0,-2),10):parseInt(e,10)*i}};var We=x(Be());function Te(r){let e={},t=r.split(",");for(let i of t){let A=i.indexOf("=");if(A===-1)continue;let s=i.substring(0,A),n=i.substring(A+1);if(s==="a"){e.action=n;continue}if(s==="o"){e.compression=n;continue}if(s==="t"){e.transmission=n;continue}if(s==="d"){e.deleteSelector=n;continue}let a=parseInt(n,10);switch(s){case"f":e.format=a;break;case"i":e.id=a;break;case"I":e.imageNumber=a;break;case"s":e.width=a;break;case"v":e.height=a;break;case"x":e.x=a;break;case"y":e.y=a;break;case"w":e.sourceWidth=a;break;case"h":e.sourceHeight=a;break;case"X":e.xOffset=a;break;case"Y":e.yOffset=a;break;case"c":e.columns=a;break;case"r":e.rows=a;break;case"m":e.more=a;break;case"q":e.quiet=a;break;case"C":e.cursorMovement=a;break;case"z":e.zIndex=a;break;case"p":e.placementId=a;break}}return e}var ze=0,Ae=class{constructor(e,t,i,A){this._opts=e;this._renderer=t;this._kittyStorage=i;this._coreTerminal=A;this._aborted=!1;this._generation=0;this._decodeError=!1;this._activeDecoder=null;this._inControlData=!0;this._controlData=new Uint32Array(512);this._controlLength=0;this._encodedSizeLimit=0;this._totalEncodedSize=0;this._parsedCommand=null;this._pendingTransmissions=new Map;this._maxEncodedBytes=Math.ceil(this._opts.kittySizeLimit*4/3),this._initialEncodedBytes=Math.min(4194304,this._maxEncodedBytes)}reset(){this._generation++,this._cleanupAllPending(),this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),this._kittyStorage.reset()}dispose(){this.reset()}_removePendingEntry(e){this._pendingTransmissions.delete(e),this._lastPendingKey===e&&(this._lastPendingKey=void 0)}_cleanupAllPending(){for(let e of this._pendingTransmissions.values())e.decoder.release();this._pendingTransmissions.clear(),this._lastPendingKey=void 0}start(){this._aborted=!1,this._decodeError=!1,this._inControlData=!0,this._controlLength=0,this._parsedCommand=null,this._encodedSizeLimit=this._maxEncodedBytes,this._totalEncodedSize=0,this._activeDecoder=null}put(e,t,i){if(!this._aborted)if(!this._inControlData)this._streamPayload(e,t,i);else{let A=i;for(let n=t;n512){this._aborted=!0;return}if(this._controlData.set(e.subarray(t,A),this._controlLength),this._controlLength+=s,!this._inControlData){if(this._parsedCommand=Te(this._parseControlDataString()),this._parsedCommand.id!==void 0&&this._parsedCommand.imageNumber!==void 0){this._sendResponse(this._parsedCommand.id,"EINVAL:cannot specify both i and I keys",this._parsedCommand.quiet??0),this._aborted=!0;return}if(this._parsedCommand.action==="d")return;let n=A+1;nthis._encodedSizeLimit){let o=this._activeDecoder??s?.decoder;o&&o.release(),this._activeDecoder=null,s&&this._removePendingEntry(A),this._aborted=!0;return}if(!this._decodeError){if(s?.decoder&&!this._activeDecoder&&(this._activeDecoder=s.decoder),!this._activeDecoder){let o=this._maxEncodedBytes+131072;if(o>this._opts.storageLimit*1e6){this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:pending image budget exceeded",this._parsedCommand.quiet??0);return}let h=Math.max(1,Math.floor(this._opts.storageLimit*1e6/o));for(;this._pendingTransmissions.size>=h;){let g=this._pendingTransmissions.entries().next().value;if(!g)break;g[1].decoder.release(),this._removePendingEntry(g[0]),g[1].cmd.id!==void 0&&this._sendResponse(g[1].cmd.id,"ENOMEM:pending image budget exceeded",g[1].cmd.quiet??0)}let d=new We.default(4194304,this._maxEncodedBytes,this._initialEncodedBytes);try{d.init()}catch(g){console.warn("KITTY: could not allocate decoder",g),this._aborted=!0,this._parsedCommand?.id!==void 0&&this._sendResponse(this._parsedCommand.id,"ENOMEM:could not allocate decoder",this._parsedCommand.quiet??0);return}this._activeDecoder=d}this._activeDecoder.put(e.subarray(t,i))!==ze&&(this._activeDecoder.release(),this._activeDecoder=null,this._decodeError=!0,s&&this._removePendingEntry(A))}}end(e){if(this._aborted||!e)return this._activeDecoder&&(this._activeDecoder.release(),this._activeDecoder=null),!0;if(this._inControlData)return this._handleNoPayloadCommand();let t=this._parsedCommand;if(t.action==="d")return this._handleDelete(t);let i=t.id??this._lastPendingKey??0,A=t.more===1,s=this._pendingTransmissions.get(i);if(A)return this._activeDecoder&&(s?(s.totalEncodedSize+=this._totalEncodedSize,s.decodeError=s.decodeError||this._decodeError):this._pendingTransmissions.set(i,{cmd:{...t},decoder:this._activeDecoder,totalEncodedSize:this._totalEncodedSize,decodeError:this._decodeError}),this._lastPendingKey=i,this._activeDecoder=null),!0;s&&(this._lastPendingKey=void 0);let n=this._decodeError,a=t,o=this._activeDecoder;s&&(a=s.cmd,o=s.decoder,n=n||s.decodeError,this._pendingTransmissions.delete(i));let h=new Uint8Array(0);o&&(o.end()!==ze&&(n=!0),h=o.data8),this._activeDecoder=null;let d=this._handleCommandWithBytesAndCmd(a,h,n);return o&&o.release(),d}_parseControlDataString(){let e="";for(let t=0;t0&&this._sendResponse(e.id,"OK",e.quiet??0)),s}case"T":return this._handleTransmitDisplay(e,t,i);case"q":return this._handleQuery(e,t,i);case"p":return this._handlePlacement(e);default:return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported action",e.quiet??0),!0}}_handlePlacement(e){if(e.id===void 0)return!0;let t=e.id,i=this._kittyStorage.getImage(t);return i?this._displayImage(i,e).then(s=>(this._sendResponse(t,s?"OK":"EINVAL:image rendering failed",e.quiet??0,e.placementId),!0)):(this._sendResponse(t,"ENOENT:image not found",e.quiet??0,e.placementId),!0)}_handleTransmit(e,t,i){return(e.transmission??"d")!=="d"?(e.id!==void 0&&this._sendResponse(e.id,"EINVAL:unsupported transmission medium",e.quiet??0),!0):(i||t.length===0||this._kittyStorage.storeImage(e.id,{data:new Blob([t]),width:e.width??0,height:e.height??0,format:e.format??32,compression:e.compression??""}),!0)}_handleTransmitDisplay(e,t,i){if(i)return e.id!==void 0&&this._sendResponse(e.id,"EINVAL:invalid base64 data",e.quiet??0),!0;this._handleTransmit(e,t,i);let A=e.id??this._kittyStorage.lastImageId,s=this._kittyStorage.getImage(A);if(s){let n=this._displayImage(s,e);return e.id!==void 0?n.then(a=>(this._sendResponse(A,a?"OK":"EINVAL:image rendering failed",e.quiet??0),!0)):n.then(()=>!0)}return!0}_handleQuery(e,t,i){let A=e.id??0,s=e.quiet??0;if((e.transmission??"d")!=="d")return this._sendResponse(A,"EINVAL:unsupported transmission medium",s),!0;if(i)return this._sendResponse(A,"EINVAL:invalid base64 data",s),!0;if(t.length===0)return this._sendResponse(A,"OK",s),!0;let a=e.format??32;if(a===100)this._sendResponse(A,"OK",s);else{let o=e.width??0,h=e.height??0;if(!o||!h)return this._sendResponse(A,"EINVAL:width and height required for raw pixel data",s),!0;let d=a===32?4:3,g=o*h*d;if(t.length=1||!s&&i>=2)return;let n=A?`,p=${A}`:"",a=`\x1B_Gi=${e}${n};${t}\x1B\\`;this._coreTerminal._core.coreService.triggerDataEvent(a)}_displayImage(e,t){return this._decodeAndDisplay(e,t).then(()=>!0).catch(()=>!1)}async _decodeAndDisplay(e,t){let i=this._generation,A=await this._createBitmap(e);try{if(i!==this._generation)throw new Error("image decode canceled");let s=Math.max(0,t.x??0),n=Math.max(0,t.y??0),a=t.sourceWidth||A.width-s,o=t.sourceHeight||A.height-n,h=Math.max(0,A.width-s),d=Math.max(0,A.height-n),g=Math.max(0,Math.min(a,h)),I=Math.max(0,Math.min(o,d));if(g===0||I===0)throw new Error("invalid source rectangle");if(s!==0||n!==0||g!==A.width||I!==A.height){let T=await createImageBitmap(A,s,n,g,I);A.close(),A=T}let l=this._renderer.dimensions?.css.cell.width||w.width,c=this._renderer.dimensions?.css.cell.height||w.height,u,E;t.columns!==void 0&&t.rows!==void 0?(u=t.columns,E=t.rows):t.columns!==void 0?(u=t.columns,E=Math.max(1,Math.ceil(A.height/A.width*(u*l)/c))):t.rows!==void 0?(E=t.rows,u=Math.max(1,Math.ceil(A.width/A.height*(E*c)/l))):(u=Math.ceil(A.width/l),E=Math.ceil(A.height/c));let C=A.width,p=A.height;if((t.columns!==void 0||t.rows!==void 0)&&(C=Math.round(u*l),p=Math.round(E*c)),C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");let B=this._coreTerminal._core.buffer,Y=B.x,q=B.y,z=B.ybase,At=t.zIndex!==void 0&&t.zIndex<0?"bottom":"top";if(C!==A.width||p!==A.height){let T=await createImageBitmap(A,{resizeWidth:C,resizeHeight:p});A.close(),A=T}let Ie=Math.min(Math.max(0,t.xOffset??0),l-1),le=Math.min(Math.max(0,t.yOffset??0),c-1);if(Ie!==0||le!==0){let T=t.columns!==void 0?Math.round(u*l):A.width+Ie,st=t.rows!==void 0?Math.round(E*c):A.height+le,W=D.createCanvas(window.document,T,st),ke=W.getContext("2d");if(!ke)throw new Error("Failed to create offset canvas context");ke.drawImage(A,Ie,le);let nt=await createImageBitmap(W);if(W.width=W.height=0,A.close(),A=nt,C=A.width,p=A.height,C*p>this._opts.pixelLimit)throw new Error("image exceeds pixel limit");t.columns===void 0&&(u=Math.ceil(A.width/l)),t.rows===void 0&&(E=Math.ceil(A.height/c))}if(i!==this._generation)throw new Error("image decode canceled");let rt=t.zIndex??0;if(this._kittyStorage.addImage(e.id,A,!0,At,rt),A=void 0,t.cursorMovement===1){let T=B.ybase-z;B.x=Y,B.y=Math.max(q-T,0)}else B.x=Math.min(Y+u,this._coreTerminal.cols)}catch(s){throw A?.close(),s}}async _createBitmap(e){let t=new Uint8Array(await e.data.arrayBuffer());if(e.compression==="z"&&(t=await this._decompressZlib(t)),e.format===100){let E=te(t);if(E.mime!=="image/png"||!(E.width>0)||!(E.height>0)||E.width*E.height>this._opts.pixelLimit)throw new RangeError("PNG exceeds pixel limit or has invalid dimensions");let C=new Blob([t],{type:"image/png"});if(!window.createImageBitmap){let p=URL.createObjectURL(C),B=new Image;return new Promise((Y,q)=>{B.addEventListener("load",()=>{URL.revokeObjectURL(p);let z=D.createCanvas(window.document,B.width,B.height);z.getContext("2d")?.drawImage(B,0,0),createImageBitmap(z).then(Y).catch(q)}),B.addEventListener("error",()=>{URL.revokeObjectURL(p),q(new Error("Failed to load image"))}),B.src=p})}return createImageBitmap(C)}let i=e.width,A=e.height;if(!i||!A)throw new Error("Width and height required for raw pixel data");let s=e.format===32?4:3,n=i*A*s;if(t.length>>24|p<<8,d[l++]=4278190080|p>>>16|B<<16,d[l++]=4278190080|B>>>8}let c=g*3,u=g*4;for(let E=g;E=e.length){g.close();return}let I=Math.min(A+4096,e.length);g.enqueue(new Uint8Array(e.subarray(A,I))),A=I}}).pipeThrough(new DecompressionStream(t)).getReader(),a=[],o=0;try{for(;;){let{done:g,value:I}=await n.read();if(g)break;if(o+=I.byteLength,o>i)throw await n.cancel().catch(()=>{}),new RangeError("decompressed image exceeds byte limit");a.push(I)}}finally{n.releaseLock()}let h=new Uint8Array(o),d=0;for(let g of a)h.set(g,d),d+=g.length;return h}get images(){return this._kittyStorage.images}get _kittyIdToStorageId(){return this._kittyStorage.kittyIdToStorageId}get pendingTransmissions(){return this._pendingTransmissions}};var U=class U{constructor(e){this._storage=e;this._nextImageId=1;this._images=new Map;this._kittyIdToStorageId=new Map;this._storageIdToKittyId=new Map;this._handleStorageImageDeleted=e=>{let t=this._storageIdToKittyId.get(e);t!==void 0&&(this._kittyIdToStorageId.delete(t),this._storageIdToKittyId.delete(e),this._images.delete(t))};this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"iip"};this._previousOnImageDeleted=this._storage.onImageDeleted,this._wrappedOnImageDeleted=t=>{this._previousOnImageDeleted?.(t),this._handleStorageImageDeleted(t)},this._storage.onImageDeleted=this._wrappedOnImageDeleted}reset(){this._nextImageId=1,this._images.clear(),this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}dispose(){this.reset(),this._storage.onImageDeleted===this._wrappedOnImageDeleted&&(this._storage.onImageDeleted=this._previousOnImageDeleted)}storeImage(e,t){let i=e??this._nextImageId++,A=this._kittyIdToStorageId.get(i);A!==void 0&&(this._storage.deleteImage(A),this._kittyIdToStorageId.delete(i),this._storageIdToKittyId.delete(A)),!this._images.has(i)&&this._images.size>=U._maxStoredImages&&this._evictUndisplayedImages();let s=this._storage.getLimit()*1e6;this._images.delete(i);let n=0;for(let a of this._images.values())n+=a.data.size;for(let a of[!1,!0])for(let[o,h]of this._images){if(n+t.data.size<=s)break;this._kittyIdToStorageId.has(o)===a&&(n-=h.data.size,this.deleteById(o))}return this._images.set(i,{...t,id:i}),i}addImage(e,t,i,A,s){let n=this._kittyIdToStorageId.get(e);n!==void 0&&this._storageIdToKittyId.delete(n),this._addImageOpts.scrolling=i,this._addImageOpts.layer=A,this._addImageOpts.zIndex=s;let a=this._storage.addImage(t,this._addImageOpts);this._kittyIdToStorageId.set(e,a),this._storageIdToKittyId.set(a,e)}getImage(e){return this._images.get(e)}deleteById(e){this._images.delete(e);let t=this._kittyIdToStorageId.get(e);t!==void 0&&(this._storage.deleteImage(t),this._kittyIdToStorageId.delete(e),this._storageIdToKittyId.delete(t))}deleteAll(){this._images.clear();for(let e of this._kittyIdToStorageId.values())this._storage.deleteImage(e);this._kittyIdToStorageId.clear(),this._storageIdToKittyId.clear()}get images(){return this._images}get kittyIdToStorageId(){return this._kittyIdToStorageId}get lastImageId(){return this._nextImageId-1}_evictUndisplayedImages(){for(let[e]of this._images){if(this._images.size<=U._maxStoredImages/2)break;this._kittyIdToStorageId.has(e)||this._images.delete(e)}}};U._maxStoredImages=256;var re=U;var y=x(j());var he=x(je()),Ve=x(xe());var Nt=4194304,K=y.PALETTE_ANSI_256;K.set(y.PALETTE_VT340_COLOR);var Ht=2,Re=new Map,ve=!1;function Ft(r){ve||(ve=!0,(0,he.DecoderAsync)({memoryLimit:r,palette:K}).then(e=>$e(e,r),()=>{ve=!1}))}function Gt(r){return Re.get(r)?.pop()??new he.Decoder({memoryLimit:r,palette:K})}function $e(r,e){r.memoryUsage>Nt&&r.release();let t=Re.get(e)??[];t.lengththis._opts.sixelSizeLimit){console.warn("SIXEL: too much data, aborting"),this._aborted=!0,this._dec.release();return}try{this._dec.decode(e,t,i)}catch(A){console.warn(`SIXEL: error while decoding image - ${A}`),this._aborted=!0,this._dec.release()}}}unhook(e){try{return this._unhook(e)}finally{this._returnDecoder()}}_unhook(e){if(this._aborted||!e||!this._dec)return!0;let t=this._dec.width,i=this._dec.height;if(!t||!i)return i&&this._storage.advanceCursor(i),!0;let A=D.createCanvas(void 0,t,i);return A.getContext("2d")?.putImageData(new ImageData(this._dec.data8,t,i),0,0),this._storage.addImage(A),!0}};function Ut(r,e){let t=0;if(!e)return t;if(r.isInverse())if(r.isFgDefault())t=ae(e.foreground.rgba);else if(r.isFgRGB()){let i=r.constructor.toColorRGB(r.getFgColor());t=(0,y.toRGBA8888)(...i)}else t=ae(e.ansi[r.getFgColor()].rgba);else if(r.isBgDefault())t=ae(e.background.rgba);else if(r.isBgRGB()){let i=r.constructor.toColorRGB(r.getBgColor());t=(0,y.toRGBA8888)(...i)}else t=ae(e.ansi[r.getBgColor()].rgba);return t}function ae(r){return y.BIG_ENDIAN?r:(r&255)<<24|(r>>>8&255)<<16|(r>>>16&255)<<8|r>>>24&255}var de=class{constructor(e,t,i,A){this._storage=e;this._opts=t;this._renderer=i;this._terminal=A;this._addImageOpts={scrolling:!0,layer:"top",zIndex:0,cursorPos:"vt340"}}addImage(e){this._addImageOpts.scrolling=this._opts.sixelScrolling,this._storage.addImage(e,this._addImageOpts)}advanceCursor(e){if(this._opts.sixelScrolling){let t=this._renderer.cellSize;(t.width===-1||t.height===-1)&&(t=w);let i=Math.ceil(e/t.height);for(let A=1;Athis._onImageAdded.fire(),this._opts.enableSizeReports){let t=e.options.windowOptions??{};t.getWinSizePixels=!0,t.getCellSizePixels=!0,t.getWinSizeChars=!0,e.options.windowOptions=t}if(this._disposeLater(this._renderer,this._storage,e.parser.registerCsiHandler({prefix:"?",final:"h"},t=>this._decset(t)),e.parser.registerCsiHandler({prefix:"?",final:"l"},t=>this._decrst(t)),e.parser.registerCsiHandler({final:"c"},t=>this._da1(t)),e.parser.registerCsiHandler({prefix:"?",final:"S"},t=>this._xtermGraphicsAttributes(t)),e.onRender(t=>this._storage?.render(t)),e.parser.registerCsiHandler({intermediates:"!",final:"p"},()=>this.reset()),e.parser.registerEscHandler({final:"c"},()=>this.reset()),e._core._inputHandler.onRequestReset(()=>this.reset()),e.buffer.onBufferChange(()=>this._storage?.wipeAlternate()),e.onResize(t=>this._storage?.viewportResize(t))),this._opts.sixelSupport){let t=new de(this._storage,this._opts,this._renderer,e),i=new oe(this._opts,t,e);this._handlers.set("sixel",i),this._disposeLater(e._core._inputHandler._parser.registerDcsHandler({final:"q"},i))}if(this._opts.iipSupport){let t=new ge(this._storage),i=new ie(this._opts,this._renderer,t,e);this._handlers.set("iip",i),this._disposeLater(e._core._inputHandler._parser.registerOscHandler(1337,i))}if(this._opts.kittySupport){let t=new re(this._storage),i=new Ae(this._opts,this._renderer,t,e);this._handlers.set("kitty",i),this._disposeLater(t,i,e._core._inputHandler._parser.registerApcHandler({final:"G"},i))}}reset(){this._opts.sixelScrolling=this._defaultOpts.sixelScrolling,this._opts.sixelPaletteLimit=this._defaultOpts.sixelPaletteLimit,this._storage?.reset();for(let e of this._handlers.values())e.reset();return!1}get storageLimit(){return this._storage?.getLimit()||-1}set storageLimit(e){this._storage?.setLimit(e),this._opts.storageLimit=e}get storageUsage(){return this._storage?this._storage.getUsage():-1}get showPlaceholder(){return this._opts.showPlaceholder}set showPlaceholder(e){this._opts.showPlaceholder=e,this._renderer?.showPlaceholder(e)}getImageAtBufferCell(e,t){return this._storage?.getImageAtBufferCell(e,t)}extractTileAtBufferCell(e,t){return this._storage?.extractTileAtBufferCell(e,t)}_report(e){this._terminal?._core.input(e,!1)}_decset(e){for(let t=0;t2&&!(e[2]instanceof Array)&&e[2]<=tt?(this._opts.sixelPaletteLimit=e[2],this._report(`\x1B[?${e[0]};0;${this._opts.sixelPaletteLimit}S`)):this._report(`\x1B[?${e[0]};2S`),!0;case 4:return this._report(`\x1B[?${e[0]};0;${tt}S`),!0;default:return this._report(`\x1B[?${e[0]};2S`),!0}if(e[0]===2)switch(e[1]){case 1:let t=this._renderer?.dimensions?.css.canvas.width,i=this._renderer?.dimensions?.css.canvas.height;if(!t||!i){let s=w;t=(this._terminal?.cols||80)*s.width,i=(this._terminal?.rows||24)*s.height}if(t*i {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const cols = Math.ceil(img.width / width);\n const sx = (tileId % cols) * width;\n const sy = Math.floor(tileId / cols) * height;\n const finalWidth = width + sx > img.width ? img.width - sx : width;\n const finalHeight = sy + height > img.height ? img.height - sy : height;\n\n const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document,\n Math.ceil(spec.orig!.width * currentWidth / originalWidth),\n Math.ceil(spec.orig!.height * currentHeight / originalHeight)\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch {\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const ds = new DecompressionStream(format);\n const writer = ds.writable.getWriter();\n writer.write(compressed as BufferSource);\n writer.close();\n\n const chunks: Uint8Array[] = [];\n const reader = ds.readable.getReader();\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], - "mappings": "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", - "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "r", "min", "idx", "i", "dr", "dg", "db", "d", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "s", "b", "r", "def", "t", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "s", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "canvas", "ImageRenderer", "bm", "e", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "Base64Decoder", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "decoder", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "e", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "ds", "writer", "chunks", "reader", "done", "value", "totalLength", "sum", "chunk", "offset", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "image", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "SixelHandler", "_opts", "_storage", "_coreTerminal", "d", "params", "fillColor", "extractActiveBg", "data", "start", "end", "e", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "handler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] -+ "sourcesContent": [null, "\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Limit held memory in base64 decoder (encoded bytes).\n KEEP_DATA = 4194304,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n this._dec.init();\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n this._dec.init();\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n const data = this._qoiDec.decode(this._dec.data8);\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n this._activeDecoder.init();\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n DecoderAsync({\n memoryLimit: this._opts.pixelLimit * 4,\n palette: DEFAULT_PALETTE,\n paletteLimit: this._opts.sixelPaletteLimit\n }).then(d => this._dec = d);\n }\n\n public reset(): void {\n /**\n * reset sixel decoder to defaults:\n * - release all memory\n * - nullify palette (4096)\n * - apply default palette (256)\n */\n if (this._dec) {\n this._dec.release();\n // FIXME: missing interface on decoder to nullify full palette\n (this._dec as any)._palette.fill(0);\n this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);\n }\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n if (this._dec) {\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);\n }\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {\n this._dec.release();\n }\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], -+ "mappings": "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", -+ "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "r", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "min", "idx", "i", "dr", "dg", "db", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "d", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "b", "r", "def", "t", "s", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "sourceWidth", "sourceHeight", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "scaledWidth", "scaledHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "placeholder", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "canvas", "ImageRenderer", "generation", "bm", "e", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "decoderCapacity", "maxPending", "oldest", "Base64Decoder", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "decoder", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "generation", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "e", "metrics", "imageType", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "error", "limit", "offsetIn", "reader", "controller", "chunks", "totalLength", "done", "value", "offset", "chunk", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "byteLimit", "retainedBytes", "image", "evictPlaced", "oldestId", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "SixelHandler", "_opts", "_storage", "_coreTerminal", "d", "params", "fillColor", "extractActiveBg", "data", "start", "end", "e", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "handler", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] ++ "sourcesContent": [null, "\"use strict\";\n/**\n * Copyright (c) 2022, 2026 Joerg Breitbart\n * @license MIT\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.InWasm = InWasm;\nlet z = (s) => {\n if (Uint8Array.fromBase64)\n return Uint8Array.fromBase64(s);\n if (typeof Buffer !== 'undefined')\n return Buffer.from(s, 'base64');\n const b = atob(s);\n const r = new Uint8Array(b.length);\n for (let i = 0; i < r.length; ++i)\n r[i] = b.charCodeAt(i);\n return r;\n};\nfunction InWasm(def) {\n if (def.d) {\n const { t, s, d } = def;\n let b;\n let m;\n const W = WebAssembly;\n if (t === 0 /* OutputType.INSTANCE */) {\n if (s)\n return (e) => new W.Instance(m || (m = new W.Module(b || (b = z(d)))), e);\n return (e) => m\n ? W.instantiate(m, e)\n : W.instantiate(b || (b = z(d)), e).then(r => (m = r.module) && r.instance);\n }\n if (t === 1 /* OutputType.MODULE */) {\n if (s)\n return () => m || (m = new W.Module(b || (b = z(d))));\n return () => m\n ? Promise.resolve(m)\n : W.compile(b || (b = z(d))).then(r => m = r);\n }\n if (s)\n return () => b || (b = z(d));\n return () => Promise.resolve(b || (b = z(d)));\n }\n if (typeof _wasmCtx === 'undefined')\n throw new Error('must run \"inwasm\"');\n _wasmCtx.add(def);\n}\n//# sourceMappingURL=index.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023, 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\n/**\n * wasm base64 decoder.\n */\nconst wasmDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#828e69684093b2c6:rdef-start:\"decode\"*/{s:1,t:0,d:'AGFzbQEAAAABBQFgAAF/Ag8BA2VudgZtZW1vcnkCAAEDAwIAAAcNAgNkZWMAAANlbmQAAQqLBgKZBAEKf0GIKCgCAEGgKGohAUGEKCgCACIDQaAoaiEAQYAoKAIAQQFrQXxxIgRBoChqIQUgBEEQayADSgRAIARBkChqIQMDQCABIABBA2otAABBAnQoAoAgIABBAmotAABBAnQoAoAYIABBAWotAABBAnQoAoAQIAAtAABBAnQoAoAIcnJyIgY2AgAgAUEDaiAAQQdqLQAAQQJ0KAKAICAAQQZqLQAAQQJ0KAKAGCAAQQVqLQAAQQJ0KAKAECAAQQRqLQAAQQJ0KAKACHJyciIHNgIAIAFBBmogAEELai0AAEECdCgCgCAgAEEKai0AAEECdCgCgBggAEEJai0AAEECdCgCgBAgAEEIai0AAEECdCgCgAhycnIiCDYCACABQQlqIABBD2otAABBAnQoAoAgIABBDmotAABBAnQoAoAYIABBDWotAABBAnQoAoAQIABBDGotAABBAnQoAoAIcnJyIgk2AgAgAiAGciAHciAIciAJciECIAFBDGohASAAQRBqIgAgA0kNAAsLIAAgBUkEQANAIAEgAEEDai0AAEECdCgCgCAgAEECai0AAEECdCgCgBggAEEBai0AAEECdCgCgBAgAC0AAEECdCgCgAhycnIiAzYCACACIANyIQIgAUEDaiEBIABBBGoiACAFSQ0ACwtBfyEAIAJB////B00Ef0GEKCAENgIAQYgoIAFBoChrNgIAQQAFQX8LC+0BAQR/AkBBgCgoAgAiAUGEKCgCACIAa0EFTgRAQX8hAxAADQFBgCgoAgAhAUGEKCgCACEAC0F/IQMgASAAayIBQQJIDQAgAC0AoShBAnQoAoAQIAAtAKAoQQJ0KAKACHIhAgJ/IAFBBEYEQEEDQQQgAC0AoyhBPUYbIAAtAKIoQT1GayEBC0EBIAFBA0kNABogAC0AoihBAnQoAoAYIAJyIQJBAiABQQRHDQAaIAAtAKMoQQJ0KAKAICACciECQQMLIQEgAkH///8HSw0AQQAhA0GIKCgCACIAIAI2AKAoQYgoIAAgAWo2AgALIAML'}/*inwasm#828e69684093b2c6:rdef-end:\"decode\"*/);\n// SIMD version (speedup ~1.4x, not covered by tests yet)\n/*\nconst wasmDecode = InWasm({\n name: 'decode',\n type: OutputType.INSTANCE,\n mode: OutputMode.SYNC,\n srctype: 'Clang-C',\n imports: {\n env: { memory: new WebAssembly.Memory({ initial: 1 }) }\n },\n exports: {\n dec: () => 0,\n end: () => 0\n },\n compile: {\n switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']\n },\n code: `\n #include \n typedef struct {\n unsigned int wp;\n unsigned int sp;\n unsigned int dp;\n unsigned int e_size;\n unsigned int dummy[4];\n unsigned char data[0];\n } State;\n\n unsigned int *D0 = (unsigned int *) ${P32.D0 * 4};\n unsigned int *D1 = (unsigned int *) ${P32.D1 * 4};\n unsigned int *D2 = (unsigned int *) ${P32.D2 * 4};\n unsigned int *D3 = (unsigned int *) ${P32.D3 * 4};\n State *state = (State *) ${P32.STATE * 4};\n\n #define packed_byte(x) wasm_i8x16_splat((char) x)\n #define packed_dword(x) wasm_i32x4_splat(x)\n #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))\n\n __attribute__((noinline)) int dec() {\n unsigned int nsp = (state->wp - 1) & ~3;\n unsigned char *src = state->data + state->sp;\n unsigned char *end = state->data + nsp;\n unsigned char *dst = state->data + state->dp;\n unsigned int error = 0;\n\n v128_t err = wasm_i8x16_splat(0);\n unsigned char *end16 = state->data + (nsp & ~15);\n while (src < end16) {\n v128_t data = wasm_v128_load((v128_t *) src);\n\n // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb\n const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);\n const char linv = 1;\n const char hinv = 0;\n\n const v128_t lower_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n linv, linv, 0x2b, 0x30,\n 0x41, 0x50, 0x61, 0x70,\n linv, linv, linv, linv,\n linv, linv, linv, linv\n );\n const v128_t upper_bound_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n hinv, hinv, 0x2b, 0x39,\n 0x4f, 0x5a, 0x6f, 0x7a,\n hinv, hinv, hinv, hinv,\n hinv, hinv, hinv, hinv\n );\n // the difference between the shift and lower bound\n const v128_t shift_LUT = wasm_i8x16_const(\n // order: 0 1 2 3 4 5 6 7 8 9 a b c d e f\n 0x00, 0x00, 0x3e - 0x2b, 0x34 - 0x30,\n 0x00 - 0x41, 0x0f - 0x50, 0x1a - 0x61, 0x29 - 0x70,\n 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00\n );\n\n const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);\n const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);\n\n const v128_t below = wasm_i8x16_lt(data, lower_bound);\n const v128_t above = wasm_i8x16_gt(data, upper_bound);\n const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));\n\n // in_range = not (below or above) or eq_2f\n // outside = not in_range = below or above and not eq_2f (from deMorgan law)\n const v128_t outside = wasm_v128_andnot(eq_2f, above | below);\n err = wasm_v128_or(err, outside);\n\n const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);\n const v128_t t0 = wasm_i8x16_add(data, shift);\n v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));\n\n // pack bytes\n const v128_t ca = masked(v, 0x003f003f);\n const v128_t db = masked(v, 0x3f003f00);\n const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));\n v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));\n res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));\n\n wasm_v128_store((v128_t *) dst, res);\n dst += 12;\n src += 16;\n }\n //if (wasm_i8x16_bitmask(err) != 0) return -1;\n if (wasm_v128_any_true(err)) return -1;\n\n // operate on 4-byte blocks\n while (src < end) {\n error |= *((unsigned int *) dst) = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]];\n dst += 3;\n src += 4;\n }\n if (error >> 24) return -1;\n state->sp = nsp;\n state->dp = dst - state->data;\n return 0;\n }\n\n int end() {\n int rem = state->wp - state->sp;\n if (rem > 4 && dec()) return -1;\n rem = state->wp - state->sp;\n if (rem < 2) return -1;\n\n unsigned char *src = state->data + state->sp;\n if (rem == 4) {\n if (src[3] == 61) rem--;\n if (src[2] == 61) rem--;\n }\n unsigned int accu = D0[src[0]] | D1[src[1]];\n int dp = 1;\n if (rem > 2) {\n accu |= D2[src[2]];\n dp++;\n if (rem == 4) {\n accu |= D3[src[3]];\n dp++;\n }\n }\n if (accu >> 24) return -1;\n *((unsigned int *) (state->data + state->dp)) = accu;\n state->dp += dp;\n return 0;\n }\n `\n});\n*/\n// base64 map\nconst MAP = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n .split('')\n .map(el => el.charCodeAt(0)));\n// init decoder maps in LE order\nconst D = new Uint32Array(1024);\nD.fill(0xFF000000);\nfor (let i = 0; i < MAP.length; ++i)\n D[MAP[i]] = i << 2;\nfor (let i = 0; i < MAP.length; ++i)\n D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;\nfor (let i = 0; i < MAP.length; ++i)\n D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;\nfor (let i = 0; i < MAP.length; ++i)\n D[768 + MAP[i]] = i << 16;\nconst EMPTY = new Uint8Array(0);\n/**\n * base64 stream decoder.\n *\n * Features / assumptions:\n * - lazy chunkwise decoding\n * - errors out on any non base64 chars (no support for NL formatted base64)\n * - decodes in wasm\n * - inplace decoding to save memory\n * - supports a keepSize for lazy memory release\n */\nclass Base64Decoder {\n /**\n * @param keepSize Keep the wasm instance below this limit when calling `release()`.\n * @param maxBytes Max allowed bytes to allocate.\n * @param initialBytes Initial bytes to allocate.\n */\n constructor(keepSize, maxBytes, initialBytes) {\n this._inst = null;\n this._ended = true;\n this._bytes = 0;\n this.keepSize = keepSize !== null && keepSize !== void 0 ? keepSize : 1048576 /* Bytes.KEEP */;\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : 4294901760 /* Bytes.MAX */;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : 32768 /* Bytes.INITIAL */;\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw new Error('invalid byte settings');\n }\n }\n /**\n * Currently decoded bytes (borrowed).\n * Must be accessed before calling `release` or `init`.\n */\n get data8() {\n return this._inst ? this._d.subarray(0, this._m32[1282 /* P32.STATE_DP */]) : EMPTY;\n }\n /**\n * Release memory conditionally based on `keepSize`.\n * If memory gets released, also the wasm instance will be freed and recreated on next `init`,\n * otherwise the instance will be reused.\n */\n release() {\n if (!this._inst)\n return;\n if (this._bytes > this.keepSize) {\n this._inst = this._m32 = this._d = this._mem = null;\n }\n else {\n this._m32[1280 /* P32.STATE_WP */] = 0;\n this._m32[1281 /* P32.STATE_SP */] = 0;\n this._m32[1282 /* P32.STATE_DP */] = 0;\n }\n }\n /**\n * Initializes the decoder for new base64 data.\n * Must be called before doing any decoding attempts.\n * The method will either spawn a new wasm instance or grow\n * the needed memory of an existing instance.\n * @param maxBytes Max allowed bytes to allocate (overwrites ctor value).\n * @param initialBytes Initial bytes to allocate (overwrites ctor value).\n */\n init(maxBytes, initialBytes) {\n this.maxBytes = maxBytes !== null && maxBytes !== void 0 ? maxBytes : this.maxBytes;\n this._bytes = initialBytes !== null && initialBytes !== void 0 ? initialBytes : Math.min(this._bytes, this.maxBytes);\n if (this._bytes > this.maxBytes || this.maxBytes > 4294901760 /* Bytes.MAX */) {\n throw Error('invalid byte settings');\n }\n let m = this._m32;\n const bytes = this._bytes + 5152 /* Bytes._DATA_OFFSET */;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmDecode({ env: { memory: this._mem } });\n m = new Uint32Array(this._mem.buffer, 0);\n m.set(D, 256 /* P32.D0 */);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n m = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n m[1280 /* P32.STATE_WP */] = 0;\n m[1281 /* P32.STATE_SP */] = 0;\n m[1282 /* P32.STATE_DP */] = 0;\n this._m32 = m;\n this._ended = false;\n }\n /**\n * Realloc memory. Realloc only happens, if the requested\n * size doesn't fit in the current memory.\n * The new size will be capped by `maxBytes`.\n * @param requested Bytes to be stored.\n */\n _realloc(requested) {\n const needed = this._m32[1280 /* P32.STATE_WP */] + requested;\n if (this._bytes < needed) {\n if (needed > this.maxBytes) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n let newSize = this._bytes;\n while ((newSize *= 2) < needed) { }\n newSize = Math.min(newSize, this.maxBytes);\n if (newSize < needed) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n if (newSize + 5152 /* Bytes._DATA_OFFSET */ > this._mem.buffer.byteLength) {\n const addPages = Math.ceil((newSize + 5152 /* Bytes._DATA_OFFSET */ - this._mem.buffer.byteLength) / 65536);\n this._mem.grow(addPages);\n this._m32 = new Uint32Array(this._mem.buffer, 0);\n this._d = new Uint8Array(this._mem.buffer, 5152 /* Bytes._DATA_OFFSET */);\n }\n this._bytes = newSize;\n }\n return 0 /* DecodeStatus.OK */;\n }\n /**\n * Put bytes in `data` into the decoder.\n * Additionally decodes the payload, if it reached 2^17 bytes.\n * The return value indicates the type of issue.\n * @param data Bytes to be loaded.\n */\n put(data) {\n if (!this._inst || this._ended) {\n return -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n if (this._realloc(data.length)) {\n return -3 /* DecodeStatus.SIZE_EXCEEDED */;\n }\n const m = this._m32;\n this._d.set(data, m[1280 /* P32.STATE_WP */]);\n m[1280 /* P32.STATE_WP */] += data.length;\n // max chunk in input handler is 2^17, try to run in \"tandem mode\"\n return m[1280 /* P32.STATE_WP */] - m[1281 /* P32.STATE_SP */] >= 131072\n ? this._inst.exports.dec()\n : 0 /* DecodeStatus.OK */;\n }\n /**\n * End the current decoding.\n * Also decodes leftover payload from previous put calls.\n */\n end() {\n this._ended = true;\n return this._inst\n ? this._inst.exports.end()\n : -2 /* DecodeStatus.NOT_INITIALIZED */;\n }\n /**\n * Bytes loaded into the decoder.\n */\n get loadedBytes() {\n return this._inst\n ? this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n /**\n * Free bytes to feed to the decoder.\n */\n get freeBytes() {\n return this._inst\n ? this.maxBytes - this._m32[1280 /* P32.STATE_WP */]\n : 0;\n }\n}\nexports.default = Base64Decoder;\n//# sourceMappingURL=Base64Decoder.wasm.js.map", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nconst inwasm_runtime_1 = require(\"inwasm-runtime\");\nconst wasmQoiDecode = (0, inwasm_runtime_1.InWasm)(/*inwasm#459f9e1bfb80b1a8:rdef-start:\"qoi_decode\"*/{s:1,t:0,d:'AGFzbQEAAAABCgJgAABgA39/fwACDwEDZW52Bm1lbW9yeQIAAQMDAgABBwcBA2RlYwABCAEACu4EAgwAQQBBAEGAAvwLAAveBAEJf0EAQQBBgAL8CwAgAUEXTgRAIAAgAWpBCGshCkGACCEBIAJBAnRBgAhqIQsgAEEOaiEDQf8BIQZBACECA0AgA0EBaiEHIAMtAAAiCEE/cSEAAkACQCAIQcABcSIJRQRAIABBAnQiAC0AAyEGIAAtAAIhBCAALQABIQUgAC0AACECIAchAwwBCwJAIAhB/QFLDQAgCUHAAUcNACAFQQVsIAJBA2xqIARBB2xqIAZBC2xqQT9xQQJ0IgMgBDoAAiADIAU6AAEgAyACOgAAIANBA2ogBjoAAANAIAEgAjoAACABQQNqIAY6AAAgAUECaiAEOgAAIAFBAWogBToAACABQQRqIQEgAEUEQCAHIQMMBAsgAEEBayEAIAEgC0kNAAsgByEDDAILAn8CQAJAAkAgCEH+AWsOAgABAgsgAy0AAyEEIAMtAAIhBSADLQABIQIgA0EEagwCCyADKAIBIgJBGHYhBiACQRB2IQQgAkEIdiEFIANBBWoMAQsgCUGAAUcEQCAHIAlBwABHDQEaIAQgCEEDcWpBAmshBCACIABBBHZqQQJrIQIgBSAIQQJ2QQNxakECayEFIAcMAQsgBCAAQShrIgkgAy0AASIHQQ9xamohBCACIAdBBHYgCWpqIQIgACAFakEgayEFIANBAmoLIQMgBUEFbCACQQNsaiAEQQdsaiAGQQtsakE/cUECdCIAIAQ6AAIgACAFOgABIAAgAjoAACAAQQNqIAY6AAALIAEgBjoAAyABIAQ6AAIgASAFOgABIAEgAjoAACABQQRqIQELIAMgCkkNAAsLCw=='}/*inwasm#459f9e1bfb80b1a8:rdef-end:\"qoi_decode\"*/);\nclass QoiDecoder {\n constructor(keepSize) {\n this.keepSize = keepSize;\n this.width = 0;\n this.height = 0;\n }\n decode(d) {\n this.width = d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7];\n this.height = d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11];\n const pixels = this.width * this.height;\n const ib = pixels * 4;\n const dl = d.length;\n /**\n * byte/offset calculation:\n * To save some memory we dont reserve full memory for decoded + encoded,\n * but place encoded at the end of decoded plus 50% security distance\n * to avoid reads before writes positions:\n *\n * encoded < decoded (good compression)\n * enc ####################\n * dec #######################################\n * ^ ^\n * DST_P CHUNK_P\n *\n * encoded > decoded (degenerated compression, should not happen)\n * enc ##############################\n * dec ####################\n * ^ ^\n * DST_P CHUNK_P\n *\n * There is still a chance for overlapping r/w positions in case the compressed\n * data has very different pixel progression, yet the 50% security distance\n * should deal with that, as QOI will bloat data by 25% at max (RGB -> OP byte + RGB).\n * Since we always assume RGBA at decoding stage, the possible bloat reduces to 20% at max.\n */\n const bytes = Math.max(ib, dl) + (Math.min(ib, dl) >> 1) + 4096;\n if (!this._inst) {\n this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });\n this._inst = wasmQoiDecode({ env: { memory: this._mem } });\n }\n else if (this._mem.buffer.byteLength < bytes) {\n this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));\n this._d = null;\n }\n if (!this._d) {\n this._d = new Uint8Array(this._mem.buffer);\n }\n // put src data at the end of memory, also align to 256\n const chunkP = (this._mem.buffer.byteLength - dl) & ~0xFF;\n this._d.set(d, chunkP);\n this._inst.exports.dec(chunkP, dl, pixels);\n return this._d.subarray(1024 /* P8.DST_P */, 1024 /* P8.DST_P */ + ib);\n }\n release() {\n if (!this._inst)\n return;\n if (this._mem.buffer.byteLength > this.keepSize) {\n this._inst = this._d = this._mem = null;\n }\n }\n}\nexports.default = QoiDecoder;\n//# sourceMappingURL=QoiDecoder.wasm.js.map", null, null, "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { toRGBA8888 } from 'sixel/lib/Colors';\nimport { IDisposable } from '@xterm/xterm';\nimport { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\n\nconst enum Constants {\n PLACEHOLDER_LENGTH = 4096,\n PLACEHOLDER_HEIGHT = 24\n}\n\n/**\n * ImageRenderer - terminal frontend extension:\n * - provide primitives for canvas, ImageData, Bitmap (static)\n * - add canvas layer to DOM (browser only for now)\n * - draw image tiles onRender\n */\nexport class ImageRenderer extends Disposable implements IDisposable {\n /** @deprecated Kept for backward compat \u2014 points to top layer canvas. */\n public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }\n private _layers = new Map();\n private _placeholder: HTMLCanvasElement | undefined;\n private _placeholderBitmap: ImageBitmap | undefined;\n private _optionsRefresh = this._register(new MutableDisposable());\n private _oldOpen: ((parent: HTMLElement) => void) | undefined;\n private _renderService: IRenderService | undefined;\n private _oldSetRenderer: ((renderer: any) => void) | undefined;\n\n // drawing primitive - canvas\n public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {\n /**\n * NOTE: We normally dont care, from which document the canvas\n * gets created, so we can fall back to global document,\n * if the terminal has no document associated yet.\n * This way early image loads before calling .open keep working\n * (still discouraged though, as the metrics will be screwed up).\n * Only the DOM output canvas should be on the terminal's document,\n * which gets explicitly checked in `insertLayerToDom`.\n */\n const canvas = (localDocument ?? document).createElement('canvas');\n canvas.width = width | 0;\n canvas.height = height | 0;\n return canvas;\n }\n\n // drawing primitive - ImageData with optional buffer\n public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {\n if (typeof ImageData !== 'function') {\n const imgData = ctx.createImageData(width, height);\n if (buffer) {\n imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));\n }\n return imgData;\n }\n return buffer\n ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)\n : new ImageData(width, height);\n }\n\n // drawing primitive - ImageBitmap\n public static createImageBitmap(img: ImageBitmapSource): Promise {\n if (typeof createImageBitmap !== 'function') {\n return Promise.resolve(undefined);\n }\n return createImageBitmap(img);\n }\n\n\n constructor(private _terminal: ITerminalExt) {\n super();\n this._oldOpen = this._terminal._core.open;\n this._terminal._core.open = (parent: HTMLElement): void => {\n this._oldOpen?.call(this._terminal._core, parent);\n this._open();\n };\n if (this._terminal._core.screenElement) {\n this._open();\n }\n // hack to spot fontSize changes\n this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {\n if (option === 'fontSize') {\n this.rescaleCanvas();\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n });\n this._register(toDisposable(() => {\n this.removeLayerFromDom();\n this.removeLayerFromDom('bottom');\n if (this._terminal._core && this._oldOpen) {\n this._terminal._core.open = this._oldOpen;\n this._oldOpen = undefined;\n }\n if (this._renderService && this._oldSetRenderer) {\n this._renderService.setRenderer = this._oldSetRenderer;\n this._oldSetRenderer = undefined;\n }\n this._renderService = undefined;\n this._layers.clear();\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }));\n }\n\n /**\n * Enable the placeholder.\n */\n public showPlaceholder(value: boolean): void {\n if (value) {\n if (!this._placeholder && this.cellSize.height !== -1) {\n this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));\n }\n } else {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n this._placeholder = undefined;\n }\n this._renderService?.refreshRows(0, this._terminal.rows);\n }\n\n /**\n * Dimensions of the terminal.\n * Forwarded from internal render service.\n */\n public get dimensions(): IRenderDimensions | undefined {\n return this._terminal.dimensions;\n }\n\n /**\n * Current cell size (float).\n */\n public get cellSize(): ICellSize {\n return {\n width: this.dimensions?.css.cell.width || -1,\n height: this.dimensions?.css.cell.height || -1\n };\n }\n\n /**\n * Clear a region of the image layer canvas.\n */\n public clearLines(start: number, end: number, layer?: ImageLayer): void {\n const y = start * (this.dimensions?.css.cell.height || 0);\n const w = this.dimensions?.css.canvas.width || 0;\n const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);\n if (!layer || layer === 'top') {\n this._layers.get('top')?.clearRect(0, y, w, h);\n }\n if (!layer || layer === 'bottom') {\n this._layers.get('bottom')?.clearRect(0, y, w, h);\n }\n }\n\n /**\n * Clear whole image canvas.\n */\n public clearAll(layer?: ImageLayer): void {\n if (!layer || layer === 'top') {\n const ctx = this._layers.get('top');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n if (!layer || layer === 'bottom') {\n const ctx = this._layers.get('bottom');\n ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n }\n }\n\n /**\n * Draw neighboring tiles on the image layer canvas.\n */\n public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get(imgSpec.layer);\n if (!ctx) {\n return;\n }\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const dx = col * width;\n const dy = row * height;\n\n // safari bug: never access image source out of bounds\n const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n // Floor all pixel offsets to get stable tile mapping without any overflows.\n // Note: For not pixel perfect aligned cells like in the DOM renderer\n // this will move a tile slightly to the top/left (subpixel range, thus ignore it).\n // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),\n Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)\n );\n }\n\n /**\n * Extract a single tile from an image.\n */\n public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {\n const { width, height } = this.cellSize;\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n this._rescaleImage(imgSpec, width, height);\n const img = imgSpec.actual!;\n const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;\n const cols = Math.ceil(img.width / sourceWidth);\n const sx = (tileId % cols) * sourceWidth;\n const sy = Math.floor(tileId / cols) * sourceHeight;\n const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;\n const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;\n\n const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(\n img,\n Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),\n 0, 0, canvas.width, canvas.height\n );\n return canvas;\n }\n }\n\n /**\n * Draw a line with placeholder on the image layer canvas.\n */\n public drawPlaceholder(col: number, row: number, count: number = 1): void {\n const ctx = this._layers.get('top');\n if (ctx) {\n const { width, height } = this.cellSize;\n\n // Don't try to draw anything, if we cannot get valid renderer metrics.\n if (width === -1 || height === -1) {\n return;\n }\n\n if (!this._placeholder) {\n this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));\n } else if (height >= this._placeholder!.height) {\n this._createPlaceHolder(height + 1);\n }\n if (!this._placeholder) return;\n ctx.drawImage(\n this._placeholderBitmap ?? this._placeholder!,\n col * width,\n (row * height) % 2 ? 0 : 1, // needs %2 offset correction\n width * count,\n height,\n col * width,\n row * height,\n width * count,\n height\n );\n }\n }\n\n /**\n * Rescale image layer canvas if needed.\n * Checked once from `ImageStorage.render`.\n */\n public rescaleCanvas(): void {\n const w = this.dimensions?.css.canvas.width || 0;\n const h = this.dimensions?.css.canvas.height || 0;\n for (const ctx of this._layers.values()) {\n if (ctx.canvas.width !== w || ctx.canvas.height !== h) {\n ctx.canvas.width = w;\n ctx.canvas.height = h;\n }\n }\n }\n\n /**\n * Rescale image in storage if needed.\n */\n private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {\n if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {\n return;\n }\n const { width: originalWidth, height: originalHeight } = spec.origCellSize;\n if (currentWidth === originalWidth && currentHeight === originalHeight) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);\n const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);\n // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.\n if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {\n spec.actual = spec.orig;\n spec.actualCellSize.width = originalWidth;\n spec.actualCellSize.height = originalHeight;\n return;\n }\n const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);\n spec.actual = canvas;\n spec.actualCellSize.width = currentWidth;\n spec.actualCellSize.height = currentHeight;\n }\n }\n\n /**\n * Lazy init for the renderer.\n */\n private _open(): void {\n this._renderService = this._terminal._core._renderService;\n this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);\n this._renderService.setRenderer = (renderer: any) => {\n for (const key of [...this._layers.keys()]) {\n this.removeLayerFromDom(key);\n }\n this._oldSetRenderer?.call(this._renderService, renderer);\n };\n }\n\n public insertLayerToDom(layer: ImageLayer = 'top'): void {\n // make sure that the terminal is attached to a document and to DOM\n if (!this.document || !this._terminal._core.screenElement) {\n console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');\n return;\n }\n if (this._layers.has(layer)) {\n return;\n }\n const canvas = ImageRenderer.createCanvas(\n this.document, this.dimensions?.css.canvas.width || 0,\n this.dimensions?.css.canvas.height || 0\n );\n canvas.classList.add(`xterm-image-layer-${layer}`);\n const screenElement = this._terminal._core.screenElement;\n // Use isolation to create a stacking context without overriding z-index,\n // which would conflict with integrators (e.g. VS Code) that set their\n // own z-index on the screen element.\n screenElement.style.isolation = 'isolate';\n if (layer === 'bottom') {\n // Use z-index:-1 so it paints behind non-positioned text elements.\n // The screen element needs to be a stacking context (via isolation)\n // to contain the negative z-index, otherwise it would go behind the\n // entire terminal.\n canvas.style.zIndex = '-1';\n screenElement.insertBefore(canvas, screenElement.firstChild);\n } else {\n // Explicit z-index ensures the image canvas reliably stacks above\n // the text layer (DOM renderer rows). z-index: 0 is below the\n // selection overlay (z-index: 1).\n canvas.style.zIndex = '0';\n screenElement.appendChild(canvas);\n }\n const ctx = canvas.getContext('2d', { alpha: true });\n if (!ctx) {\n canvas.remove();\n return;\n }\n this._layers.set(layer, ctx);\n this.clearAll(layer);\n }\n\n public removeLayerFromDom(layer: ImageLayer = 'top'): void {\n const ctx = this._layers.get(layer);\n if (ctx) {\n ctx.canvas.remove();\n this._layers.delete(layer);\n }\n }\n\n public hasLayer(layer: ImageLayer): boolean {\n return this._layers.has(layer);\n }\n\n private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {\n this._placeholderBitmap?.close();\n this._placeholderBitmap = undefined;\n\n // create blueprint to fill placeholder with\n const bWidth = 32; // must be 2^n\n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);\n const ctx = blueprint.getContext('2d', { alpha: false });\n if (!ctx) return;\n const imgData = ImageRenderer.createImageData(ctx, bWidth, height);\n const d32 = new Uint32Array(imgData.data.buffer);\n const black = toRGBA8888(0, 0, 0);\n const white = toRGBA8888(255, 255, 255);\n d32.fill(black);\n for (let y = 0; y < height; ++y) {\n const shift = y % 2;\n const offset = y * bWidth;\n for (let x = 0; x < bWidth; x += 2) {\n d32[offset + x + shift] = white;\n }\n }\n ctx.putImageData(imgData, 0, 0);\n\n // create placeholder line, width aligned to blueprint width\n const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;\n this._placeholder = ImageRenderer.createCanvas(this.document, width, height);\n const ctx2 = this._placeholder.getContext('2d', { alpha: false });\n if (!ctx2) {\n this._placeholder = undefined;\n return;\n }\n for (let i = 0; i < width; i += bWidth) {\n ctx2.drawImage(blueprint, i, 0);\n }\n const placeholder = this._placeholder;\n ImageRenderer.createImageBitmap(placeholder).then(bitmap => {\n if (this._placeholder !== placeholder) bitmap?.close();\n else this._placeholderBitmap = bitmap;\n }).catch(() => {});\n }\n\n public get document(): Document | undefined {\n return this._terminal._core._coreBrowserService?.window.document;\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageRenderer } from './ImageRenderer';\nimport {\n ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,\n IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,\n UnderlineStyle, IAddImageOpts\n} from './Types';\n\n\n// fallback default cell size\nexport const CELL_SIZE_DEFAULT: ICellSize = {\n width: 7,\n height: 14\n};\n\n/**\n * Extend extended attribute to also hold image tile information.\n *\n * Object definition is copied from base repo to fully mimick its behavior.\n * Image data is added as additional public properties `imageId` and `tileId`.\n */\nclass ExtendedAttrsImage implements IExtendedAttrsImage {\n private _ext: number = 0;\n public get ext(): number {\n if (this._urlId) {\n return (\n (this._ext & ~ExtFlags.UNDERLINE_STYLE) |\n (this.underlineStyle << 26)\n );\n }\n return this._ext;\n }\n public set ext(value: number) { this._ext = value; }\n\n public get underlineStyle(): UnderlineStyle {\n // Always return the URL style if it has one\n if (this._urlId) {\n return UnderlineStyle.DASHED;\n }\n return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;\n }\n public set underlineStyle(value: UnderlineStyle) {\n this._ext &= ~ExtFlags.UNDERLINE_STYLE;\n this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;\n }\n\n public get underlineColor(): number {\n return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n public set underlineColor(value: number) {\n this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);\n this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);\n }\n\n public get underlineVariantOffset(): number {\n const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;\n if (val < 0) {\n return val ^ 0xFFFFFFF8;\n }\n return val;\n }\n public set underlineVariantOffset(value: number) {\n this._ext &= ~ExtFlags.VARIANT_OFFSET;\n this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;\n }\n\n private _urlId: number = 0;\n public get urlId(): number {\n return this._urlId;\n }\n public set urlId(value: number) {\n this._urlId = value;\n }\n\n constructor(\n ext: number = 0,\n urlId: number = 0,\n public imageId = -1,\n public tileId = -1\n ) {\n this._ext = ext;\n this._urlId = urlId;\n }\n\n public clone(): IExtendedAttrsImage {\n /**\n * Technically we dont need a clone variant of ExtendedAttrsImage,\n * as we never clone a cell holding image data.\n * Note: Clone is only meant to be used by the InputHandler for\n * sticky attributes, which is never the case for image data.\n * We still provide a proper clone method to reflect the full ext attr\n * state in case there are future use cases for clone.\n */\n return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);\n }\n\n public isEmpty(): boolean {\n return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;\n }\n}\nconst EMPTY_ATTRS = new ExtendedAttrsImage();\n\n\n/**\n * ImageStorage - extension of CoreTerminal:\n * - hold image data\n * - write/read image data to/from buffer\n *\n * TODO: image composition for overwrites\n */\nexport class ImageStorage implements IDisposable {\n // storage\n private _images: Map = new Map();\n // last used id\n private _lastId = 0;\n // last evicted id\n private _lowestId = 0;\n // whether a full clear happened before\n private _fullyCleared = false;\n // whether render should do a full clear\n private _needsFullClear = false;\n // hard limit of stored pixels (fallback limit of 10 MB)\n private _pixelLimit: number = 2500000;\n\n private _viewportMetrics: { cols: number, rows: number };\n public onImageAdded: (() => void) | undefined;\n public onImageDeleted: ((storageId: number) => void) | undefined;\n\n constructor(\n private _terminal: ITerminalExt,\n private _renderer: ImageRenderer,\n private _opts: IImageAddonOptions\n ) {\n try {\n this.setLimit(this._opts.storageLimit);\n } catch (e: unknown) {\n if (e instanceof Error) {\n console.error(e.message);\n }\n console.warn(`storageLimit is set to ${this.getLimit()} MB`);\n }\n this._viewportMetrics = {\n cols: this._terminal.cols,\n rows: this._terminal.rows\n };\n }\n\n public dispose(): void {\n this.reset();\n }\n\n public reset(): void {\n for (const spec of this._images.values()) {\n spec.marker?.dispose();\n }\n // NOTE: marker.dispose above already calls ImageBitmap.close\n // therefore we can just wipe the map here\n this._images.clear();\n this._renderer.clearAll();\n }\n\n public getLimit(): number {\n return this._pixelLimit * 4 / 1000000;\n }\n\n public setLimit(value: number): void {\n if (value < 0.5 || value > 1000) {\n throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');\n }\n this._pixelLimit = (value / 4 * 1000000) >>> 0;\n this._evictOldest(0);\n }\n\n public getUsage(): number {\n return this._getStoredPixels() * 4 / 1000000;\n }\n\n private _getStoredPixels(): number {\n let storedPixels = 0;\n for (const spec of this._images.values()) {\n if (spec.orig) {\n storedPixels += spec.orig.width * spec.orig.height;\n if (spec.actual && spec.actual !== spec.orig) {\n storedPixels += spec.actual.width * spec.actual.height;\n }\n }\n }\n return storedPixels;\n }\n\n private _delImg(id: number): void {\n const spec = this._images.get(id);\n if (!spec) return;\n this._images.delete(id);\n // FIXME: really ugly workaround to get bitmaps deallocated :(\n if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {\n spec.orig.close();\n }\n this.onImageDeleted?.(id);\n }\n\n /**\n * Wipe canvas and images on alternate buffer.\n */\n public wipeAlternate(): void {\n // remove all alternate tagged images\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate') {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n // mark canvas to be wiped on next render\n this._needsFullClear = true;\n this._fullyCleared = false;\n }\n\n /**\n * Delete an image by its internal storage ID.\n * Used by protocols that support explicit deletion (e.g. Kitty a=d).\n */\n public deleteImage(id: number): void {\n const spec = this._images.get(id);\n if (spec) {\n spec.marker?.dispose();\n this._delImg(id);\n }\n }\n\n /**\n * Method to add an image to the storage.\n * @param img - The image to add (canvas or bitmap).\n * @param opts - Options for addImage:\n * - scrolling: When true, cursor advances with the image.\n * When false, image is placed at ORIGIN and cursor does not move.\n * - layer: Which canvas layer to render on ('top' or 'bottom').\n * - zIndex: Z-index for image layering within the same layer.\n * - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.\n * @returns The internal image ID assigned to the stored image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {\n // never allow storage to exceed memory limit\n this._evictOldest(img.width * img.height);\n\n // calc rows x cols needed to display the image\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const cols = Math.ceil(img.width / cellSize.width);\n const rows = Math.ceil(img.height / cellSize.height);\n\n const imageId = ++this._lastId;\n\n const buffer = this._terminal._core.buffer;\n const termCols = this._terminal.cols;\n const termRows = this._terminal.rows;\n const originX = buffer.x;\n const originY = buffer.y;\n let offset = originX;\n let tileCount = 0;\n\n if (!opts.scrolling) {\n buffer.x = 0;\n buffer.y = 0;\n offset = 0;\n }\n\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(buffer.y + buffer.ybase);\n for (let col = 0; col < cols; ++col) {\n if (offset + col >= termCols) break;\n this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);\n tileCount++;\n }\n if (opts.scrolling) {\n if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();\n } else {\n if (++buffer.y >= termRows) break;\n }\n buffer.x = offset;\n }\n this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);\n\n // cursor positioning modes\n if (opts.scrolling) {\n if (opts.cursorPos === 'iip') {\n buffer.x = Math.min(offset + cols, termCols);\n } else {\n buffer.x = offset;\n }\n } else {\n buffer.x = originX;\n buffer.y = originY;\n }\n\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.tileCount < 1) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n\n // eviction marker:\n // delete the image when the marker gets disposed\n const endMarker = this._terminal.registerMarker(0);\n endMarker?.onDispose(() => {\n const spec = this._images.get(imageId);\n if (spec) {\n this._delImg(imageId);\n }\n });\n\n // since markers do not work on alternate for some reason,\n // we evict images here manually\n if (this._terminal.buffer.active.type === 'alternate') {\n this._evictOnAlternate();\n }\n\n // create storage entry\n const imgSpec: IImageSpec = {\n orig: img,\n origCellSize: cellSize,\n actual: img,\n actualCellSize: { ...cellSize }, // clone needed, since later modified\n marker: endMarker || undefined,\n tileCount,\n bufferType: this._terminal.buffer.active.type,\n layer: opts.layer,\n zIndex: opts.zIndex\n };\n\n // finally add the image\n this._images.set(imageId, imgSpec);\n this.onImageAdded?.();\n return imageId;\n }\n\n\n /**\n * Render method. Collects buffer information and triggers\n * canvas updates.\n */\n // TODO: Should we move this to the ImageRenderer?\n public render(range: { start: number, end: number }): void {\n // Determine which layers have images\n let hasTopImages = false;\n let hasBottomImages = false;\n for (const spec of this._images.values()) {\n if (spec.layer === 'bottom') {\n hasBottomImages = true;\n } else {\n hasTopImages = true;\n }\n if (hasTopImages && hasBottomImages) break;\n }\n\n // Lazily insert layers that are needed\n if (hasTopImages && !this._renderer.hasLayer('top')) {\n this._renderer.insertLayerToDom('top');\n if (!this._renderer.hasLayer('top')) return;\n }\n if (hasBottomImages && !this._renderer.hasLayer('bottom')) {\n this._renderer.insertLayerToDom('bottom');\n }\n\n // rescale if needed\n this._renderer.rescaleCanvas();\n\n // exit early if we dont have any images to test for\n if (!this._images.size) {\n if (!this._fullyCleared) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n if (this._renderer.hasLayer('top')) {\n this._renderer.removeLayerFromDom('top');\n }\n if (this._renderer.hasLayer('bottom')) {\n this._renderer.removeLayerFromDom('bottom');\n }\n return;\n }\n\n // Remove layers no longer needed\n if (!hasTopImages && this._renderer.hasLayer('top')) {\n this._renderer.clearAll('top');\n this._renderer.removeLayerFromDom('top');\n }\n if (!hasBottomImages && this._renderer.hasLayer('bottom')) {\n this._renderer.clearAll('bottom');\n this._renderer.removeLayerFromDom('bottom');\n }\n\n // buffer switches force a full clear\n if (this._needsFullClear) {\n this._renderer.clearAll();\n this._fullyCleared = true;\n this._needsFullClear = false;\n }\n\n const { start, end } = range;\n const buffer = this._terminal._core.buffer;\n const cols = this._terminal._core.cols;\n\n // clear drawing area\n this._renderer.clearLines(start, end);\n\n // Collect draw calls so we can sort by z-index (lower z drawn first).\n const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];\n const placeholderCalls: { col: number, row: number, count: number }[] = [];\n\n // walk all cells in viewport and collect tiles found\n for (let row = start; row <= end; ++row) {\n const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;\n if (!line) return;\n for (let col = 0; col < cols; ++col) {\n if (line.getBg(col) & BgFlags.HAS_EXTENDED) {\n let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (e.tileId !== -1) {\n const startTile = e.tileId;\n const startCol = col;\n let count = 1;\n /**\n * merge tiles to the right into a single draw call, if:\n * - not at end of line\n * - cell has same image id\n * - cell has consecutive tile id\n */\n while (\n ++col < cols\n && (line.getBg(col) & BgFlags.HAS_EXTENDED)\n && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)\n && (e.imageId === imageId)\n && (e.tileId === startTile + count)\n ) {\n count++;\n }\n col--;\n if (imgSpec) {\n if (imgSpec.actual) {\n drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });\n }\n } else if (this._opts.showPlaceholder) {\n placeholderCalls.push({ col: startCol, row, count });\n }\n this._fullyCleared = false;\n }\n }\n }\n }\n\n // Sort by z-index so lower z draws first (higher z renders on top)\n drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);\n\n // Draw placeholders first (lowest priority)\n for (const call of placeholderCalls) {\n this._renderer.drawPlaceholder(call.col, call.row, call.count);\n }\n\n // Draw images in z-index order\n for (const call of drawCalls) {\n this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);\n }\n }\n\n public viewportResize(metrics: { cols: number, rows: number }): void {\n // exit early if we have nothing in storage\n if (!this._images.size) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // handle only viewport width enlargements, exit all other cases\n // TODO: needs patch for tile counter\n if (this._viewportMetrics.cols >= metrics.cols) {\n this._viewportMetrics = metrics;\n return;\n }\n\n // walk scrollbuffer at old col width to find all possible expansion matches\n const buffer = this._terminal._core.buffer;\n const rows = buffer.lines.length;\n const oldCol = this._viewportMetrics.cols - 1;\n for (let row = 0; row < rows; ++row) {\n const line = buffer.lines.get(row) as IBufferLineExt;\n if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;\n const imageId = e.imageId;\n if (imageId === undefined || imageId === -1) {\n continue;\n }\n const imgSpec = this._images.get(imageId);\n if (!imgSpec) {\n continue;\n }\n // found an image tile at oldCol, check if it qualifies for right exapansion\n const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);\n if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {\n continue;\n }\n // expand only if right side is empty (nothing got wrapped from below)\n let hasData = false;\n for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {\n if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {\n hasData = true;\n break;\n }\n }\n if (hasData) {\n continue;\n }\n // do right expansion on terminal buffer\n const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);\n let lastTile = e.tileId;\n for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {\n this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);\n imgSpec.tileCount++;\n }\n }\n }\n // store new viewport metrics\n this._viewportMetrics = metrics;\n }\n\n /**\n * Retrieve original canvas at buffer position.\n */\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1) {\n const orig = this._images.get(e.imageId)?.orig;\n if (window.ImageBitmap && orig instanceof ImageBitmap) {\n const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);\n canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);\n return canvas;\n }\n return orig as HTMLCanvasElement;\n }\n }\n }\n\n /**\n * Extract active single tile at buffer position.\n */\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n const buffer = this._terminal._core.buffer;\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {\n const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;\n if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {\n const spec = this._images.get(e.imageId);\n if (spec) {\n return this._renderer.extractTile(spec, e.tileId);\n }\n }\n }\n }\n\n // TODO: Do we need some blob offloading tricks here to avoid early eviction?\n // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size\n private _evictOldest(room: number): number {\n const used = this._getStoredPixels();\n let current = used;\n while (this._pixelLimit < current + room && this._images.size) {\n const spec = this._images.get(++this._lowestId);\n if (spec && spec.orig) {\n current -= spec.orig.width * spec.orig.height;\n if (spec.actual && spec.orig !== spec.actual) {\n current -= spec.actual.width * spec.actual.height;\n }\n spec.marker?.dispose();\n this._delImg(this._lowestId);\n }\n }\n return used - current;\n }\n\n private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const old = line._extendedAttrs[x];\n if (old) {\n if (old.imageId !== undefined) {\n // found an old ExtendedAttrsImage, since we know that\n // they are always isolated instances (single cell usage),\n // we can re-use it and just update their id entries\n const oldSpec = this._images.get(old.imageId);\n if (oldSpec) {\n // early eviction for in-viewport overwrites\n oldSpec.tileCount--;\n }\n old.imageId = imageId;\n old.tileId = tileId;\n return;\n }\n // found a plain ExtendedAttrs instance, clone it to new entry\n line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);\n return;\n }\n }\n // fall-through: always create new ExtendedAttrsImage entry\n line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;\n line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);\n }\n\n private _evictOnAlternate(): void {\n // nullify tile count of all images on alternate buffer\n for (const spec of this._images.values()) {\n if (spec.bufferType === 'alternate') {\n spec.tileCount = 0;\n }\n }\n // re-count tiles on whole buffer\n const buffer = this._terminal._core.buffer;\n for (let y = 0; y < this._terminal.rows; ++y) {\n const line = buffer.lines.get(y) as IBufferLineExt;\n if (!line) {\n continue;\n }\n for (let x = 0; x < this._terminal.cols; ++x) {\n if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {\n const imgId = line._extendedAttrs[x]?.imageId;\n if (imgId) {\n const spec = this._images.get(imgId);\n if (spec) {\n spec.tileCount++;\n }\n }\n }\n }\n }\n // deleted images with zero tile count\n const zero = [];\n for (const [id, spec] of this._images.entries()) {\n if (spec.bufferType === 'alternate' && !spec.tileCount) {\n spec.marker?.dispose();\n zero.push(id);\n }\n }\n for (const id of zero) {\n this._delImg(id);\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\nimport { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { CELL_SIZE_DEFAULT } from './ImageStorage';\nimport Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';\nimport { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';\nimport { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';\n\n// Local const enum mirror - esbuild can't inline const enums from external packages\nconst enum DecoderConst {\n // Held memory in base64/QOI decoders between images. Zero because each kept\n // decoder pins a wasm memory, and V8 caps those per process (~124 in a\n // sandboxed renderer), so idle terminals must not hold one.\n KEEP_DATA = 0,\n // Initial buffer allocation for the decoder.\n INITIAL_DATA = 1048576,\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n OK = 0\n}\n\n// default IIP header values\nconst DEFAULT_HEADER: IHeaderFields = {\n type: SequenceType.INVALID,\n name: 'Unnamed file',\n size: 0,\n width: 'auto',\n height: 'auto',\n preserveAspectRatio: 1,\n inline: 0\n};\n\n\nexport class IIPHandler implements IOscHandler, IResetHandler {\n private _generation = 0;\n private _aborted = false;\n private _hp = new HeaderParser();\n private _header: IHeaderFields = DEFAULT_HEADER;\n private _dec: Base64Decoder;\n private _qoiDec: QoiDecoder;\n private _isMultipart = false;\n private _abortMulti = false;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _storage: IIPImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);\n const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);\n this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);\n this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);\n }\n\n public reset(): void {\n this._generation++;\n this._hp.reset();\n this._dec.release();\n this._qoiDec.release();\n }\n\n public start(): void {\n this._aborted = false;\n this._hp.reset();\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (this._hp.state === HeaderState.END) {\n if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n }\n } else {\n const dataPos = this._hp.parse(data, start, end);\n if (dataPos === -1) {\n this._aborted = true;\n return;\n }\n if (dataPos > 0) {\n const seqType = this._hp.fields.type;\n if (seqType === SequenceType.FILE) {\n if (this._isMultipart) {\n this._isMultipart = false;\n this._abortMulti = false;\n this._dec.release();\n }\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n if (!this._header.inline) {\n this._aborted = true;\n return;\n }\n if (!this._initDecoder()) {\n this._aborted = true;\n return;\n }\n } else if (this._abortMulti) {\n this._aborted = true;\n return;\n }\n if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {\n this._dec.release();\n this._aborted = true;\n if (this._isMultipart) this._abortMulti = true;\n }\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted) return true;\n\n if (this._hp.state !== HeaderState.END) {\n if (this._hp.end()) return true;\n }\n const seqType = this._hp.fields.type;\n\n if (seqType === SequenceType.FILEPART) return true;\n\n if (seqType === SequenceType.REPORTCELLSIZE) {\n // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST\n let w = CELL_SIZE_DEFAULT.width;\n let h = CELL_SIZE_DEFAULT.height;\n if (this._renderer.dimensions) {\n w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;\n h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;\n }\n const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;\n const report = `\\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\\x1b\\\\`;\n this._coreTerminal.input(report, false);\n return true;\n }\n\n if (seqType === SequenceType.MULTIPARTFILE) {\n this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);\n this._isMultipart = true;\n this._abortMulti = false;\n this._dec.release();\n if (!this._initDecoder()) {\n this._abortMulti = true;\n }\n return true;\n }\n\n if (seqType === SequenceType.FILEEND) {\n if (!this._isMultipart) return true;\n this._isMultipart = false;\n if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;\n }\n\n // fallthrough for SequenceType.FILE & SequenceType.FILEEND\n\n let w = 0;\n let h = 0;\n\n // early exit condition chain\n let cond: number | boolean;\n let metrics = UNSUPPORTED_TYPE;\n if (cond = success) {\n if (cond = !this._dec.end()) {\n metrics = imageType(this._dec.data8);\n if (cond = metrics.mime !== 'unsupported') {\n w = metrics.width;\n h = metrics.height;\n if (cond = w && h && w * h < this._opts.pixelLimit) {\n [w, h] = this._resize(w, h).map(Math.floor);\n cond = w && h && w * h < this._opts.pixelLimit;\n } else {\n console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);\n }\n } else {\n console.warn('IIP: unsupported image type');\n }\n } else {\n console.warn('IIP: error during BASE64 decoding');\n }\n }\n if (!cond) {\n this._dec.release();\n return true;\n }\n\n let blob: Blob | ImageData;\n if (metrics.mime === 'image/qoi') {\n let data: Uint8Array;\n try {\n data = this._qoiDec.decode(this._dec.data8);\n } catch (e) {\n console.warn('IIP: could not decode QOI image', e);\n this._dec.release();\n this._qoiDec.release();\n return true;\n }\n blob = new ImageData(\n new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),\n this._qoiDec.width,\n this._qoiDec.height\n );\n this._qoiDec.release();\n if (w === this._qoiDec.width && h === this._qoiDec.height) {\n // use fast-path if we don't need to rescale\n this._dec.release();\n const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);\n canvas.getContext('2d')?.putImageData(blob, 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n } else {\n blob = new Blob([this._dec.data8], { type: metrics.mime });\n }\n this._dec.release();\n const generation = this._generation;\n return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })\n .then(bm => {\n if (generation !== this._generation) {\n bm.close();\n return true;\n }\n this._storage.addImage(bm);\n return true;\n })\n .catch(e => {\n console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);\n return true;\n });\n }\n\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n private _initDecoder(): boolean {\n try {\n this._dec.init();\n return true;\n } catch (e) {\n console.warn('IIP: could not allocate decoder', e);\n this._dec.release();\n return false;\n }\n }\n\n private _resize(w: number, h: number): [number, number] {\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;\n const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;\n\n const rw = this._dim(this._header.width!, width, cw);\n const rh = this._dim(this._header.height!, height, ch);\n if (!rw && !rh) {\n const wf = width / w; // TODO: should this respect initial cursor offset?\n const hf = (height - ch) / h; // TODO: fix offset issues from float cell height\n const f = Math.min(wf, hf);\n return f < 1 ? [w * f, h * f] : [w, h];\n }\n return !rw\n ? [w * rh / h, rh]\n : this._header.preserveAspectRatio || !rw || !rh\n ? [rw, h * rw / w] : [rw, rh];\n }\n\n private _dim(s: string, total: number, cdim: number): number {\n if (s === 'auto') return 0;\n if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;\n if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);\n return parseInt(s, 10) * cdim;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n// eslint-disable-next-line\ndeclare const Buffer: any;\n\nexport const enum HeaderState {\n START = 0,\n ABORT = 1,\n KEY = 2,\n VALUE = 3,\n END = 4\n}\n\nexport const enum SequenceType {\n INVALID = 0,\n FILE = 1,\n MULTIPARTFILE = 2,\n FILEPART = 3,\n FILEEND = 4,\n REPORTCELLSIZE = 5\n}\n\nexport interface IHeaderFields {\n [key: string]: number | string | Uint32Array | null | undefined;\n // sequence type\n type: SequenceType;\n // base-64 encoded filename. Defaults to \"Unnamed file\".\n name: string;\n // File size in bytes. The file transfer will be canceled if this size is exceeded.\n size: number;\n /**\n * Optional width and height to render:\n * - N: N character cells.\n * - Npx: N pixels.\n * - N%: N percent of the session's width or height.\n * - auto: The image's inherent size will be used to determine an appropriate dimension.\n */\n width?: string;\n height?: string;\n // Optional, defaults to 1 respecting aspect ratio (width takes precedence).\n preserveAspectRatio?: number;\n // Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded\n // (download not supported).\n inline?: number;\n}\n\n// field value decoders\n\n// ASCII bytes to string\nfunction toStr(data: Uint32Array): string {\n let s = '';\n for (let i = 0; i < data.length; ++i) {\n s += String.fromCharCode(data[i]);\n }\n return s;\n}\n\n// digits to integer\nfunction toInt(data: Uint32Array): number {\n let v = 0;\n for (let i = 0; i < data.length; ++i) {\n if (data[i] < 48 || data[i] > 57) {\n throw new Error('illegal char');\n }\n v = v * 10 + data[i] - 48;\n }\n return v;\n}\n\n// check for correct size entry\nfunction toSize(data: Uint32Array): string {\n const v = toStr(data);\n if (!v.match(/^((auto)|(\\d+?((px)|(%)){0,1}))$/)) {\n throw new Error('illegal size');\n }\n return v;\n}\n\n// name is base64 encoded utf-8\nfunction toName(data: Uint32Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(toStr(data), 'base64').toString();\n }\n const bs = atob(toStr(data));\n const b = new Uint8Array(bs.length);\n for (let i = 0; i < b.length; ++i) {\n b[i] = bs.charCodeAt(i);\n }\n return new TextDecoder().decode(b);\n}\n\nconst DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {\n inline: toInt,\n size: toInt,\n name: toName,\n width: toSize,\n height: toSize,\n preserveAspectRatio: toInt\n};\n\n\n// sequence type markers\n// File\nconst FILE_MARKER = [70, 105, 108, 101];\n// MultipartFile\nconst MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];\n// FilePart\nconst FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];\n// FileEnd\nconst FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];\n// ReportCellSize\nconst REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];\n\n// max allowed chars for sequence header\nconst MAX_FIELDCHARS = 1024;\n\n\nexport class HeaderParser {\n public state: HeaderState = HeaderState.START;\n private _buffer = new Uint32Array(MAX_FIELDCHARS);\n private _position = 0;\n private _key = '';\n public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};\n\n public reset(): void {\n this._buffer.fill(0);\n this.state = HeaderState.START;\n this._position = 0;\n this.fields = {};\n this._key = '';\n }\n\n public end(): number {\n if (this.state === HeaderState.START) {\n if (this._position === FILEEND_MARKER.length) {\n for (let k = 0; k < FILEEND_MARKER.length; ++k) {\n if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEEND;\n this.state = HeaderState.END;\n return 0;\n }\n if (this._position === REPORTCELLSIZE_MARKER.length) {\n for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {\n if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.REPORTCELLSIZE;\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n if (this.state === HeaderState.END) return 0;\n if (this.state === HeaderState.VALUE\n && this.fields.type === SequenceType.MULTIPARTFILE\n ) {\n if (!this._storeValue(this._position)) return this._a();\n this.state = HeaderState.END;\n return 0;\n }\n return this._a();\n }\n\n public parse(data: Uint32Array, start: number, end: number): number {\n let state = this.state;\n let pos = this._position;\n const buffer = this._buffer;\n if (state === HeaderState.ABORT || state === HeaderState.END) return -1;\n if (state === HeaderState.START && pos > 14) return -1;\n for (let i = start; i < end; ++i) {\n const c = data[i];\n switch (c) {\n case 59: // ;\n if (!this._storeValue(pos)) return this._a();\n state = HeaderState.KEY;\n pos = 0;\n break;\n case 61: // =\n if (state === HeaderState.START) {\n if (buffer[0] === 70) {\n // 'File' or 'FilePart'\n let k = 0;\n for (; k < FILE_MARKER.length; ++k) {\n if (buffer[k] !== FILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILE;\n if (pos === FILEPART_MARKER.length) {\n for (; k < FILEPART_MARKER.length; ++k) {\n if (buffer[k] !== FILEPART_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.FILEPART;\n this.state = HeaderState.END;\n return i + 1;\n }\n } else if (buffer[0] === 77) {\n // 'MultipartFile'\n for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {\n if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();\n }\n this.fields['type'] = SequenceType.MULTIPARTFILE;\n } else {\n return this._a();\n }\n state = HeaderState.KEY;\n pos = 0;\n } else if (state === HeaderState.KEY) {\n if (!this._storeKey(pos)) return this._a();\n state = HeaderState.VALUE;\n pos = 0;\n } else if (state === HeaderState.VALUE) {\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n break;\n case 58: // :\n if (state === HeaderState.VALUE) {\n if (!this._storeValue(pos)) return this._a();\n }\n this.state = HeaderState.END;\n return i + 1;\n default:\n if (pos >= MAX_FIELDCHARS) return this._a();\n buffer[pos++] = c;\n }\n }\n this.state = state;\n this._position = pos;\n return -2;\n }\n\n private _a(): number {\n this.fields.type = SequenceType.INVALID;\n this.state = HeaderState.ABORT;\n return -1;\n }\n\n private _storeKey(pos: number): boolean {\n const k = toStr(this._buffer.subarray(0, pos));\n if (k) {\n this._key = k;\n this.fields[k] = null;\n return true;\n }\n return false;\n }\n\n private _storeValue(pos: number): boolean {\n if (this._key) {\n try {\n const v = this._buffer.slice(0, pos);\n this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;\n } catch {\n return false;\n }\n return true;\n }\n return false;\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\n\nexport type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';\n\nexport interface IMetrics {\n mime: ImageType;\n width: number;\n height: number;\n}\n\nexport const UNSUPPORTED_TYPE: IMetrics = {\n mime: 'unsupported',\n width: 0,\n height: 0\n};\n\nexport function imageType(d: Uint8Array): IMetrics {\n if (d.length < 32) {\n return UNSUPPORTED_TYPE;\n }\n const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);\n // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)\n // + first chunk must be IHDR\n if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {\n return {\n mime: 'image/png',\n width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],\n height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]\n };\n }\n // JPEG: FF D8 FF\n if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {\n const [width, height] = jpgSize(d);\n return { mime: 'image/jpeg', width, height };\n }\n // GIF: GIF87a or GIF89a\n if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {\n return {\n mime: 'image/gif',\n width: d[7] << 8 | d[6],\n height: d[9] << 8 | d[8]\n };\n }\n // QOI: qoif\n if (d32[0] === 0x66696F71) {\n return {\n mime: 'image/qoi',\n width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],\n height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]\n };\n }\n // WEBP: RIFF | xxxx | WEBP | VP8x\n if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {\n switch (d[15]) {\n case 0x58: // Extended WebP VP8X --> \"X\"\n return {\n mime: 'image/webp',\n width: (d[24] | d[25] << 8 | d[26] << 16) + 1,\n height: (d[27] | d[28] << 8 | d[29] << 16) + 1\n };\n case 0x4C: // Lossless WebP VP8L --> \"L\"\n if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;\n const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;\n return {\n mime: 'image/webp',\n width: (dim & 0x3FFF) + 1,\n height: (dim >>> 14 & 0x3FFF) + 1\n };\n case 0x20: // Lossy WebP VP8 --> \" \"\n if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;\n return {\n mime: 'image/webp',\n width: (d[26] | d[27] << 8) & 0x3FFF,\n height: (d[28] | d[29] << 8) & 0x3FFF\n };\n }\n return UNSUPPORTED_TYPE;\n }\n // AVIF: Box size | ftyp | avif/avis\n if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {\n let pos = -1;\n // search for ispe box within first 1024 bytes\n const limit = Math.min(d.length - 16, 1024);\n for (let i = 8; i < limit; i++) {\n // scan for ispe\n if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {\n pos = i;\n break;\n }\n }\n if (pos !== -1) {\n // dimensions are in BE at +8 (width) at +12 (height)\n const width =\n d[pos + 8] << 24 |\n d[pos + 9] << 16 |\n d[pos + 10] << 8 |\n d[pos + 11];\n const height =\n d[pos + 12] << 24 |\n d[pos + 13] << 16 |\n d[pos + 14] << 8 |\n d[pos + 15];\n if (width > 0 && height > 0) {\n return { mime: 'image/avif', width, height };\n }\n }\n return UNSUPPORTED_TYPE;\n }\n return UNSUPPORTED_TYPE;\n}\n\n\nfunction jpgSize(d: Uint8Array): [number, number] {\n const len = d.length;\n let i = 4;\n let blockLength = d[i] << 8 | d[i + 1];\n while (true) {\n i += blockLength;\n if (i >= len) {\n // exhausted without size info\n return [0, 0];\n }\n if (d[i] !== 0xFF) {\n return [0, 0];\n }\n if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {\n if (i + 8 < len) {\n return [\n d[i + 7] << 8 | d[i + 8],\n d[i + 5] << 8 | d[i + 6]\n ];\n }\n return [0, 0];\n }\n i += 2;\n blockLength = d[i] << 8 | d[i + 1];\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';\nimport { ImageRenderer } from '../ImageRenderer';\nimport { CELL_SIZE_DEFAULT } from '../ImageStorage';\nimport { imageType } from '../IIPMetrics';\nimport { KittyImageStorage } from './KittyImageStorage';\nimport Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\nimport {\n KittyAction,\n KittyFormat,\n KittyCompression,\n IKittyCommand,\n IPendingTransmission,\n IKittyImageData,\n KittyPixelConstants,\n parseKittyCommand\n} from './KittyGraphicsTypes';\n\nconst enum Constants {\n // Memory limit for base64 decoder (4MB, same as IIPHandler)\n DECODER_KEEP_DATA = 4194304,\n DECODER_INITIAL_DATA = 4194304, // 4MB\n // Local mirror of const enum (esbuild can't inline const enums from external packages)\n DECODER_OK = 0,\n // Maximum control data size\n MAX_CONTROL_DATA_SIZE = 512,\n // Semicolon codepoint\n SEMICOLON = 0x3B\n}\n\nconst DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;\n\n// Kitty graphics protocol handler with streaming base64 decoding.\nexport class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {\n private _aborted = false;\n private _generation = 0;\n private _decodeError = false;\n\n private _activeDecoder: Base64Decoder | null = null;\n private readonly _maxEncodedBytes: number;\n private readonly _initialEncodedBytes: number;\n\n // Streaming related states\n\n // True while receiving control data (before semicolon).\n private _inControlData = true;\n\n // Buffer for control data.\n private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);\n private _controlLength = 0;\n\n // Pre-calculated encoded size limit\n private _encodedSizeLimit = 0;\n private _totalEncodedSize = 0;\n\n // Parsed command. These are the control data before semicolon.\n private _parsedCommand: IKittyCommand | null = null;\n\n // Storage related states\n\n private _pendingTransmissions: Map = new Map();\n // Tracks the pending key of the most recently started chunked upload.\n // Per spec, subsequent chunks only need m= (and optionally q=), without i=.\n // When a chunk arrives with no i=, this key is used to find the pending upload.\n private _lastPendingKey: number | undefined;\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _kittyStorage: KittyImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n // Convert decoded size limit -> max encoded bytes.\n this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);\n // ensure we preallocate more than configured limit while using 4mb initial size.\n this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);\n }\n\n public reset(): void {\n this._generation++;\n this._cleanupAllPending();\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n this._kittyStorage.reset();\n }\n\n public dispose(): void {\n this.reset();\n }\n\n private _removePendingEntry(key: number): void {\n this._pendingTransmissions.delete(key);\n if (this._lastPendingKey === key) {\n this._lastPendingKey = undefined;\n }\n }\n\n private _cleanupAllPending(): void {\n for (const pending of this._pendingTransmissions.values()) {\n pending.decoder.release();\n }\n this._pendingTransmissions.clear();\n this._lastPendingKey = undefined;\n }\n\n public start(): void {\n this._aborted = false;\n this._decodeError = false;\n this._inControlData = true;\n this._controlLength = 0;\n this._parsedCommand = null;\n // Pre-calculate encoded limit once: base64 is 4 bytes encoded \u2192 3 bytes decoded\n this._encodedSizeLimit = this._maxEncodedBytes;\n this._totalEncodedSize = 0;\n this._activeDecoder = null;\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n if (!this._inControlData) {\n this._streamPayload(data, start, end);\n } else {\n // Scan for semicolon\n let controlEnd = end;\n for (let i = start; i < end; i++) {\n if (data[i] === Constants.SEMICOLON) {\n this._inControlData = false;\n controlEnd = i;\n break;\n }\n }\n\n // Copy control data\n const copyLength = controlEnd - start;\n if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {\n this._aborted = true;\n return;\n }\n this._controlData.set(data.subarray(start, controlEnd), this._controlLength);\n this._controlLength += copyLength;\n\n if (!this._inControlData) {\n // Found semicolon - parse control data early for validation\n this._parsedCommand = parseKittyCommand(this._parseControlDataString());\n\n // Early validation: i+I conflict\n if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);\n this._aborted = true;\n return;\n }\n\n // Delete action doesn't need payload - skip streaming\n if (this._parsedCommand.action === KittyAction.DELETE) {\n return;\n }\n\n // Stream remaining as payload\n const payloadStart = controlEnd + 1;\n if (payloadStart < end) {\n this._streamPayload(data, payloadStart, end);\n }\n }\n }\n }\n\n // Stream payload bytes into the base64 decoder.\n private _streamPayload(data: Uint32Array, start: number, end: number): void {\n if (this._aborted) return;\n\n // Check size limit (compare encoded bytes against pre-calculated limit)\n // Include cumulative size from pending transmission for multi-chunk images.\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;\n const pending = this._pendingTransmissions.get(pendingKey);\n const previousEncodedSize = pending?.totalEncodedSize ?? 0;\n this._totalEncodedSize += end - start;\n const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;\n if (cumulativeEncodedSize > this._encodedSizeLimit) {\n const decoderToRelease = this._activeDecoder ?? pending?.decoder;\n if (decoderToRelease) {\n decoderToRelease.release();\n }\n this._activeDecoder = null;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n this._aborted = true;\n return;\n }\n\n if (this._decodeError) return;\n\n if (pending?.decoder && !this._activeDecoder) {\n this._activeDecoder = pending.decoder;\n }\n if (!this._activeDecoder) {\n // Budget WASM capacity, including one page of decoder state and rounding.\n const decoderCapacity = this._maxEncodedBytes + 131072;\n if (decoderCapacity > this._opts.storageLimit * 1000000) {\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));\n while (this._pendingTransmissions.size >= maxPending) {\n const oldest = this._pendingTransmissions.entries().next().value;\n if (!oldest) break;\n oldest[1].decoder.release();\n this._removePendingEntry(oldest[0]);\n if (oldest[1].cmd.id !== undefined) {\n this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);\n }\n }\n const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);\n try {\n decoder.init();\n } catch (e) {\n // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.\n console.warn('KITTY: could not allocate decoder', e);\n this._aborted = true;\n if (this._parsedCommand?.id !== undefined) {\n this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);\n }\n return;\n }\n this._activeDecoder = decoder;\n }\n\n if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n this._decodeError = true;\n if (pending) {\n this._removePendingEntry(pendingKey);\n }\n }\n }\n\n public end(success: boolean): boolean | Promise {\n if (this._aborted || !success) {\n if (this._activeDecoder) {\n this._activeDecoder.release();\n this._activeDecoder = null;\n }\n return true;\n }\n\n // No semicolon = no payload (delete, capability query)\n if (this._inControlData) {\n return this._handleNoPayloadCommand();\n }\n\n // Use command parsed early in put() - i+I already validated there\n const cmd = this._parsedCommand!;\n\n // Delete action was handled by skipping payload - just execute\n if (cmd.action === KittyAction.DELETE) {\n return this._handleDelete(cmd);\n }\n\n // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.\n const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;\n const isMoreComing = cmd.more === 1;\n const pending = this._pendingTransmissions.get(pendingKey);\n\n if (isMoreComing) {\n if (this._activeDecoder) {\n if (pending) {\n pending.totalEncodedSize += this._totalEncodedSize;\n pending.decodeError = pending.decodeError || this._decodeError;\n } else {\n this._pendingTransmissions.set(pendingKey, {\n cmd: { ...cmd },\n decoder: this._activeDecoder,\n totalEncodedSize: this._totalEncodedSize,\n decodeError: this._decodeError\n });\n }\n this._lastPendingKey = pendingKey;\n this._activeDecoder = null;\n }\n return true;\n }\n\n // Final chunk received \u2014 clear the last pending key\n if (pending) {\n this._lastPendingKey = undefined;\n }\n\n let decodeError = this._decodeError;\n let finalCmd = cmd;\n let decoder = this._activeDecoder;\n\n if (pending) {\n finalCmd = pending.cmd;\n decoder = pending.decoder;\n decodeError = decodeError || pending.decodeError;\n this._pendingTransmissions.delete(pendingKey);\n }\n\n let imageBytes = new Uint8Array(0);\n if (decoder) {\n if (decoder.end() !== DECODER_OK) {\n decodeError = true;\n }\n imageBytes = decoder.data8;\n }\n this._activeDecoder = null;\n\n // Handle command first \u2014 handlers create Blob/ImageData from imageBytes,\n // which copies the data. Only then is it safe to release the decoder's\n // wasm memory that imageBytes points into.\n const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);\n if (decoder) {\n decoder.release();\n }\n return result;\n }\n\n // Command handling\n\n private _parseControlDataString(): string {\n let str = '';\n for (let i = 0; i < this._controlLength; i++) {\n str += String.fromCodePoint(this._controlData[i]);\n }\n return str;\n }\n\n private _handleNoPayloadCommand(): boolean | Promise {\n const cmd = parseKittyCommand(this._parseControlDataString());\n\n // Per spec: specifying both i and I is an error\n if (cmd.id !== undefined && cmd.imageNumber !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);\n return true;\n }\n\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.DELETE:\n return this._handleDelete(cmd);\n case KittyAction.QUERY:\n this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);\n return true;\n case KittyAction.PLACEMENT:\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n const action = cmd.action ?? 't';\n\n switch (action) {\n case KittyAction.TRANSMIT: {\n const result = this._handleTransmit(cmd, bytes, decodeError);\n // Only send response when _handleTransmit didn't already respond\n // (it handles unsupported transmission medium responses internally)\n if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {\n if (decodeError) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n } else if (bytes.length > 0) {\n this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);\n }\n }\n return result;\n }\n case KittyAction.TRANSMIT_DISPLAY:\n return this._handleTransmitDisplay(cmd, bytes, decodeError);\n case KittyAction.QUERY:\n return this._handleQuery(cmd, bytes, decodeError);\n case KittyAction.PLACEMENT:\n // a=p ignores any payload \u2014 image data was already transmitted\n return this._handlePlacement(cmd);\n default:\n // TODO: Implement remaining actions when needed:\n // - a=f (frame): animation frame operations\n // - a=a (animation): animation control\n // - a=c (compose): compose images\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);\n }\n return true;\n }\n }\n\n private _handlePlacement(cmd: IKittyCommand): boolean | Promise {\n if (cmd.id === undefined) {\n return true;\n }\n const id = cmd.id;\n const image = this._kittyStorage.getImage(id);\n if (!image) {\n this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);\n return true;\n }\n const result = this._displayImage(image, cmd);\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);\n return true;\n });\n }\n\n private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n // TODO: Support file-based transmission modes (t=f, t=t, t=s)\n // Currently only supports direct transmission (t=d, the default).\n // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.\n // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.\n // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.\n // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:\n // 1. Check cmd.transmission (t key) before treating bytes as image data\n // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents\n // 3. For t=d: treat bytes as image data (current behavior)\n // When implementing, also update _handleQuery to accept these transmission mediums.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);\n }\n return true;\n }\n\n if (decodeError || bytes.length === 0) return true;\n\n this._kittyStorage.storeImage(cmd.id, {\n data: new Blob([bytes as BlobPart]),\n width: cmd.width ?? 0,\n height: cmd.height ?? 0,\n format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,\n compression: cmd.compression ?? ''\n });\n return true;\n }\n\n private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise {\n if (decodeError) {\n if (cmd.id !== undefined) {\n this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);\n }\n return true;\n }\n\n this._handleTransmit(cmd, bytes, decodeError);\n\n const id = cmd.id ?? this._kittyStorage.lastImageId;\n const image = this._kittyStorage.getImage(id);\n if (image) {\n const result = this._displayImage(image, cmd);\n if (cmd.id !== undefined) {\n return result.then(success => {\n this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);\n return true;\n });\n }\n return result.then(() => true);\n }\n return true;\n }\n\n private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {\n const id = cmd.id ?? 0;\n const quiet = cmd.quiet ?? 0;\n\n // Per spec: reject unsupported transmission mediums (only t=d is supported atm)\n // TODO: When filesystem support is added (Node.js/Electron), update this to accept\n // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.\n const transmission = cmd.transmission ?? 'd';\n if (transmission !== 'd') {\n this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);\n return true;\n }\n\n // Check decode error first (invalid base64)\n if (decodeError) {\n this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);\n return true;\n }\n\n // Capability query (no payload) - just respond OK\n if (bytes.length === 0) {\n this._sendResponse(id, 'OK', quiet);\n return true;\n }\n\n const format = cmd.format ?? KittyFormat.RGBA;\n\n if (format === KittyFormat.PNG) {\n this._sendResponse(id, 'OK', quiet);\n } else {\n const width = cmd.width ?? 0;\n const height = cmd.height ?? 0;\n\n if (!width || !height) {\n this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);\n return true;\n }\n\n const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);\n return true;\n }\n\n this._sendResponse(id, 'OK', quiet);\n }\n return true;\n }\n\n private _handleDelete(cmd: IKittyCommand): boolean {\n // Per spec: default delete selector is 'a' (delete all visible placements)\n const selector = cmd.deleteSelector ?? 'a';\n\n // TODO: Distinguish lowercase (delete placements only) from uppercase\n // (delete placements + free stored image data). Currently both variants\n // free everything since we don't separate stored data from placements.\n switch (selector) {\n case 'a':\n case 'A':\n this._cleanupAllPending();\n this._kittyStorage.deleteAll();\n break;\n case 'i':\n case 'I':\n // TODO: When placement id tracking is implemented (see TODO in\n // KittyImageStorage), d=i with p= should delete only that\n // specific placement, while d=i without p should delete all\n // placements for the image.\n if (cmd.id !== undefined) {\n const pending = this._pendingTransmissions.get(cmd.id);\n if (pending) {\n pending.decoder.release();\n }\n this._removePendingEntry(cmd.id);\n this._kittyStorage.deleteById(cmd.id);\n }\n break;\n default:\n // Unsupported selectors (c, n, p, q, r, x, y, z, f) \u2014 ignore for now\n break;\n }\n return true;\n }\n\n private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {\n const isOk = message === 'OK';\n if (isOk && quiet >= 1) return;\n if (!isOk && quiet >= 2) return;\n\n const pPart = placementId ? `,p=${placementId}` : '';\n const response = `\\x1b_Gi=${id}${pPart};${message}\\x1b\\\\`;\n this._coreTerminal._core.coreService.triggerDataEvent(response);\n }\n\n // Image display\n\n private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise {\n return this._decodeAndDisplay(image, cmd)\n .then(() => true)\n .catch(() => false);\n }\n\n private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {\n const generation = this._generation;\n let bitmap: ImageBitmap | undefined = await this._createBitmap(image);\n\n try {\n if (generation !== this._generation) throw new Error('image decode canceled');\n const cropX = Math.max(0, cmd.x ?? 0);\n const cropY = Math.max(0, cmd.y ?? 0);\n const cropW = cmd.sourceWidth || (bitmap.width - cropX);\n const cropH = cmd.sourceHeight || (bitmap.height - cropY);\n\n const maxCropW = Math.max(0, bitmap.width - cropX);\n const maxCropH = Math.max(0, bitmap.height - cropY);\n const finalCropW = Math.max(0, Math.min(cropW, maxCropW));\n const finalCropH = Math.max(0, Math.min(cropH, maxCropH));\n\n if (finalCropW === 0 || finalCropH === 0) {\n throw new Error('invalid source rectangle');\n }\n\n if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {\n const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);\n bitmap.close();\n bitmap = cropped;\n }\n\n const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;\n const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;\n\n // Per spec: c/r default to image's natural cell dimensions.\n // If only one of c/r is specified, compute the other from image aspect ratio.\n let imgCols: number;\n let imgRows: number;\n if (cmd.columns !== undefined && cmd.rows !== undefined) {\n imgCols = cmd.columns;\n imgRows = cmd.rows;\n } else if (cmd.columns !== undefined) {\n imgCols = cmd.columns;\n imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));\n } else if (cmd.rows !== undefined) {\n imgRows = cmd.rows;\n imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));\n } else {\n imgCols = Math.ceil(bitmap.width / cw);\n imgRows = Math.ceil(bitmap.height / ch);\n }\n\n let w = bitmap.width;\n let h = bitmap.height;\n\n // Scale bitmap to fit placement rectangle when c/r are specified\n if (cmd.columns !== undefined || cmd.rows !== undefined) {\n w = Math.round(imgCols * cw);\n h = Math.round(imgRows * ch);\n }\n\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n\n // Save cursor position before addImage modifies it\n const buffer = this._coreTerminal._core.buffer;\n const savedX = buffer.x;\n const savedY = buffer.y;\n const savedYbase = buffer.ybase;\n\n // Determine layer based on z-index: negative = behind text, 0+ = on top.\n // When z<0 we always use the bottom layer even without allowTransparency \u2014\n // the image will simply be hidden behind the opaque text background, which\n // is the correct behavior (client asked for \"behind text\").\n const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;\n const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';\n\n if (w !== bitmap.width || h !== bitmap.height) {\n const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });\n bitmap.close();\n bitmap = scaled;\n }\n\n // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions\n const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);\n const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);\n if (xOffset !== 0 || yOffset !== 0) {\n // Per spec: X/Y is not added to c/r area. When c/r are explicit, the\n // total placement area remains c*cw \u00D7 r*ch pixels and the offset image\n // is clipped to fit. When c/r are unset, the padded canvas determines\n // the natural cell dimensions.\n const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;\n const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;\n const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);\n const offsetCtx = offsetCanvas.getContext('2d');\n if (!offsetCtx) {\n throw new Error('Failed to create offset canvas context');\n }\n offsetCtx.drawImage(bitmap, xOffset, yOffset);\n\n const offsetBitmap = await createImageBitmap(offsetCanvas);\n offsetCanvas.width = offsetCanvas.height = 0;\n bitmap.close();\n bitmap = offsetBitmap;\n w = bitmap.width;\n h = bitmap.height;\n if (w * h > this._opts.pixelLimit) {\n throw new Error('image exceeds pixel limit');\n }\n if (cmd.columns === undefined) {\n imgCols = Math.ceil(bitmap.width / cw);\n }\n if (cmd.rows === undefined) {\n imgRows = Math.ceil(bitmap.height / ch);\n }\n }\n\n if (generation !== this._generation) throw new Error('image decode canceled');\n const zIndex = cmd.zIndex ?? 0;\n this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);\n bitmap = undefined; // ownership transferred to storage\n\n // Kitty cursor movement\n // Per spec: cursor placed at first column after last image column,\n // on the last row of the image. C=1 means don't move cursor.\n if (cmd.cursorMovement === 1) {\n // C=1: restore cursor to position before image was placed\n const scrolled = buffer.ybase - savedYbase;\n buffer.x = savedX;\n // Can't restore cursor to scrollback?\n buffer.y = Math.max(savedY - scrolled, 0);\n } else {\n // Default (C=0): advance cursor horizontally past the image\n // addImage already positioned cursor on the last row via lineFeeds\n buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);\n }\n } catch (e) {\n bitmap?.close();\n throw e;\n }\n }\n\n // Create ImageBitmap from already-decoded image data.\n private async _createBitmap(image: IKittyImageData): Promise {\n let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());\n\n if (image.compression === KittyCompression.ZLIB) {\n bytes = await this._decompressZlib(bytes);\n }\n\n if (image.format === KittyFormat.PNG) {\n const metrics = imageType(bytes);\n // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes\n // back negative and a bare `>` pixel-limit test passes it; require positive.\n if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {\n throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');\n }\n const blob = new Blob([bytes as BlobPart], { type: 'image/png' });\n if (!window.createImageBitmap) {\n const url = URL.createObjectURL(blob);\n const img = new Image();\n return new Promise((resolve, reject) => {\n img.addEventListener('load', () => {\n URL.revokeObjectURL(url);\n const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);\n canvas.getContext('2d')?.drawImage(img, 0, 0);\n createImageBitmap(canvas).then(resolve).catch(reject);\n });\n img.addEventListener('error', () => {\n URL.revokeObjectURL(url);\n reject(new Error('Failed to load image'));\n });\n img.src = url;\n });\n }\n return createImageBitmap(blob);\n }\n\n // Raw pixel data\n const width = image.width;\n const height = image.height;\n\n if (!width || !height) {\n throw new Error('Width and height required for raw pixel data');\n }\n\n const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n const expectedBytes = width * height * bytesPerPixel;\n\n if (bytes.length < expectedBytes) {\n throw new Error('Insufficient pixel data');\n }\n\n const pixelCount = width * height;\n\n if (image.format === KittyFormat.RGBA) {\n // RGBA: use bytes directly \u2014 no copy needed\n return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));\n }\n\n // RGB\u2192RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).\n // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes \u2014 ~6x faster.\n // Assumes little-endian (all modern browsers/Node.js).\n const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);\n const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));\n const dst32 = new Uint32Array(data.buffer);\n const alignedPixels = pixelCount & ~3; // round down to multiple of 4\n\n let srcOffset = 0;\n let dstOffset = 0;\n for (let i = 0; i < alignedPixels; i += 4) {\n const b0 = src32[srcOffset++];\n const b1 = src32[srcOffset++];\n const b2 = src32[srcOffset++];\n // Little-endian: pixel bytes are [R,G,B] \u2192 uint32 ABGR layout\n dst32[dstOffset++] = 0xFF000000 | b0;\n dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);\n dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);\n dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);\n }\n\n // Handle remaining 1\u20133 pixels\n let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n for (let i = alignedPixels; i < pixelCount; i++) {\n data[dstByte] = bytes[srcByte];\n data[dstByte + 1] = bytes[srcByte + 1];\n data[dstByte + 2] = bytes[srcByte + 2];\n data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;\n srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;\n dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;\n }\n\n return createImageBitmap(new ImageData(data, width, height));\n }\n\n private async _decompressZlib(compressed: Uint8Array): Promise {\n try {\n return await this._decompress(compressed, 'deflate');\n } catch (error) {\n if (error instanceof RangeError) throw error;\n return await this._decompress(compressed, 'deflate-raw');\n }\n }\n\n private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise {\n const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);\n let offsetIn = 0;\n // Bound inflation within one native transform before its output is budgeted.\n const source = new ReadableStream({\n pull(controller) {\n if (offsetIn >= compressed.length) {\n controller.close();\n return;\n }\n const end = Math.min(offsetIn + 4096, compressed.length);\n controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));\n offsetIn = end;\n }\n });\n const reader = source.pipeThrough(new DecompressionStream(format)).getReader();\n const chunks: Uint8Array[] = [];\n let totalLength = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n totalLength += value.byteLength;\n if (totalLength > limit) {\n await reader.cancel().catch(() => {});\n throw new RangeError('decompressed image exceeds byte limit');\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n }\n\n public get images(): ReadonlyMap {\n return this._kittyStorage.images;\n }\n\n public get _kittyIdToStorageId(): ReadonlyMap {\n return this._kittyStorage.kittyIdToStorageId;\n }\n\n public get pendingTransmissions(): ReadonlyMap {\n return this._pendingTransmissions;\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Kitty graphics protocol types, constants, and parsing utilities.\n */\n\nimport type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';\n\n// Kitty graphics protocol action types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.\nexport const enum KittyAction {\n TRANSMIT = 't',\n TRANSMIT_DISPLAY = 'T',\n QUERY = 'q',\n PLACEMENT = 'p',\n DELETE = 'd'\n}\n\n// Kitty graphics protocol format types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyFormat {\n RGB = 24,\n RGBA = 32,\n PNG = 100\n}\n\n// Kitty graphics protocol compression types.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.\nexport const enum KittyCompression {\n NONE = '',\n ZLIB = 'z'\n}\n\n// Kitty graphics protocol control data keys.\n// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference\nexport const enum KittyKey {\n // Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)\n ACTION = 'a',\n // Image format (24=RGB, 32=RGBA, 100=PNG)\n FORMAT = 'f',\n // Image ID for referencing stored images\n ID = 'i',\n // Image number (alternative to ID, terminal assigns ID)\n IMAGE_NUMBER = 'I',\n // Source image width in pixels\n WIDTH = 's',\n // Source image height in pixels\n HEIGHT = 'v',\n // The left edge (in pixels) of the image area to display\n X_OFFSET = 'x',\n // The top edge (in pixels) of the image area to display\n Y_OFFSET = 'y',\n // Width (in pixels) of the source rectangle to display\n SOURCE_WIDTH = 'w',\n // Height (in pixels) of the source rectangle to display\n SOURCE_HEIGHT = 'h',\n // Horizontal offset (in pixels) within the first cell\n X_PLACEMENT_OFFSET = 'X',\n // Vertical offset (in pixels) within the first cell\n Y_PLACEMENT_OFFSET = 'Y',\n // Number of terminal columns to display the image over\n COLUMNS = 'c',\n // Number of terminal rows to display the image over\n ROWS = 'r',\n // More data flag (1=more chunks coming, 0=final chunk)\n MORE = 'm',\n // Compression type (z=zlib). This is essential for chunking larger images.\n COMPRESSION = 'o',\n // Quiet mode (1=suppress OK responses, 2=suppress error responses)\n QUIET = 'q',\n // Cursor movement policy (0=move cursor after image, 1=don't move cursor)\n CURSOR_MOVEMENT = 'C',\n // Z-index for image layering (negative = behind text, 0+ = on top)\n Z_INDEX = 'z',\n // Transmission medium (d=direct, f=file, t=temp file, s=shared memory)\n TRANSMISSION = 't',\n // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) \u2014 only used when a=d\n DELETE_SELECTOR = 'd',\n // Placement ID for targeting specific placements\n PLACEMENT_ID = 'p'\n}\n\n// Pixel format constants\nexport const enum KittyPixelConstants {\n BYTES_PER_PIXEL_RGB = 3,\n BYTES_PER_PIXEL_RGBA = 4,\n ALPHA_OPAQUE = 255\n}\n\n// Parsed Kitty graphics command.\nexport interface IKittyCommand {\n action?: string;\n format?: number;\n id?: number;\n imageNumber?: number;\n width?: number;\n height?: number;\n x?: number;\n y?: number;\n sourceWidth?: number;\n sourceHeight?: number;\n xOffset?: number;\n yOffset?: number;\n columns?: number;\n rows?: number;\n more?: number;\n quiet?: number;\n cursorMovement?: number;\n zIndex?: number;\n transmission?: string;\n deleteSelector?: string;\n placementId?: number;\n compression?: string;\n payload?: string;\n}\n\n// Pending chunked transmission state.\n// Stores metadata from the first chunk while accumulating decoded payload data.\nexport interface IPendingTransmission {\n // The parsed command from the first chunk (contains action, format, dimensions, etc.)\n cmd: IKittyCommand;\n // Decoder used across chunked payloads\n decoder: Base64Decoder;\n // Total encoded (base64) bytes received across all chunks - for size limit enforcement\n totalEncodedSize: number;\n // Whether any chunk has failed to decode\n decodeError: boolean;\n}\n\n// Stored Kitty image data.\nexport interface IKittyImageData {\n id: number;\n // Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit\n data: Blob;\n width: number;\n height: number;\n format: 24 | 32 | 100;\n compression?: string;\n}\n\n// Parses Kitty graphics control data into a command object.\nexport function parseKittyCommand(data: string): IKittyCommand {\n const cmd: IKittyCommand = {};\n const parts = data.split(',');\n\n for (const part of parts) {\n const eqIdx = part.indexOf('=');\n if (eqIdx === -1) continue;\n\n const key = part.substring(0, eqIdx);\n const value = part.substring(eqIdx + 1);\n\n // Handle string keys first\n if (key === KittyKey.ACTION) {\n cmd.action = value;\n continue;\n }\n if (key === KittyKey.COMPRESSION) {\n cmd.compression = value;\n continue;\n }\n if (key === KittyKey.TRANSMISSION) {\n cmd.transmission = value;\n continue;\n }\n if (key === KittyKey.DELETE_SELECTOR) {\n cmd.deleteSelector = value;\n continue;\n }\n const numValue = parseInt(value, 10);\n switch (key) {\n case KittyKey.FORMAT: cmd.format = numValue; break;\n case KittyKey.ID: cmd.id = numValue; break;\n case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;\n case KittyKey.WIDTH: cmd.width = numValue; break;\n case KittyKey.HEIGHT: cmd.height = numValue; break;\n case KittyKey.X_OFFSET: cmd.x = numValue; break;\n case KittyKey.Y_OFFSET: cmd.y = numValue; break;\n case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;\n case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;\n case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;\n case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;\n case KittyKey.COLUMNS: cmd.columns = numValue; break;\n case KittyKey.ROWS: cmd.rows = numValue; break;\n case KittyKey.MORE: cmd.more = numValue; break;\n case KittyKey.QUIET: cmd.quiet = numValue; break;\n case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;\n case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;\n case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;\n }\n }\n\n return cmd;\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IDisposable } from '@xterm/xterm';\nimport { ImageStorage } from '../ImageStorage';\nimport { ImageLayer, IAddImageOpts } from '../Types';\nimport { IKittyImageData } from './KittyGraphicsTypes';\n\n// Kitty-specific image storage controller.\n//\n// Wraps shared ImageStorage with kitty protocol semantics:\n// - tracks transmitted image payloads by kitty image id\n// - tracks kitty image id -> shared ImageStorage id mapping for displayed images\n// - mirrors shared-storage evictions into kitty maps\n// - applies protocol-level undisplayed-image eviction policy\nexport class KittyImageStorage implements IDisposable {\n private static readonly _maxStoredImages = 256;\n\n private _nextImageId = 1;\n private readonly _images: Map = new Map();\n // TODO: Support multiple placements per image. The kitty spec identifies\n // placements by an (image id, placement id) pair \u2014 same i + different p\n // values should coexist, and same i + same p should replace the prior\n // placement. Currently we track only one storage entry per kitty image id,\n // so multiple placements of the same image overwrite each other. Fixing\n // this requires changing these maps to Map>\n // (kittyId \u2192 placementId \u2192 storageId) and updating addImage/deleteById\n // accordingly. The underlying shared ImageStorage would also need to\n // support multiple entries per logical image.\n private readonly _kittyIdToStorageId: Map = new Map();\n private readonly _storageIdToKittyId: Map = new Map();\n\n private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;\n private readonly _wrappedOnImageDeleted: (storageId: number) => void;\n private readonly _handleStorageImageDeleted = (storageId: number): void => {\n const kittyId = this._storageIdToKittyId.get(storageId);\n if (kittyId !== undefined) {\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n this._images.delete(kittyId);\n }\n };\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n\n constructor(\n private readonly _storage: ImageStorage\n ) {\n this._previousOnImageDeleted = this._storage.onImageDeleted;\n this._wrappedOnImageDeleted = (storageId: number) => {\n this._previousOnImageDeleted?.(storageId);\n this._handleStorageImageDeleted(storageId);\n };\n this._storage.onImageDeleted = this._wrappedOnImageDeleted;\n }\n\n public reset(): void {\n this._nextImageId = 1;\n this._images.clear();\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public dispose(): void {\n this.reset();\n if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {\n this._storage.onImageDeleted = this._previousOnImageDeleted;\n }\n }\n\n public storeImage(id: number | undefined, imageData: Omit): number {\n const imageId = id ?? this._nextImageId++;\n\n const oldStorageId = this._kittyIdToStorageId.get(imageId);\n if (oldStorageId !== undefined) {\n this._storage.deleteImage(oldStorageId);\n this._kittyIdToStorageId.delete(imageId);\n this._storageIdToKittyId.delete(oldStorageId);\n }\n\n if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {\n this._evictUndisplayedImages();\n }\n\n // Encoded images awaiting placement are outside ImageStorage's pixel budget.\n // Unplaced payloads are evicted first so a new upload cannot erase a visible\n // image while abandoned blobs still hold budget; placed ones go only when\n // that is not enough, because the byte cap is a hard bound. The new image is\n // always stored, so an oversized one overshoots by at most one payload\n // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.\n const byteLimit = this._storage.getLimit() * 1000000;\n this._images.delete(imageId);\n let retainedBytes = 0;\n for (const image of this._images.values()) retainedBytes += image.data.size;\n for (const evictPlaced of [false, true]) {\n for (const [oldestId, image] of this._images) {\n if (retainedBytes + imageData.data.size <= byteLimit) break;\n if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;\n retainedBytes -= image.data.size;\n this.deleteById(oldestId);\n }\n }\n\n this._images.set(imageId, {\n ...imageData,\n id: imageId\n });\n return imageId;\n }\n\n public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {\n // Clean up stale reverse-mapping from a previous placement of the same\n // kitty image. The old shared-storage entry is kept (it may still be\n // visible on screen) but its reverse mapping is removed so that eviction\n // of the old entry won't incorrectly delete the kitty image data.\n const oldStorageId = this._kittyIdToStorageId.get(kittyId);\n if (oldStorageId !== undefined) {\n this._storageIdToKittyId.delete(oldStorageId);\n }\n this._addImageOpts.scrolling = scrolling;\n this._addImageOpts.layer = layer;\n this._addImageOpts.zIndex = zIndex;\n const storageId = this._storage.addImage(image, this._addImageOpts);\n this._kittyIdToStorageId.set(kittyId, storageId);\n this._storageIdToKittyId.set(storageId, kittyId);\n }\n\n public getImage(kittyId: number): IKittyImageData | undefined {\n return this._images.get(kittyId);\n }\n\n public deleteById(kittyId: number): void {\n this._images.delete(kittyId);\n const storageId = this._kittyIdToStorageId.get(kittyId);\n if (storageId !== undefined) {\n this._storage.deleteImage(storageId);\n this._kittyIdToStorageId.delete(kittyId);\n this._storageIdToKittyId.delete(storageId);\n }\n }\n\n public deleteAll(): void {\n this._images.clear();\n for (const storageId of this._kittyIdToStorageId.values()) {\n this._storage.deleteImage(storageId);\n }\n this._kittyIdToStorageId.clear();\n this._storageIdToKittyId.clear();\n }\n\n public get images(): ReadonlyMap {\n return this._images;\n }\n\n public get kittyIdToStorageId(): ReadonlyMap {\n return this._kittyIdToStorageId;\n }\n\n public get lastImageId(): number {\n return this._nextImageId - 1;\n }\n\n private _evictUndisplayedImages(): void {\n for (const [kittyId] of this._images) {\n if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {\n break;\n }\n if (!this._kittyIdToStorageId.has(kittyId)) {\n this._images.delete(kittyId);\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';\nimport { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';\nimport { RGBA8888 } from 'sixel/lib/Types';\nimport { ImageRenderer } from './ImageRenderer';\n\nimport { DecoderAsync, Decoder } from 'sixel/lib/Decoder';\nimport { LIMITS } from 'sixel/lib/wasm';\n\n// always free decoder ressources after decoding if it exceeds this limit\nconst MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB\n\n// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)\nconst DEFAULT_PALETTE = PALETTE_ANSI_256;\nDEFAULT_PALETTE.set(PALETTE_VT340_COLOR);\n\n// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories\n// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only\n// while a SIXEL sequence is open, so idle terminals hold none.\nconst MAX_IDLE_DECODERS = 2;\nconst idleDecoders = new Map();\nlet poolPrimed = false;\n\nfunction primeDecoderPool(memoryLimit: number): void {\n if (poolPrimed) return;\n poolPrimed = true;\n // Async compile once, off the parser's hot path; later decoders reuse the cached module.\n DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(\n d => releaseDecoder(d, memoryLimit),\n () => { poolPrimed = false; }\n );\n}\n\nfunction acquireDecoder(memoryLimit: number): Decoder {\n return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });\n}\n\nfunction releaseDecoder(dec: Decoder, memoryLimit: number): void {\n if (dec.memoryUsage > MEM_PERMA_LIMIT) {\n dec.release();\n }\n const idle = idleDecoders.get(memoryLimit) ?? [];\n if (idle.length < MAX_IDLE_DECODERS) {\n idle.push(dec);\n idleDecoders.set(memoryLimit, idle);\n }\n}\n\n\nexport class SixelHandler implements IDcsHandler, IResetHandler {\n private _size = 0;\n private _aborted = false;\n private _dec: Decoder | undefined;\n private _decMemoryLimit = 0;\n // Color registers outlive a single image, so they live here rather than in a pooled decoder.\n private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);\n\n constructor(\n private readonly _opts: IImageAddonOptions,\n private readonly _storage: SixelImageStorage,\n private readonly _coreTerminal: ITerminalExt\n ) {\n this._palette.set(DEFAULT_PALETTE);\n primeDecoderPool(this._opts.pixelLimit * 4);\n }\n\n public reset(): void {\n this._returnDecoder();\n this._palette.fill(0);\n this._palette.set(DEFAULT_PALETTE);\n }\n\n public hook(params: IParams): void {\n this._size = 0;\n this._aborted = false;\n this._returnDecoder();\n const memoryLimit = this._opts.pixelLimit * 4;\n try {\n this._dec = acquireDecoder(memoryLimit);\n } catch (e) {\n // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.\n console.warn(`SIXEL: could not allocate decoder - ${e}`);\n this._aborted = true;\n return;\n }\n this._decMemoryLimit = memoryLimit;\n const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(\n this._coreTerminal._core._inputHandler._curAttrData,\n this._coreTerminal._core._themeService?.colors);\n this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);\n }\n\n private _returnDecoder(): void {\n const dec = this._dec;\n if (!dec) return;\n this._dec = undefined;\n this._palette.set(dec.palette);\n releaseDecoder(dec, this._decMemoryLimit);\n }\n\n public put(data: Uint32Array, start: number, end: number): void {\n if (this._aborted || !this._dec) {\n return;\n }\n this._size += end - start;\n if (this._size > this._opts.sixelSizeLimit) {\n console.warn(`SIXEL: too much data, aborting`);\n this._aborted = true;\n this._dec.release();\n return;\n }\n try {\n this._dec.decode(data, start, end);\n } catch (e) {\n console.warn(`SIXEL: error while decoding image - ${e}`);\n this._aborted = true;\n this._dec.release();\n }\n }\n\n public unhook(success: boolean): boolean | Promise {\n try {\n return this._unhook(success);\n } finally {\n this._returnDecoder();\n }\n }\n\n private _unhook(success: boolean): boolean {\n if (this._aborted || !success || !this._dec) {\n return true;\n }\n\n const width = this._dec.width;\n const height = this._dec.height;\n\n // partial fix for https://github.com/jerch/xterm-addon-image/issues/37\n if (!width || !height) {\n if (height) {\n this._storage.advanceCursor(height);\n }\n return true;\n }\n\n const canvas = ImageRenderer.createCanvas(undefined, width, height);\n canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0);\n this._storage.addImage(canvas);\n return true;\n }\n}\n\n\n/**\n * Some helpers to extract current terminal colors.\n */\n\n// get currently active background color from terminal\n// also respect INVERSE setting\nfunction extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet | undefined): RGBA8888 {\n let bg = 0;\n if (!colors) {\n // FIXME: theme service is prolly not available yet,\n // happens if .open() was not called yet (bug in core?)\n return bg;\n }\n if (attr.isInverse()) {\n if (attr.isFgDefault()) {\n bg = convertLe(colors.foreground.rgba);\n } else if (attr.isFgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getFgColor()].rgba);\n }\n } else {\n if (attr.isBgDefault()) {\n bg = convertLe(colors.background.rgba);\n } else if (attr.isBgRGB()) {\n const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());\n bg = toRGBA8888(...t);\n } else {\n bg = convertLe(colors.ansi[attr.getBgColor()].rgba);\n }\n }\n return bg;\n}\n\n// rgba values on the color managers are always in BE, thus convert to LE\nfunction convertLe(color: number): RGBA8888 {\n if (BIG_ENDIAN) return color;\n return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';\nimport { ImageRenderer } from './ImageRenderer';\n\n/**\n * Sixel-specific image storage controller.\n *\n * Wraps the shared ImageStorage with sixel protocol semantics:\n * - Cursor behavior governed by DECSET 80 (sixelScrolling option)\n * - advanceCursor for empty sixels carrying only height\n */\nexport class SixelImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };\n constructor(\n private readonly _storage: ImageStorage,\n private readonly _opts: IImageAddonOptions,\n private readonly _renderer: ImageRenderer,\n private readonly _terminal: ITerminalExt\n ) {}\n\n /**\n * Add a sixel image to storage.\n * Cursor behavior depends on the sixelScrolling option (DECSET 80).\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._addImageOpts.scrolling = this._opts.sixelScrolling;\n this._storage.addImage(img, this._addImageOpts);\n }\n\n /**\n * Only advance text cursor.\n * This is an edge case from empty sixels carrying only a height but no pixels.\n * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.\n */\n public advanceCursor(height: number): void {\n if (this._opts.sixelScrolling) {\n let cellSize = this._renderer.cellSize;\n if (cellSize.width === -1 || cellSize.height === -1) {\n cellSize = CELL_SIZE_DEFAULT;\n }\n const rows = Math.ceil(height / cellSize.height);\n for (let i = 1; i < rows; ++i) {\n this._terminal._core._inputHandler.lineFeed();\n }\n }\n }\n}\n", "/**\n * Copyright (c) 2023 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport { IAddImageOpts } from './Types';\nimport { ImageStorage } from './ImageStorage';\n\n/**\n * IIP (iTerm Image Protocol) specific image storage controller.\n *\n * Wraps the shared ImageStorage with IIP protocol semantics:\n * - Always uses scrolling mode (cursor advances with image)\n */\nexport class IIPImageStorage {\n private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };\n constructor(\n private readonly _storage: ImageStorage\n ) {}\n\n /**\n * Add an IIP image to storage.\n * Always uses scrolling mode \u2014 cursor advances past the image.\n */\n public addImage(img: HTMLCanvasElement | ImageBitmap): void {\n this._storage.addImage(img, this._addImageOpts);\n }\n}\n", "/**\n * Copyright (c) 2020 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ITerminalAddon, IDisposable } from '@xterm/xterm';\nimport type { ImageAddon as IImageApi } from '@xterm/addon-image';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { IIPHandler } from './IIPHandler';\nimport { ImageRenderer } from './ImageRenderer';\nimport { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';\nimport { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';\nimport { KittyImageStorage } from './kitty/KittyImageStorage';\nimport { SixelHandler } from './SixelHandler';\nimport { SixelImageStorage } from './SixelImageStorage';\nimport { IIPImageStorage } from './IIPImageStorage';\nimport { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';\n\n\n/**\n * Document VT features provided by this addon.\n *\n * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL \"SIXEL Graphics\" \"DCS Ps ; Ps ; Ps ; q Pt ST\" \"Draw SIXEL image.\"\n *\n * Sixel support is provided by the addon @xterm/addon-image with these limitations:\n * - immediate coloring (no shared palette, allows high color settings of `img2sixel`)\n * - max. palette size of 4096 colors\n * - max. pixel width of 16K\n * - max. 25 MB per sixel sequence\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 \"iTerm2 Commands\" \"OSC 1337 ; Pt BEL\" \"Custom iTerm2 commands.\"\n *\n * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with\n * the following limitations:\n * - sequence:\n * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL`\n * - size param must be set and payload may not exceed CEIL(size * 4 / 3)\n * - strict base64 handling as of RFC4648 \u00A74 (standard alphabet, optional padding,\n * no separator bytes allowed)\n * - supported params: size, name, width, height, preserveAspectRatio\n * - image formats: PNG, JPEG and GIF\n * - no animation support (renders first image of a GIF)\n * - no multipart support\n * - VT340 cursor positioning (begin of last sixel data row)\n *\n * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)\n * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.\n *\n *\n * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS \"Kitty Graphics\" \"APC G Pt ST\" \"Kitty Graphics Protocol.\"\n *\n * Kitty graphics support is provided by the addon @xterm/addon-image.\n * Note that while basic image output already works, this is still work in progress.\n */\n\n// default values of addon ctor options\nconst DEFAULT_OPTIONS: IImageAddonOptions = {\n enableSizeReports: true,\n pixelLimit: 16777216, // limit to 4096 * 4096 pixels\n sixelSupport: true,\n sixelScrolling: true,\n sixelPaletteLimit: 4096,\n sixelSizeLimit: 33554432,\n storageLimit: 128,\n showPlaceholder: true,\n iipSupport: true,\n iipSizeLimit: 33554432,\n kittySupport: true,\n kittySizeLimit: 33554432\n};\n\n// max palette size supported by the sixel lib (compile time setting)\nconst MAX_SIXEL_PALETTE_SIZE = 4096;\n\n// definitions for _xtermGraphicsAttributes sequence\nconst enum GaItem {\n COLORS = 1,\n SIXEL_GEO = 2,\n REGIS_GEO = 3\n}\nconst enum GaAction {\n READ = 1,\n SET_DEFAULT = 2,\n SET = 3,\n READ_MAX = 4\n}\nconst enum GaStatus {\n SUCCESS = 0,\n ITEM_ERROR = 1,\n ACTION_ERROR = 2,\n FAILURE = 3\n}\n\n\nexport class ImageAddon implements ITerminalAddon, IImageApi {\n private _opts: IImageAddonOptions;\n private _defaultOpts: IImageAddonOptions;\n private _storage: ImageStorage | undefined;\n private _renderer: ImageRenderer | undefined;\n private _disposables: IDisposable[] = [];\n private _terminal: ITerminalExt | undefined;\n private _handlers: Map = new Map();\n private readonly _onImageAdded = new Emitter();\n public readonly onImageAdded: IEvent = this._onImageAdded.event;\n\n constructor(opts?: Partial) {\n this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);\n this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);\n }\n\n public dispose(): void {\n for (const handler of this._handlers.values()) handler.reset();\n for (const obj of this._disposables) {\n obj.dispose();\n }\n this._disposables.length = 0;\n this._handlers.clear();\n this._onImageAdded.dispose();\n }\n\n private _disposeLater(...args: IDisposable[]): void {\n for (const obj of args) {\n this._disposables.push(obj);\n }\n }\n\n public activate(terminal: ITerminalExt): void {\n this._terminal = terminal;\n\n // internal data structures\n this._renderer = new ImageRenderer(terminal);\n this._storage = new ImageStorage(terminal, this._renderer, this._opts);\n this._storage.onImageAdded = () => this._onImageAdded.fire();\n\n // enable size reports\n if (this._opts.enableSizeReports) {\n const windowOps = terminal.options.windowOptions ?? {};\n windowOps.getWinSizePixels = true;\n windowOps.getCellSizePixels = true;\n windowOps.getWinSizeChars = true;\n terminal.options.windowOptions = windowOps;\n }\n\n this._disposeLater(\n this._renderer,\n this._storage,\n\n // DECSET/DECRST/DA1/XTSMGRAPHICS handlers\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),\n terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),\n terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),\n\n // render hook\n terminal.onRender(range => this._storage?.render(range)),\n\n /**\n * reset handlers covered:\n * - DECSTR\n * - RIS\n * - Terminal.reset()\n */\n terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),\n terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),\n terminal._core._inputHandler.onRequestReset(() => this.reset()),\n\n // wipe canvas and delete alternate images on buffer switch\n terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),\n\n // extend images to the right on resize\n terminal.onResize(metrics => this._storage?.viewportResize(metrics))\n );\n\n // SIXEL handler\n if (this._opts.sixelSupport) {\n const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);\n const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);\n this._handlers.set('sixel', sixelHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)\n );\n }\n\n // iTerm IIP handler\n if (this._opts.iipSupport) {\n const iipStorage = new IIPImageStorage(this._storage!);\n const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);\n this._handlers.set('iip', iipHandler);\n this._disposeLater(\n terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)\n );\n }\n\n // Kitty graphics handler\n if (this._opts.kittySupport) {\n const kittyStorage = new KittyImageStorage(this._storage!);\n const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);\n this._handlers.set('kitty', kittyHandler);\n this._disposeLater(\n kittyStorage,\n kittyHandler,\n terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)\n );\n }\n }\n\n // Note: storageLimit is skipped here to not intoduce a surprising side effect.\n public reset(): boolean {\n // reset options customizable by sequences to defaults\n this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n // also clear image storage\n this._storage?.reset();\n // reset protocol handlers\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return false;\n }\n\n public get storageLimit(): number {\n return this._storage?.getLimit() || -1;\n }\n\n public set storageLimit(limit: number) {\n this._storage?.setLimit(limit);\n this._opts.storageLimit = limit;\n }\n\n public get storageUsage(): number {\n if (this._storage) {\n return this._storage.getUsage();\n }\n return -1;\n }\n\n public get showPlaceholder(): boolean {\n return this._opts.showPlaceholder;\n }\n\n public set showPlaceholder(value: boolean) {\n this._opts.showPlaceholder = value;\n this._renderer?.showPlaceholder(value);\n }\n\n public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.getImageAtBufferCell(x, y);\n }\n\n public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {\n return this._storage?.extractTileAtBufferCell(x, y);\n }\n\n private _report(s: string): void {\n this._terminal?._core.input(s, false);\n }\n\n private _decset(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = false;\n break;\n }\n }\n return false;\n }\n\n private _decrst(params: (number | number[])[]): boolean {\n for (let i = 0; i < params.length; ++i) {\n switch (params[i]) {\n case 80:\n this._opts.sixelScrolling = true;\n break;\n }\n }\n return false;\n }\n\n // overload DA to return something more appropriate\n private _da1(params: (number | number[])[]): boolean {\n if (params[0]) {\n return true;\n }\n // reported features:\n // 62 - VT220\n // 4 - SIXEL support\n // 9 - charsets\n // 22 - ANSI colors\n if (this._opts.sixelSupport) {\n this._report(`\\x1b[?62;4;9;22c`);\n return true;\n }\n return false;\n }\n\n /**\n * Implementation of xterm's graphics attribute sequence.\n *\n * Supported features:\n * - read/change palette limits (max 4096 by sixel lib)\n * - read SIXEL canvas geometry (reports current window canvas or\n * squared pixelLimit if canvas > pixel limit)\n *\n * Everything else is deactivated.\n */\n private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {\n if (params.length < 2) {\n return true;\n }\n if (params[0] === GaItem.COLORS) {\n switch (params[1]) {\n case GaAction.READ:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n return true;\n case GaAction.SET_DEFAULT:\n this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n // also reset protocol handlers for now\n for (const handler of this._handlers.values()) {\n handler.reset();\n }\n return true;\n case GaAction.SET:\n if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {\n this._opts.sixelPaletteLimit = params[2];\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);\n } else {\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n }\n return true;\n case GaAction.READ_MAX:\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n if (params[0] === GaItem.SIXEL_GEO) {\n switch (params[1]) {\n // we only implement read and read_max here\n case GaAction.READ:\n let width = this._renderer?.dimensions?.css.canvas.width;\n let height = this._renderer?.dimensions?.css.canvas.height;\n if (!width || !height) {\n // for some reason we have no working image renderer\n // --> fallback to default cell size\n const cellSize = CELL_SIZE_DEFAULT;\n width = (this._terminal?.cols || 80) * cellSize.width;\n height = (this._terminal?.rows || 24) * cellSize.height;\n }\n if (width * height < this._opts.pixelLimit) {\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);\n } else {\n // if we overflow pixelLimit report that squared instead\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n }\n return true;\n case GaAction.READ_MAX:\n // read_max returns pixelLimit as square area\n const x = Math.floor(Math.sqrt(this._opts.pixelLimit));\n this._report(`\\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);\n return true;\n default:\n this._report(`\\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);\n return true;\n }\n }\n // exit with error on ReGIS or any other requests\n this._report(`\\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);\n return true;\n }\n}\n"], ++ "mappings": "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", ++ "names": ["exports", "red", "n", "green", "blue", "alpha", "toRGBA8888", "g", "b", "a", "fromRGBA8888", "color", "nearestColorIndex", "palette", "r", "min", "idx", "i", "dr", "dg", "db", "d", "clamp", "low", "high", "value", "h2c", "t1", "t2", "c", "HLStoRGB", "h", "l", "s", "v", "normalizeRGB", "normalizeHLS", "p", "require_lib", "__commonJSMin", "exports", "InWasm", "z", "s", "b", "r", "def", "t", "d", "m", "W", "e", "require_Base64Decoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmDecode", "MAP", "el", "D", "i", "EMPTY", "Base64Decoder", "keepSize", "maxBytes", "initialBytes", "m", "bytes", "requested", "needed", "newSize", "addPages", "data", "require_QoiDecoder_wasm", "__commonJSMin", "exports", "inwasm_runtime_1", "wasmQoiDecode", "QoiDecoder", "keepSize", "d", "pixels", "ib", "dl", "bytes", "chunkP", "exports", "Colors_1", "wasm_1", "decodeBase64", "s", "bytestring", "result", "WASM_BYTES", "WASM_MODULE", "NULL_CANVAS", "CallbackProxy", "width", "mode", "DEFAULT_OPTIONS", "DecoderAsync", "opts", "cbProxy", "importObj", "inst", "Decoder", "exports", "_instance", "_cbProxy", "module", "pixels", "offset", "additionalPixels", "newCanvas", "adv", "remaining", "c", "i", "fillColor", "palette", "paletteLimit", "truncate", "data", "start", "end", "p", "length", "j", "currentWidth", "escape", "final", "finalOffset", "bw", "currentHeight", "decode", "dec", "decodeAsync", "toDisposable", "fn", "DisposableStore", "o", "d", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "len", "EventUtils", "forward", "from", "to", "e", "map", "i", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "import_Colors", "ImageRenderer", "_ImageRenderer", "Disposable", "_terminal", "MutableDisposable", "parent", "option", "toDisposable", "localDocument", "width", "height", "canvas", "ctx", "buffer", "imgData", "img", "value", "start", "end", "layer", "y", "w", "h", "imgSpec", "tileId", "col", "row", "count", "sourceWidth", "sourceHeight", "cols", "sx", "sy", "dx", "dy", "finalWidth", "finalHeight", "spec", "currentWidth", "currentHeight", "originalWidth", "originalHeight", "scaledWidth", "scaledHeight", "renderer", "key", "screenElement", "bWidth", "blueprint", "d32", "black", "white", "shift", "offset", "x", "ctx2", "i", "placeholder", "bitmap", "CELL_SIZE_DEFAULT", "ExtendedAttrsImage", "_ExtendedAttrsImage", "ext", "urlId", "imageId", "tileId", "value", "val", "EMPTY_ATTRS", "ImageStorage", "_terminal", "_renderer", "_opts", "e", "spec", "storedPixels", "id", "zero", "img", "opts", "cellSize", "cols", "rows", "buffer", "termCols", "termRows", "originX", "originY", "offset", "tileCount", "row", "line", "col", "endMarker", "imgSpec", "range", "hasTopImages", "hasBottomImages", "start", "end", "drawCalls", "placeholderCalls", "startTile", "startCol", "count", "a", "b", "call", "metrics", "oldCol", "tilesPerRow", "hasData", "rightCol", "lastTile", "expandCol", "x", "y", "orig", "canvas", "ImageRenderer", "room", "used", "current", "old", "oldSpec", "imgId", "import_Base64Decoder", "import_QoiDecoder", "toStr", "data", "s", "i", "toInt", "v", "toSize", "toName", "bs", "b", "DECODERS", "FILE_MARKER", "MULTIPARTFILE_MARKER", "FILEPART_MARKER", "FILEEND_MARKER", "REPORTCELLSIZE_MARKER", "MAX_FIELDCHARS", "HeaderParser", "k", "start", "end", "state", "pos", "buffer", "c", "UNSUPPORTED_TYPE", "imageType", "d", "d32", "width", "height", "jpgSize", "dim", "pos", "limit", "i", "len", "blockLength", "DEFAULT_HEADER", "IIPHandler", "_opts", "_renderer", "_storage", "_coreTerminal", "HeaderParser", "maxEncodedBytes", "initialBytes", "Base64Decoder", "QoiDecoder", "data", "start", "end", "dataPos", "success", "seqType", "w", "CELL_SIZE_DEFAULT", "h", "scale", "report", "cond", "metrics", "UNSUPPORTED_TYPE", "imageType", "blob", "e", "canvas", "ImageRenderer", "generation", "bm", "cw", "ch", "width", "height", "rw", "rh", "wf", "hf", "f", "s", "total", "cdim", "import_Base64Decoder", "parseKittyCommand", "data", "cmd", "parts", "part", "eqIdx", "key", "value", "numValue", "DECODER_OK", "KittyGraphicsHandler", "_opts", "_renderer", "_kittyStorage", "_coreTerminal", "key", "pending", "data", "start", "end", "controlEnd", "i", "copyLength", "parseKittyCommand", "payloadStart", "pendingKey", "previousEncodedSize", "decoderToRelease", "decoderCapacity", "maxPending", "oldest", "decoder", "Base64Decoder", "e", "success", "cmd", "isMoreComing", "decodeError", "finalCmd", "imageBytes", "result", "str", "bytes", "id", "image", "quiet", "format", "width", "height", "bytesPerPixel", "expectedBytes", "message", "placementId", "isOk", "pPart", "response", "generation", "bitmap", "cropX", "cropY", "cropW", "cropH", "maxCropW", "maxCropH", "finalCropW", "finalCropH", "cropped", "cw", "CELL_SIZE_DEFAULT", "ch", "imgCols", "imgRows", "w", "h", "buffer", "savedX", "savedY", "savedYbase", "layer", "scaled", "xOffset", "yOffset", "canvasW", "canvasH", "offsetCanvas", "ImageRenderer", "offsetCtx", "offsetBitmap", "zIndex", "scrolled", "metrics", "imageType", "blob", "url", "img", "resolve", "reject", "canvas", "pixelCount", "src32", "dst32", "alignedPixels", "srcOffset", "dstOffset", "b0", "b1", "b2", "srcByte", "dstByte", "compressed", "error", "limit", "offsetIn", "reader", "controller", "chunks", "totalLength", "done", "value", "offset", "chunk", "_KittyImageStorage", "_storage", "storageId", "kittyId", "id", "imageData", "imageId", "oldStorageId", "byteLimit", "retainedBytes", "image", "evictPlaced", "oldestId", "scrolling", "layer", "zIndex", "KittyImageStorage", "import_Colors", "import_Decoder", "import_wasm", "MEM_PERMA_LIMIT", "DEFAULT_PALETTE", "MAX_IDLE_DECODERS", "idleDecoders", "poolPrimed", "primeDecoderPool", "memoryLimit", "d", "releaseDecoder", "acquireDecoder", "dec", "idle", "SixelHandler", "_opts", "_storage", "_coreTerminal", "params", "e", "fillColor", "extractActiveBg", "data", "start", "end", "success", "width", "height", "canvas", "ImageRenderer", "attr", "colors", "bg", "convertLe", "t", "color", "SixelImageStorage", "_storage", "_opts", "_renderer", "_terminal", "img", "height", "cellSize", "CELL_SIZE_DEFAULT", "rows", "i", "IIPImageStorage", "_storage", "img", "DEFAULT_OPTIONS", "MAX_SIXEL_PALETTE_SIZE", "ImageAddon", "opts", "Emitter", "DEFAULT_OPTIONS", "handler", "obj", "args", "terminal", "ImageRenderer", "ImageStorage", "windowOps", "params", "range", "metrics", "sixelStorage", "SixelImageStorage", "sixelHandler", "SixelHandler", "iipStorage", "IIPImageStorage", "iipHandler", "IIPHandler", "kittyStorage", "KittyImageStorage", "kittyHandler", "KittyGraphicsHandler", "limit", "value", "x", "y", "s", "i", "MAX_SIXEL_PALETTE_SIZE", "width", "height", "cellSize", "CELL_SIZE_DEFAULT"] } diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts -index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644 +index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644 --- a/src/IIPHandler.ts +++ b/src/IIPHandler.ts -@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = { +@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; + + // Local const enum mirror - esbuild can't inline const enums from external packages + const enum DecoderConst { +- // Limit held memory in base64 decoder (encoded bytes). +- KEEP_DATA = 4194304, ++ // Held memory in base64/QOI decoders between images. Zero because each kept ++ // decoder pins a wasm memory, and V8 caps those per process (~124 in a ++ // sandboxed renderer), so idle terminals must not hold one. ++ KEEP_DATA = 0, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) +@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = { export class IIPHandler implements IOscHandler, IResetHandler { @@ -55,7 +68,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; -@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } public reset(): void { @@ -63,7 +76,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._hp.reset(); this._dec.release(); this._qoiDec.release(); -@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._aborted = true; + return; + } +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._aborted = true; ++ return; ++ } + } else if (this._abortMulti) { + this._aborted = true; + return; +@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._isMultipart = true; + this._abortMulti = false; + this._dec.release(); +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._abortMulti = true; ++ } + return true; + } + +@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { + + let blob: Blob | ImageData; + if (metrics.mime === 'image/qoi') { +- const data = this._qoiDec.decode(this._dec.data8); ++ let data: Uint8Array; ++ try { ++ data = this._qoiDec.decode(this._dec.data8); ++ } catch (e) { ++ console.warn('IIP: could not decode QOI image', e); ++ this._dec.release(); ++ this._qoiDec.release(); ++ return true; ++ } + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, +@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); @@ -77,6 +130,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._storage.addImage(bm); return true; }) +@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler { + }); + } + ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ private _initDecoder(): boolean { ++ try { ++ this._dec.init(); ++ return true; ++ } catch (e) { ++ console.warn('IIP: could not allocate decoder', e); ++ this._dec.release(); ++ return false; ++ } ++ } ++ + private _resize(w: number, h: number): [number, number] { + const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; + const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644 --- a/src/ImageAddon.ts @@ -189,8 +261,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33 } public get document(): Document | undefined { +diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts +index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644 +--- a/src/SixelHandler.ts ++++ b/src/SixelHandler.ts +@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types'; + import { ImageRenderer } from './ImageRenderer'; + + import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; ++import { LIMITS } from 'sixel/lib/wasm'; + + // always free decoder ressources after decoding if it exceeds this limit + const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB +@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + const DEFAULT_PALETTE = PALETTE_ANSI_256; + DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); + ++// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories ++// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only ++// while a SIXEL sequence is open, so idle terminals hold none. ++const MAX_IDLE_DECODERS = 2; ++const idleDecoders = new Map(); ++let poolPrimed = false; ++ ++function primeDecoderPool(memoryLimit: number): void { ++ if (poolPrimed) return; ++ poolPrimed = true; ++ // Async compile once, off the parser's hot path; later decoders reuse the cached module. ++ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then( ++ d => releaseDecoder(d, memoryLimit), ++ () => { poolPrimed = false; } ++ ); ++} ++ ++function acquireDecoder(memoryLimit: number): Decoder { ++ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE }); ++} ++ ++function releaseDecoder(dec: Decoder, memoryLimit: number): void { ++ if (dec.memoryUsage > MEM_PERMA_LIMIT) { ++ dec.release(); ++ } ++ const idle = idleDecoders.get(memoryLimit) ?? []; ++ if (idle.length < MAX_IDLE_DECODERS) { ++ idle.push(dec); ++ idleDecoders.set(memoryLimit, idle); ++ } ++} ++ + + export class SixelHandler implements IDcsHandler, IResetHandler { + private _size = 0; + private _aborted = false; + private _dec: Decoder | undefined; ++ private _decMemoryLimit = 0; ++ // Color registers outlive a single image, so they live here rather than in a pooled decoder. ++ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE); + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: SixelImageStorage, + private readonly _coreTerminal: ITerminalExt + ) { +- DecoderAsync({ +- memoryLimit: this._opts.pixelLimit * 4, +- palette: DEFAULT_PALETTE, +- paletteLimit: this._opts.sixelPaletteLimit +- }).then(d => this._dec = d); ++ this._palette.set(DEFAULT_PALETTE); ++ primeDecoderPool(this._opts.pixelLimit * 4); + } + + public reset(): void { +- /** +- * reset sixel decoder to defaults: +- * - release all memory +- * - nullify palette (4096) +- * - apply default palette (256) +- */ +- if (this._dec) { +- this._dec.release(); +- // FIXME: missing interface on decoder to nullify full palette +- (this._dec as any)._palette.fill(0); +- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); +- } ++ this._returnDecoder(); ++ this._palette.fill(0); ++ this._palette.set(DEFAULT_PALETTE); + } + + public hook(params: IParams): void { + this._size = 0; + this._aborted = false; +- if (this._dec) { +- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( +- this._coreTerminal._core._inputHandler._curAttrData, +- this._coreTerminal._core._themeService?.colors); +- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); ++ this._returnDecoder(); ++ const memoryLimit = this._opts.pixelLimit * 4; ++ try { ++ this._dec = acquireDecoder(memoryLimit); ++ } catch (e) { ++ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn(`SIXEL: could not allocate decoder - ${e}`); ++ this._aborted = true; ++ return; + } ++ this._decMemoryLimit = memoryLimit; ++ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( ++ this._coreTerminal._core._inputHandler._curAttrData, ++ this._coreTerminal._core._themeService?.colors); ++ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit); ++ } ++ ++ private _returnDecoder(): void { ++ const dec = this._dec; ++ if (!dec) return; ++ this._dec = undefined; ++ this._palette.set(dec.palette); ++ releaseDecoder(dec, this._decMemoryLimit); + } + + public put(data: Uint32Array, start: number, end: number): void { +@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + } + + public unhook(success: boolean): boolean | Promise { ++ try { ++ return this._unhook(success); ++ } finally { ++ this._returnDecoder(); ++ } ++ } ++ ++ private _unhook(success: boolean): boolean { + if (this._aborted || !success || !this._dec) { + return true; + } +@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + + const canvas = ImageRenderer.createCanvas(undefined, width, height); + canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0); +- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { +- this._dec.release(); +- } + this._storage.addImage(canvas); + return true; + } diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts -index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644 +index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644 --- a/src/kitty/KittyGraphicsHandler.ts +++ b/src/kitty/KittyGraphicsHandler.ts @@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm'; @@ -217,10 +437,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f this._cleanupAllPending(); if (this._activeDecoder) { this._activeDecoder.release(); -@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { +- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); +- this._activeDecoder.init(); + // Budget WASM capacity, including one page of decoder state and rounding. + const decoderCapacity = this._maxEncodedBytes + 131072; + if (decoderCapacity > this._opts.storageLimit * 1000000) { @@ -240,10 +462,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f + this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0); + } + } - this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); - this._activeDecoder.init(); ++ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); ++ try { ++ decoder.init(); ++ } catch (e) { ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn('KITTY: could not allocate decoder', e); ++ this._aborted = true; ++ if (this._parsedCommand?.id !== undefined) { ++ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0); ++ } ++ return; ++ } ++ this._activeDecoder = decoder; } -@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos + + if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) { +@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -255,7 +490,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const cropX = Math.max(0, cmd.x ?? 0); const cropY = Math.max(0, cmd.y ?? 0); const cropW = cmd.sourceWidth || (bitmap.width - cropX); -@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } } @@ -263,7 +498,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const zIndex = cmd.zIndex ?? 0; this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); bitmap = undefined; // ownership transferred to storage -@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (image.format === KittyFormat.PNG) { @@ -276,7 +511,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const blob = new Blob([bytes as BlobPart], { type: 'image/png' }); if (!window.createImageBitmap) { const url = URL.createObjectURL(blob); -@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private async _decompressZlib(compressed: Uint8Array): Promise { try { return await this._decompress(compressed, 'deflate'); diff --git a/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch b/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch index 2c5aa15fac1..3b581c30477 100644 --- a/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch +++ b/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch @@ -1,8 +1,21 @@ diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts -index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644 +index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644 --- a/src/IIPHandler.ts +++ b/src/IIPHandler.ts -@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = { +@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; + + // Local const enum mirror - esbuild can't inline const enums from external packages + const enum DecoderConst { +- // Limit held memory in base64 decoder (encoded bytes). +- KEEP_DATA = 4194304, ++ // Held memory in base64/QOI decoders between images. Zero because each kept ++ // decoder pins a wasm memory, and V8 caps those per process (~124 in a ++ // sandboxed renderer), so idle terminals must not hold one. ++ KEEP_DATA = 0, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) +@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = { export class IIPHandler implements IOscHandler, IResetHandler { @@ -10,7 +23,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; -@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } public reset(): void { @@ -18,7 +31,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._hp.reset(); this._dec.release(); this._qoiDec.release(); -@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { +@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._aborted = true; + return; + } +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._aborted = true; ++ return; ++ } + } else if (this._abortMulti) { + this._aborted = true; + return; +@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler { + this._isMultipart = true; + this._abortMulti = false; + this._dec.release(); +- this._dec.init(); ++ if (!this._initDecoder()) { ++ this._abortMulti = true; ++ } + return true; + } + +@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { + + let blob: Blob | ImageData; + if (metrics.mime === 'image/qoi') { +- const data = this._qoiDec.decode(this._dec.data8); ++ let data: Uint8Array; ++ try { ++ data = this._qoiDec.decode(this._dec.data8); ++ } catch (e) { ++ console.warn('IIP: could not decode QOI image', e); ++ this._dec.release(); ++ this._qoiDec.release(); ++ return true; ++ } + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, +@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler { blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); @@ -32,6 +85,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6 this._storage.addImage(bm); return true; }) +@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler { + }); + } + ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ private _initDecoder(): boolean { ++ try { ++ this._dec.init(); ++ return true; ++ } catch (e) { ++ console.warn('IIP: could not allocate decoder', e); ++ this._dec.release(); ++ return false; ++ } ++ } ++ + private _resize(w: number, h: number): [number, number] { + const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; + const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644 --- a/src/ImageAddon.ts @@ -144,8 +216,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33 } public get document(): Document | undefined { +diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts +index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644 +--- a/src/SixelHandler.ts ++++ b/src/SixelHandler.ts +@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types'; + import { ImageRenderer } from './ImageRenderer'; + + import { DecoderAsync, Decoder } from 'sixel/lib/Decoder'; ++import { LIMITS } from 'sixel/lib/wasm'; + + // always free decoder ressources after decoding if it exceeds this limit + const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB +@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB + const DEFAULT_PALETTE = PALETTE_ANSI_256; + DEFAULT_PALETTE.set(PALETTE_VT340_COLOR); + ++// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories ++// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only ++// while a SIXEL sequence is open, so idle terminals hold none. ++const MAX_IDLE_DECODERS = 2; ++const idleDecoders = new Map(); ++let poolPrimed = false; ++ ++function primeDecoderPool(memoryLimit: number): void { ++ if (poolPrimed) return; ++ poolPrimed = true; ++ // Async compile once, off the parser's hot path; later decoders reuse the cached module. ++ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then( ++ d => releaseDecoder(d, memoryLimit), ++ () => { poolPrimed = false; } ++ ); ++} ++ ++function acquireDecoder(memoryLimit: number): Decoder { ++ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE }); ++} ++ ++function releaseDecoder(dec: Decoder, memoryLimit: number): void { ++ if (dec.memoryUsage > MEM_PERMA_LIMIT) { ++ dec.release(); ++ } ++ const idle = idleDecoders.get(memoryLimit) ?? []; ++ if (idle.length < MAX_IDLE_DECODERS) { ++ idle.push(dec); ++ idleDecoders.set(memoryLimit, idle); ++ } ++} ++ + + export class SixelHandler implements IDcsHandler, IResetHandler { + private _size = 0; + private _aborted = false; + private _dec: Decoder | undefined; ++ private _decMemoryLimit = 0; ++ // Color registers outlive a single image, so they live here rather than in a pooled decoder. ++ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE); + + constructor( + private readonly _opts: IImageAddonOptions, + private readonly _storage: SixelImageStorage, + private readonly _coreTerminal: ITerminalExt + ) { +- DecoderAsync({ +- memoryLimit: this._opts.pixelLimit * 4, +- palette: DEFAULT_PALETTE, +- paletteLimit: this._opts.sixelPaletteLimit +- }).then(d => this._dec = d); ++ this._palette.set(DEFAULT_PALETTE); ++ primeDecoderPool(this._opts.pixelLimit * 4); + } + + public reset(): void { +- /** +- * reset sixel decoder to defaults: +- * - release all memory +- * - nullify palette (4096) +- * - apply default palette (256) +- */ +- if (this._dec) { +- this._dec.release(); +- // FIXME: missing interface on decoder to nullify full palette +- (this._dec as any)._palette.fill(0); +- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit); +- } ++ this._returnDecoder(); ++ this._palette.fill(0); ++ this._palette.set(DEFAULT_PALETTE); + } + + public hook(params: IParams): void { + this._size = 0; + this._aborted = false; +- if (this._dec) { +- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( +- this._coreTerminal._core._inputHandler._curAttrData, +- this._coreTerminal._core._themeService?.colors); +- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit); ++ this._returnDecoder(); ++ const memoryLimit = this._opts.pixelLimit * 4; ++ try { ++ this._dec = acquireDecoder(memoryLimit); ++ } catch (e) { ++ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn(`SIXEL: could not allocate decoder - ${e}`); ++ this._aborted = true; ++ return; + } ++ this._decMemoryLimit = memoryLimit; ++ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg( ++ this._coreTerminal._core._inputHandler._curAttrData, ++ this._coreTerminal._core._themeService?.colors); ++ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit); ++ } ++ ++ private _returnDecoder(): void { ++ const dec = this._dec; ++ if (!dec) return; ++ this._dec = undefined; ++ this._palette.set(dec.palette); ++ releaseDecoder(dec, this._decMemoryLimit); + } + + public put(data: Uint32Array, start: number, end: number): void { +@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + } + + public unhook(success: boolean): boolean | Promise { ++ try { ++ return this._unhook(success); ++ } finally { ++ this._returnDecoder(); ++ } ++ } ++ ++ private _unhook(success: boolean): boolean { + if (this._aborted || !success || !this._dec) { + return true; + } +@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler { + + const canvas = ImageRenderer.createCanvas(undefined, width, height); + canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray, width, height), 0, 0); +- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) { +- this._dec.release(); +- } + this._storage.addImage(canvas); + return true; + } diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts -index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644 +index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644 --- a/src/kitty/KittyGraphicsHandler.ts +++ b/src/kitty/KittyGraphicsHandler.ts @@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm'; @@ -172,10 +392,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f this._cleanupAllPending(); if (this._activeDecoder) { this._activeDecoder.release(); -@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { +- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); +- this._activeDecoder.init(); + // Budget WASM capacity, including one page of decoder state and rounding. + const decoderCapacity = this._maxEncodedBytes + 131072; + if (decoderCapacity > this._opts.storageLimit * 1000000) { @@ -195,10 +417,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f + this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0); + } + } - this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); - this._activeDecoder.init(); ++ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); ++ try { ++ decoder.init(); ++ } catch (e) { ++ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue. ++ console.warn('KITTY: could not allocate decoder', e); ++ this._aborted = true; ++ if (this._parsedCommand?.id !== undefined) { ++ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0); ++ } ++ return; ++ } ++ this._activeDecoder = decoder; } -@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos + + if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) { +@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -210,7 +445,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const cropX = Math.max(0, cmd.x ?? 0); const cropY = Math.max(0, cmd.y ?? 0); const cropW = cmd.sourceWidth || (bitmap.width - cropX); -@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } } @@ -218,7 +453,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const zIndex = cmd.zIndex ?? 0; this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); bitmap = undefined; // ownership transferred to storage -@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (image.format === KittyFormat.PNG) { @@ -231,7 +466,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f const blob = new Blob([bytes as BlobPart], { type: 'image/png' }); if (!window.createImageBitmap) { const url = URL.createObjectURL(blob); -@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos +@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private async _decompressZlib(compressed: Uint8Array): Promise { try { return await this._decompress(compressed, 'deflate'); diff --git a/config/scripts/xterm-image-wasm-budget-contract.test.mjs b/config/scripts/xterm-image-wasm-budget-contract.test.mjs new file mode 100644 index 00000000000..9ec5f4fc750 --- /dev/null +++ b/config/scripts/xterm-image-wasm-budget-contract.test.mjs @@ -0,0 +1,160 @@ +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const { Terminal } = require('@xterm/xterm') +const { ImageAddon } = require('@xterm/addon-image') + +// V8 caps live wasm memories per renderer (~124 under Electron's sandbox), so +// these contracts pin that idle terminals hold none and that exhaustion drops +// an image instead of wedging the terminal's write queue. + +const SIXEL_RED_REGISTER_1 = '\x1bPq#1;2;100;0;0#1~\x1b\\' +const SIXEL_USE_REGISTER_1 = '\x1bPq#1~\x1b\\' +const PNG_1X1 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' +const IIP_PNG = `\x1b]1337;File=inline=1;size=${Buffer.from(PNG_1X1, 'base64').length}:${PNG_1X1}\x07` + +class TrackedBitmap { + width = 1 + height = 1 + close = vi.fn() +} + +function stubCanvasGlobals() { + const painted = [] + vi.stubGlobal('ImageBitmap', TrackedBitmap) + vi.stubGlobal('window', { ImageBitmap: TrackedBitmap }) + vi.stubGlobal('createImageBitmap', async () => new TrackedBitmap()) + vi.stubGlobal( + 'ImageData', + class { + constructor(data, width, height) { + Object.assign(this, { data: new Uint8ClampedArray(data), width, height }) + } + } + ) + vi.stubGlobal('document', { + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ putImageData: (image) => painted.push(image) }) + }) + }) + return painted +} + +function createTerminal(options = {}) { + const terminal = new Terminal({ allowProposedApi: true }) + const addon = new ImageAddon({ + enableSizeReports: false, + storageLimit: 32, + kittySizeLimit: 8 * 1024 * 1024, + ...options + }) + terminal.loadAddon(addon) + return { + terminal, + addon, + sixel: addon._handlers.get('sixel'), + iip: addon._handlers.get('iip') + } +} + +function write(terminal, data) { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +// A throw out of a handler leaves xterm's write queue waiting forever. +function writeOrWedge(terminal, data) { + return Promise.race([ + write(terminal, data).then(() => 'parsed'), + new Promise((resolve) => setTimeout(() => resolve('wedged'), 2000)) + ]) +} + +function firstPixel(image) { + return Array.from(image.data.subarray(0, 4)) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('xterm image wasm budget', () => { + it('keeps idle terminals free of wasm decoders', async () => { + const panes = Array.from({ length: 300 }, () => createTerminal()) + try { + // Let any asynchronous decoder instantiation settle. + await new Promise((resolve) => setTimeout(resolve, 100)) + for (const { sixel, iip } of panes) { + expect(sixel._dec).toBeUndefined() + expect(iip._dec._inst).toBeNull() + expect(iip._qoiDec._inst).toBeFalsy() + } + } finally { + for (const { terminal } of panes) { + terminal.dispose() + } + } + }) + + it('returns decoders after each SIXEL and IIP image', async () => { + const painted = stubCanvasGlobals() + const { terminal, addon, sixel, iip } = createTerminal() + let added = 0 + addon.onImageAdded(() => added++) + try { + await write(terminal, SIXEL_RED_REGISTER_1) + expect(painted).toHaveLength(1) + expect(sixel._dec).toBeUndefined() + + await write(terminal, IIP_PNG) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(iip._dec._inst).toBeNull() + expect(added).toBe(2) + } finally { + terminal.dispose() + } + }) + + it('keeps SIXEL color registers across images until a terminal reset', async () => { + const painted = stubCanvasGlobals() + const { terminal } = createTerminal() + try { + await write(terminal, SIXEL_RED_REGISTER_1) + await write(terminal, SIXEL_USE_REGISTER_1) + expect(firstPixel(painted[1])).toEqual([255, 0, 0, 255]) + + await write(terminal, '\x1bc') + await write(terminal, SIXEL_USE_REGISTER_1) + expect(firstPixel(painted[2])).not.toEqual([255, 0, 0, 255]) + } finally { + terminal.dispose() + } + }) + + it('drops images without wedging the parser when wasm memory is exhausted', async () => { + stubCanvasGlobals() + // A pixelLimit no other test uses keeps the shared SIXEL pool empty for this key. + const { terminal, addon } = createTerminal({ pixelLimit: 1234567 }) + const replies = [] + terminal.onData((data) => replies.push(data)) + function exhausted() { + throw new RangeError('WebAssembly.Memory(): could not allocate memory') + } + vi.stubGlobal('WebAssembly', { ...WebAssembly, Memory: exhausted, Instance: exhausted }) + try { + expect(await writeOrWedge(terminal, '\x1b_Ga=T,f=32,s=1,v=1,i=7;AAAA/w==\x1b\\')).toBe( + 'parsed' + ) + expect(await writeOrWedge(terminal, IIP_PNG)).toBe('parsed') + expect(await writeOrWedge(terminal, SIXEL_USE_REGISTER_1)).toBe('parsed') + expect(await writeOrWedge(terminal, 'after')).toBe('parsed') + + expect(addon._storage._images.size).toBe(0) + expect(replies.join('')).toContain('ENOMEM') + expect(terminal.buffer.active.getLine(0).translateToString(true)).toContain('after') + } finally { + terminal.dispose() + } + }) +}) diff --git a/docs/reference/xterm-patch-regeneration.md b/docs/reference/xterm-patch-regeneration.md index d398f10a6bb..3197de33f20 100644 --- a/docs/reference/xterm-patch-regeneration.md +++ b/docs/reference/xterm-patch-regeneration.md @@ -48,6 +48,15 @@ after reset, disable or disposal. `config/scripts/xterm-image-lifecycle-contract exercises those boundaries against the installed addon. Font zoom scales visible tiles without creating enlarged full-image canvases; `config/scripts/xterm-image-resize-contract.test.mjs` checks allocation and tile mapping. +Every wasm memory reserves its guard region inside V8's sandbox, which caps a +renderer at about 124 live memories no matter how much RAM is free. Upstream +gave each terminal a SIXEL decoder at activation and kept IIP decoders after the +first image, so a window with ~120+ terminals ran out. The patch borrows SIXEL +decoders from a small shared pool only while a sequence is open, keeping color +registers on the terminal, and drops IIP decoders after each image. A decoder +that cannot be allocated drops that image; before the patch it threw out of the +parser and left the terminal's write queue stuck. +`config/scripts/xterm-image-wasm-budget-contract.test.mjs` covers both. ## Rules diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02422ea3f19..8b5c4b890d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,7 +111,7 @@ overrides: patchedDependencies: i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 '@vscode/windows-process-tree@0.8.0': b4be93859cefb159949d9cf05f97fa91a221dff0f793a6f03d02d78ae5b32035 - '@xterm/addon-image@0.10.0-beta.300': 5fc280019957573eefe8daa2969534d6090ddb29c6b5842b8f22e99dc1ce3146 + '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.300': b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f @@ -331,7 +331,7 @@ importers: version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) '@xterm/addon-image': specifier: 0.10.0-beta.300 - version: 0.10.0-beta.300(patch_hash=5fc280019957573eefe8daa2969534d6090ddb29c6b5842b8f22e99dc1ce3146)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) + version: 0.10.0-beta.300(patch_hash=e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) '@xterm/addon-ligatures': specifier: 0.11.0-beta.300 version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -10744,7 +10744,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393) - '@xterm/addon-image@0.10.0-beta.300(patch_hash=5fc280019957573eefe8daa2969534d6090ddb29c6b5842b8f22e99dc1ce3146)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))': + '@xterm/addon-image@0.10.0-beta.300(patch_hash=e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))': dependencies: '@xterm/xterm': 6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393) From 6e1b7e7fa314362a039829b34fb72d96df1be861 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:21:53 -0700 Subject: [PATCH 359/387] test: remove junk tests that assert source text instead of behavior (#23815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes 101 test files and trims 112 more, all matching documented junk patterns: exact source/import/string greps, copied inventories and export lists, duplicate invocations of a contract another test already owns, typeof-shape checks TypeScript already enforces, and self-comparisons. The largest group read a production `.ts` file and asserted on its text — for example a TaskPage test that required the source to contain `selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`. Any behavior-preserving rename broke it; no behavior change ever did. Production-side follow-through: exports that only these tests imported are de-exported or deleted, stale comments pointing at removed censuses are dropped, and the reliability-gate registry, `cloud/package.json` test lists, and orphaned source-reading helpers are updated so nothing references a deleted file. Two files kept their real coverage and lost only the census scaffolding: `agent-status-producer-census.test.ts` now drives all five producers end to end instead of grepping the source tree, and `config-toml-trust-stale-writes` replaces an export-list parity check. --- cloud/apps/relay/src/assignment-store.ts | 2 +- .../src/cell-inventory-lock-census.test.ts | 335 --------- .../cell-inventory-lock-contention.test.ts | 30 - ...production-cloud-sql-rollout-lock.test.mjs | 28 - .../scripts/push-gateway-workflow.test.mjs | 5 - .../scripts/push-validation-workflow.test.mjs | 48 -- .../relay-asia-admission-workflow.test.mjs | 375 ---------- .../relay-regional-rehome-workflow.test.mjs | 269 ------- .../relay-same-cap-script-census.test.mjs | 108 --- cloud/package.json | 4 +- config/reliability-gates.jsonc | 31 +- .../scripts/bun-profile-change-scope.test.mjs | 4 +- config/scripts/ci-shard-timings.json | 1 - .../cli-main-module-bundle-parity.test.ts | 91 --- ...t-hosted-browser-package-coverage.test.mjs | 7 - config/scripts/computer-e2e-workflow.test.mjs | 286 -------- .../electron-vite-output-contract.test.ts | 10 - .../headless-serve-shutdown-workflow.test.mjs | 171 ----- .../mobile-web-app-route-manifest.test.mjs | 22 +- ...package-electron-runtime-contract.test.mjs | 564 --------------- ...ged-hang-watchdog-worker-contract.test.mjs | 37 - .../scripts/pr-workflow-lint-parity.test.mjs | 86 --- .../scripts/ssh-docker-ci-sharding.test.mjs | 20 - .../terminal-ime-e2e-workflow.test.mjs | 32 - .../verify-dev-channel-packaging.test.mjs | 13 - .../scripts/win-crash-survival-e2e.test.mjs | 36 +- .../windows-process-tree-gyp-path.test.mjs | 32 +- ...ws-pty-native-capability-workflow.test.mjs | 87 --- ...windows-signing-workflow-contract.test.mjs | 498 ------------- .../accounts/mobile-accounts-route.test.ts | 13 - .../mobile-browser-pane-source.test.ts | 27 - .../mobile-agent-icon-assets.test.ts | 11 +- .../files/files-router-seam-census.test.ts | 35 - .../home-host-edit-navigation-source.test.ts | 11 - .../hooks/use-mobile-dictation-source.test.ts | 97 --- .../host-route-href-encoding.test.ts | 76 -- .../MobileWebShellScreen.test.tsx | 18 - .../bridge-client-frame-cap-boundary.test.ts | 16 - .../bridge-client-frame-cap-unit.test.ts | 14 - .../bridge/bridge-envelope.test.ts | 92 +-- ...age-first-paint-through-the-bridge.test.ts | 19 - .../mobile-web-shell-flag-census.test.ts | 207 ------ ...obile-web-shell-router-seam-census.test.ts | 94 --- .../mobile-web-shell-screen-test-harness.tsx | 2 +- .../shell-older-page-paths-census.test.ts | 89 --- .../shell-screen-route-census.test.ts | 278 -------- .../shell-view-surface.test.ts | 44 -- .../desktop-notification-channel.test.ts | 13 - .../notification-route-coordination.test.ts | 17 - .../keyboard-geometry-host-os-census.test.ts | 69 -- .../keyboard-geometry-owner-census.test.ts | 38 - .../microphone-screen-lock-census.test.ts | 104 --- ...ent-send-keyboard-dismissal-wiring.test.ts | 212 ------ .../session/mobile-native-chat-noise.test.ts | 1 + .../mobile-session-last-tab-close.test.ts | 43 -- .../mobile-session-route-parity.test.ts | 669 ------------------ ...ession-route-source-family.test-support.ts | 69 -- .../src/session/mobile-session-route.test.ts | 19 - .../mobile-session-startup-source.test.ts | 295 -------- ...uick-commands-tab-stability-source.test.ts | 67 -- .../session-router-seam-census.test.ts | 79 --- ...rminal-field-submit-binding-wiring.test.ts | 66 -- .../source-control/mobile-pr-create.test.ts | 26 - .../source-control-router-seam-census.test.ts | 44 -- .../mobile-tasks-refactor-parity.test.ts | 158 ----- .../document-parse-time-effects.test.ts | 52 -- .../src/terminal/document/host-seams.test.ts | 35 - .../terminal/terminal-cell-box-census.test.ts | 62 -- .../terminal-foreground-recovery.test.ts | 44 -- .../terminal-input-connection-gate.test.ts | 91 +-- .../terminal-ios-dictation-write-back.test.ts | 23 - .../terminal-ios-ime-keyboard.test.ts | 23 - ...erminal-keyboard-avoidance-webview.test.ts | 40 -- .../terminal-live-input-affordance.test.ts | 73 -- .../terminal/terminal-viewport-refit.test.ts | 194 +---- .../terminal-webview-consumer-census.test.ts | 85 --- .../terminal/terminal-webview-engine.test.ts | 61 -- .../terminal-webview-query-reply.test.ts | 32 - .../terminal/terminal-webview-reflow.test.ts | 62 -- .../terminal-webview-scroll-routing.test.ts | 258 ------- .../terminal-webview-text-zoom.test.ts | 80 --- ...erminal-write-coalescer-boundaries.test.ts | 79 --- .../bridged-parity/c2-page-closure.test.ts | 235 ------ .../bridged-parity/c3-page-closure.test.ts | 222 ------ .../test-support/census-source-files.test.ts | 34 +- .../src/test-support/rpc-recording/README.md | 14 +- .../native-mounting-substitutes.test.ts | 1 - .../operation-module-loader.test.ts | 2 - .../test-support/webview-document-census.ts | 6 +- .../paired-host-writer-census.test.ts | 39 - .../transport/timer-receiver-census.test.ts | 126 ---- .../unchecked-rpc-reader-boundary.test.ts | 5 - mobile/src/worktree/home-resume-card.test.ts | 11 - mobile/tests-typecheck-baseline.txt | 1 - src/cli/cli-command-name-parity.test.ts | 25 - src/cli/dispatch.ts | 3 +- src/cli/handler-group-manifest.test.ts | 30 - .../orchestration-timeout-cli.test.ts | 288 -------- .../handlers/orchestration-timeout.test.ts | 292 -------- src/cli/handlers/orchestration.test.ts | 127 +++- src/cli/registry-parity.test.ts | 46 -- src/cli/registry-parity.ts | 21 - src/cli/runtime-client-deferral.test.ts | 37 +- src/cli/serve-electron-flag-parity.test.ts | 31 +- .../wsl-hook-relay-sentinel.test.ts | 2 - .../session-search-host-registration.test.ts | 31 - ...r-session-registry-import-boundary.test.ts | 12 - .../claude-account-service-api-parity.test.ts | 31 +- .../claude-agent-sdk-import-boundary.test.ts | 110 +-- ...x-trust-grant-main-thread-boundary.test.ts | 60 -- ...=> config-toml-trust-stale-writes.test.ts} | 44 +- .../gpu-crash-diagnostics.test.ts | 28 - .../gpu-crash-fallback-field-sessions.test.ts | 114 +-- .../gpu-fallback-recovered-launch.test.ts | 21 - .../work-item-details-api-parity.test.ts | 37 - ...omation-dispatcher-source-boundary.test.ts | 22 - src/main/ipc/filesystem-watcher-real.test.ts | 9 - .../ipc/github-ipc-channel-parity.test.ts | 110 --- .../pty-spawn-shell-override-parity.test.ts | 26 - .../pi/titlebar-extension-service.test.ts | 4 - .../ports/port-scan-command-client.test.ts | 12 - .../port-scan-command-import-boundary.test.ts | 18 - .../settled-pty-writer-census.test.ts | 94 --- .../providers/ssh-filesystem-provider.test.ts | 12 - ...uit-teardown-agent-browser-daemons.test.ts | 33 - .../minimax/minimax-fetcher.test.ts | 58 +- .../agent-session-launch-env-backfill.test.ts | 89 --- ...gent-startup-input-assembly-census.test.ts | 56 -- ...osted-browser-row-hydration-census.test.ts | 63 -- .../agent-status-producer-census.test.ts | 130 +--- .../rpc/terminal-stream-byte-length.test.ts | 102 --- ...ructured-claude-auth-policy-wiring.test.ts | 24 +- .../ssh/relay-protocol-backpressure.test.ts | 297 -------- src/main/ssh/ssh-system-fallback.test.ts | 8 - src/main/startup/configure-process.test.ts | 35 +- src/main/startup/secure-dns-census.test.ts | 10 - ...updater-test-harness.leaked-timers.test.ts | 123 ---- .../usage/usage-scan-worker-client.test.ts | 13 - src/relay/relay-watcher-event-emitter.test.ts | 9 - .../workspace-view-cross-client-sync.test.tsx | 75 +- .../src/components/AgentStateDot.test.ts | 10 +- .../automation-scoped-list-client.test.ts | 3 +- .../BrowserAnnotationSendMenuContent.test.tsx | 17 - .../BrowserPane.remote-link-routing.test.ts | 25 - .../BrowserPane.render-ipc.test.ts | 19 - ...-client-page-metadata-route-census.test.ts | 43 -- .../browser-guest-paint-retention.ts | 1 - ...rowser-guest-retention-site-census.test.ts | 223 ------ ...nfirmation-dialog-refresh-boundary.test.ts | 22 - ...ture-interaction-writer-boundaries.test.ts | 323 --------- ...github-item-dialog-source-boundary.test.ts | 310 -------- .../repro-8784-ghe-avatar-fallback.test.ts | 85 --- .../linear-issue-workspace-api-parity.test.ts | 49 -- .../linear-issue-workspace-header.test.ts | 30 - ...StructuredSession.transport-probe.test.tsx | 377 ---------- .../native-chat-composer-containment.test.ts | 33 - .../native-chat/native-chat-noise.test.ts | 56 -- ...ourceControl.host-context-boundary.test.ts | 111 --- .../ai-vault-session-refresh.test.ts | 7 - .../components/settings/GeneralPane.test.ts | 31 - ...rktreeDialog.host-context-boundary.test.ts | 34 - .../sidebar/StatusIndicator.test.ts | 10 +- .../sidebar/project-group-header-dom.test.ts | 63 -- .../sidebar/worktree-keyboard-cycle.test.ts | 26 - .../worktree-list/grouping/build-rows.test.ts | 51 -- .../PetStatusSegment.layout.test.ts | 15 - ...urce-session-classification-parity.test.ts | 52 -- ...pace-space-manager-source-boundary.test.ts | 79 --- ...hosted-browser-row-strip-placement.test.ts | 42 -- ...urface.client-hosted-active-state.test.tsx | 20 - .../task-drawer-source-boundary.test.ts | 108 --- .../task-page-github-issue-creation.test.ts | 53 -- .../task-page-source-family.test-support.ts | 39 - .../task-page-source-switch-boundary.test.ts | 99 --- .../task-page-task-creation-drafts.test.ts | 103 --- ...k-page-workspace-composer-boundary.test.ts | 136 ---- ...erminal-pane-listener-order-parity.test.ts | 94 --- ...al-close-confirm-keyboard-vs-mouse.test.ts | 24 - ...inal-provider-snapshot-capability.test.tsx | 17 - ...ktree-jump-palette-sleeping-filter.test.ts | 26 - ...mp-palette-source-context-boundary.test.ts | 37 - ...rktree-jump-palette-source.test-support.ts | 6 - ...ace-session-merge-close-tombstones.test.ts | 13 - ...poserState-host-context-boundaries.test.ts | 593 ---------------- .../useSettingsNavigationMetadata.test.ts | 40 -- ...agent-launch-caller-profile-census.test.ts | 91 --- ...ent-launch-caller-profiles-test-harness.ts | 140 +--- ...agent-launch-routing-caller-census.test.ts | 86 --- .../non-secure-context-crypto.repro.test.ts | 17 +- .../react-commit-cascade-telemetry.test.ts | 2 - ...uctured-agent-startup-label-census.test.ts | 28 - .../workspace-emoji-shortcodes.lazy.test.ts | 9 - ...reation-flow-agent-trust-preflight.test.ts | 32 - ...rktree-status-spinner-launch-agent.test.ts | 12 - ...browser-workspace-tab-close-census.test.ts | 197 ------ .../host-session-mirror-settle-census.test.ts | 256 ------- .../src/runtime/remote-server-parity.test.ts | 44 -- .../runtime-host-contact-parity.test.ts | 70 +- .../store/slices/github-pr-comments.test.ts | 56 -- .../github-work-items-query-bounds.test.ts | 25 - .../store/slices/linear-invalidation.test.ts | 29 - .../slices/repo-identity-reconcile.test.ts | 3 - .../runtime-host-status-entry-parity.test.ts | 56 -- .../web-runtime-client-export-parity.test.ts | 18 - .../child-process-import-boundary.test.ts | 27 - .../emoji-shortcode-catalog.lazy.test.ts | 35 - src/shared/network/manual-address.test.ts | 8 - .../pane-agent-identity-inventory.test.ts | 488 ------------- ...e-agent-identity-surface-inventory.test.ts | 284 -------- src/shared/priority-semaphore.test.ts | 30 - .../runtime-client-export-parity.test.ts | 242 ------- .../run.test.mjs | 11 - 212 files changed, 200 insertions(+), 16827 deletions(-) delete mode 100644 cloud/apps/relay/src/cell-inventory-lock-census.test.ts delete mode 100644 cloud/dev/scripts/push-validation-workflow.test.mjs delete mode 100644 cloud/dev/scripts/relay-asia-admission-workflow.test.mjs delete mode 100644 cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs delete mode 100644 config/scripts/cli-main-module-bundle-parity.test.ts delete mode 100644 config/scripts/packaged-hang-watchdog-worker-contract.test.mjs delete mode 100644 config/scripts/pr-workflow-lint-parity.test.mjs delete mode 100644 config/scripts/windows-pty-native-capability-workflow.test.mjs delete mode 100644 config/scripts/windows-signing-workflow-contract.test.mjs delete mode 100644 mobile/src/browser/mobile-browser-pane-source.test.ts delete mode 100644 mobile/src/files/files-router-seam-census.test.ts delete mode 100644 mobile/src/home-host-edit-navigation-source.test.ts delete mode 100644 mobile/src/hooks/use-mobile-dictation-source.test.ts delete mode 100644 mobile/src/host-screen/host-route-href-encoding.test.ts delete mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts delete mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts delete mode 100644 mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts delete mode 100644 mobile/src/mobile-web-shell/shell-screen-route-census.test.ts delete mode 100644 mobile/src/mobile-web-shell/shell-view-surface.test.ts delete mode 100644 mobile/src/platform/keyboard-geometry-host-os-census.test.ts delete mode 100644 mobile/src/platform/keyboard-geometry-owner-census.test.ts delete mode 100644 mobile/src/platform/microphone-screen-lock-census.test.ts delete mode 100644 mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts delete mode 100644 mobile/src/session/mobile-session-last-tab-close.test.ts delete mode 100644 mobile/src/session/mobile-session-route-parity.test.ts delete mode 100644 mobile/src/session/mobile-session-route-source-family.test-support.ts delete mode 100644 mobile/src/session/mobile-session-startup-source.test.ts delete mode 100644 mobile/src/session/quick-commands-tab-stability-source.test.ts delete mode 100644 mobile/src/session/session-router-seam-census.test.ts delete mode 100644 mobile/src/session/terminal-field-submit-binding-wiring.test.ts delete mode 100644 mobile/src/source-control/source-control-router-seam-census.test.ts delete mode 100644 mobile/src/tasks/mobile-tasks-refactor-parity.test.ts delete mode 100644 mobile/src/terminal/terminal-cell-box-census.test.ts delete mode 100644 mobile/src/terminal/terminal-ios-dictation-write-back.test.ts delete mode 100644 mobile/src/terminal/terminal-ios-ime-keyboard.test.ts delete mode 100644 mobile/src/terminal/terminal-live-input-affordance.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-consumer-census.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-reflow.test.ts delete mode 100644 mobile/src/terminal/terminal-webview-scroll-routing.test.ts delete mode 100644 mobile/src/test-support/bridged-parity/c2-page-closure.test.ts delete mode 100644 mobile/src/test-support/bridged-parity/c3-page-closure.test.ts delete mode 100644 mobile/src/transport/paired-host-writer-census.test.ts delete mode 100644 mobile/src/transport/timer-receiver-census.test.ts delete mode 100644 src/cli/cli-command-name-parity.test.ts delete mode 100644 src/cli/handlers/orchestration-timeout-cli.test.ts delete mode 100644 src/cli/handlers/orchestration-timeout.test.ts delete mode 100644 src/cli/registry-parity.test.ts delete mode 100644 src/cli/registry-parity.ts delete mode 100644 src/main/browser/browser-session-registry-import-boundary.test.ts delete mode 100644 src/main/codex/codex-trust-grant-main-thread-boundary.test.ts rename src/main/codex/{config-toml-trust-api-parity.test.ts => config-toml-trust-stale-writes.test.ts} (59%) delete mode 100644 src/main/github/work-item-details-api-parity.test.ts delete mode 100644 src/main/headless-automation-dispatcher-source-boundary.test.ts delete mode 100644 src/main/ipc/github-ipc-channel-parity.test.ts delete mode 100644 src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts delete mode 100644 src/main/ports/port-scan-command-import-boundary.test.ts delete mode 100644 src/main/providers/settled-pty-writer-census.test.ts delete mode 100644 src/main/quit-teardown-agent-browser-daemons.test.ts delete mode 100644 src/main/runtime/agent-session-launch-env-backfill.test.ts delete mode 100644 src/main/runtime/agent-startup-input-assembly-census.test.ts delete mode 100644 src/main/runtime/client-hosted-browser-row-hydration-census.test.ts delete mode 100644 src/main/ssh/relay-protocol-backpressure.test.ts delete mode 100644 src/main/updater-test-harness.leaked-timers.test.ts delete mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts delete mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts delete mode 100644 src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts delete mode 100644 src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts delete mode 100644 src/renderer/src/components/feature-interaction-writer-boundaries.test.ts delete mode 100644 src/renderer/src/components/github-item-dialog-source-boundary.test.ts delete mode 100644 src/renderer/src/components/linear-issue-workspace-api-parity.test.ts delete mode 100644 src/renderer/src/components/linear-issue-workspace-header.test.ts delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx delete mode 100644 src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-noise.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts delete mode 100644 src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts delete mode 100644 src/renderer/src/components/sidebar/project-group-header-dom.test.ts delete mode 100644 src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts delete mode 100644 src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts delete mode 100644 src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts delete mode 100644 src/renderer/src/components/task-drawer-source-boundary.test.ts delete mode 100644 src/renderer/src/components/task-page-github-issue-creation.test.ts delete mode 100644 src/renderer/src/components/task-page-source-family.test-support.ts delete mode 100644 src/renderer/src/components/task-page-source-switch-boundary.test.ts delete mode 100644 src/renderer/src/components/task-page-task-creation-drafts.test.ts delete mode 100644 src/renderer/src/components/task-page-workspace-composer-boundary.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts delete mode 100644 src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts delete mode 100644 src/renderer/src/components/worktree-jump-palette-source.test-support.ts delete mode 100644 src/renderer/src/lib/agent-launch-caller-profile-census.test.ts delete mode 100644 src/renderer/src/lib/agent-launch-routing-caller-census.test.ts delete mode 100644 src/renderer/src/lib/structured-agent-startup-label-census.test.ts delete mode 100644 src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts delete mode 100644 src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts delete mode 100644 src/renderer/src/runtime/host-session-mirror-settle-census.test.ts delete mode 100644 src/renderer/src/store/slices/github-work-items-query-bounds.test.ts delete mode 100644 src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts delete mode 100644 src/renderer/src/web/web-runtime-client-export-parity.test.ts delete mode 100644 src/shared/pane-agent-identity-inventory.test.ts delete mode 100644 src/shared/pane-agent-identity-surface-inventory.test.ts delete mode 100644 src/shared/runtime-client-export-parity.test.ts diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 3572a69299c..9a5cd9d4165 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -8482,7 +8482,7 @@ function isDatabaseLockUnavailable(error: unknown): boolean { return error instanceof Error && error.message === 'database_lock_unavailable' } -export function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { +function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { if (mode === 'nowait') return { failIfUnavailable: true, measureHoldMs: true } if (mode === 'pool-default') return { measureHoldMs: true } return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts deleted file mode 100644 index e26d8d0551e..00000000000 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { cellInventoryLockOptions, type CellInventoryLockMode } from './assignment-store.js' - -// Which entry points can reach a call site. A site a sweep can enter must never -// take the bounded wait: its 55P03 becomes a terminal transaction failure, and -// the incident monitor freezes on a single one. -type Reachability = 'request' | 'sweep' | 'both' | 'orphan' - -// 'caller' is not a CellInventoryLockMode: those sites take the mode threaded -// from `assign`, which is 'request' for a client and 'pool-default' for the -// evacuateDeadCells sweep. -type CensusMode = CellInventoryLockMode | 'caller' - -type CensusEntry = { method: string; mode: CensusMode; reach: Reachability } - -// Every lockCellInventory / lockGeneralCellInventory call site in -// assignment-store.ts, in source order. A new site fails this test until it is -// classified here, which is the point. -const CENSUS: CensusEntry[] = [ - // assignStickyOnce is gone from this list: its retry now locks only the row - // the host is pinned to (lockCellRows), which is what a sticky refresh - // touches. Placement below is the one genuinely fleet-wide decision left. - { method: 'assignOnce', mode: 'caller', reach: 'both' }, - { method: 'assignOnce', mode: 'caller', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'assignOnce', mode: 'nowait', reach: 'both' }, - { method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' }, - // changeActivity, acquireActivity, activateControl and - // removeSupersededSameCellControls no longer take the inventory: they lock - // only the one or two cell rows they touch, in cell_id order (lockCellRows), - // so they cannot cycle with placement's ordered inventory lock, and the - // 23-row lock there had serialised every reconnect in the fleet behind every - // other one. The control accept path went one step further and takes no cell - // read lock at all: its single conditional write is the last statement before - // COMMIT. - { method: 'startEvacuation', mode: 'request', reach: 'request' }, - { method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' }, - { method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' }, - { method: 'supersedeRegisteredEvacuationOnce', mode: 'request', reach: 'request' }, - { method: 'supersedeRegisteredEvacuationOnce', mode: 'nowait', reach: 'request' }, - { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, - { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, - { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, - { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, - { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, - // startRegionalRehomeCandidate is gone: the rehome commit reads the inventory - // unlocked and locks only its target row, NOWAIT, as the statement before - // COMMIT (reserveRegionalRehomeTargetRow below). - { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - // Both regional-rehome abort sweeps share this rollback; only the 24-hour - // one also disables the durable switch. - { method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, - { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' } - // reconcileReservationAccounting and leastLoadedCell are gone too: the first - // repairs exactly two cells' counters and now holds only those rows, and the - // second selects from the inventory its single caller has already locked. -] - -// Every inline `FROM relay_cells ... FOR UPDATE` outside the named lock helpers, -// in source order: whole-table locks in reconciliation and sticky placement, -// and single-row locks for a cell the method is already scoped to (heartbeat, -// fence, drain generation, configuration, or a reservation adjust that runs -// under a lock its caller already holds). A new inline lock fails the census -// below until it is listed here; per-connection paths that touch more than one -// cell go through lockCellRows so the order is fixed. -const NAMED_LOCK_HELPERS = ['lockCellInventory', 'lockGeneralCellInventory', 'lockCellRows'] - -const INLINE_CELL_LOCK_SITES = [ - 'reconcileCellsWithOptions', - 'assignStickyOnce', - 'recordCellHeartbeat', - 'attestCellFence', - 'adoptLegacyCellFence', - 'commitLegacyCellFenceAdoption', - 'prepareCellFenceAttempt', - 'attestCellFenceAttempt', - 'attestCellFenceAttempt', - 'configureCell', - 'reserveRegionalRehomeTargetRow', - 'assertDrainCellGeneration', - 'adjustCellReservation' -] - -// The background sweeps, and nothing else. A method reachable from one of these -// can be entered by a sweep tick, whatever else can also enter it. Both lists are -// read from source, so a new sweep step or a new route widens the derivation here -// instead of silently widening what a bounded wait can be entered from. -const SWEEP_ENTRY_FILES = ['./assignment-cleanup-steps.ts', './regional-rehome-worker.ts'] -const REQUEST_ENTRY_FILES = [ - './app.ts', - './relay-server.ts', - './host-session-registry.ts', - './cell-admission-startup.ts' -] - -const DECLARATION = /^ {2}(?:private |public )?(?:static )?(?:async )?([A-Za-z_][\w]*)[(<]/ - -function storeSource(): string[] { - return readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8').split('\n') -} - -function entryPoints(files: string[]): string[] { - return files.flatMap((file) => - [ - ...readFileSync(new URL(file, import.meta.url), 'utf8').matchAll( - /assignments\.([A-Za-z_][\w]*)\(/g - ) - ].map((call) => call[1]!) - ) -} - -// Same-class call graph: store methods only ever reach each other through `this.`. -function storeCallGraph(lines: string[]): Map> { - const bounds: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) bounds.push({ name: declaration[1]!, start: index }) - }) - const callees = new Map>() - bounds.forEach((method, index) => { - const end = bounds[index + 1]?.start ?? lines.length - const names = callees.get(method.name) ?? new Set() - for (const call of lines - .slice(method.start, end) - .join('\n') - .matchAll(/this\.([A-Za-z_][\w]*)\s*\(/g)) { - names.add(call[1]!) - } - callees.set(method.name, names) - }) - return callees -} - -function closure(callees: Map>, roots: string[]): Set { - const reached = new Set() - const pending = [...roots] - while (pending.length > 0) { - const name = pending.pop()! - if (reached.has(name)) continue - reached.add(name) - for (const callee of callees.get(name) ?? []) if (!reached.has(callee)) pending.push(callee) - } - return reached -} - -// Why: a hand-written reachability column is a claim, not a check. Derive both -// directions, so a new sweep edge into a bounded site fails here instead of in -// production, and so 'sweep' and 'both' stop being asserted by hand. -function derivedReachability(lines: string[]): (method: string) => Reachability { - const callees = storeCallGraph(lines) - const sweep = closure(callees, entryPoints(SWEEP_ENTRY_FILES)) - const request = closure(callees, entryPoints(REQUEST_ENTRY_FILES)) - return (method) => - sweep.has(method) - ? request.has(method) - ? 'both' - : 'sweep' - : request.has(method) - ? 'request' - : 'orphan' -} - -function readCallSites(): { method: string; mode: CensusMode }[] { - const sites: { method: string; mode: CensusMode }[] = [] - let method = '' - for (const line of storeSource()) { - const declaration = DECLARATION.exec(line) - if (declaration) method = declaration[1]! - if (/private async lock(General)?CellInventory\(/.test(line)) continue - const call = /lock(?:General)?CellInventory\(\s*\w+\s*,\s*(?:'([a-z-]+)'|(\w+))\s*\)/.exec(line) - if (!call) continue - sites.push({ method, mode: (call[1] ?? 'caller') as CensusMode }) - } - return sites -} - - -// Tier 3 and tier 4 of the row lock order documented in assignment-store.ts. A -// transaction that takes relay_cells before this host's reservation rows can -// cycle with one that takes them the other way round, and PostgreSQL resolves -// that as a 40P01 during exactly the drain and rehome waves these paths exist -// to run. The cell row is the one every host on a cell shares, so it is the -// lock that must be taken last, which fixes the direction for everyone else. -const CELL_LOCK_CALL = - /this\.(?:lockCellInventory|lockGeneralCellInventory|lockCellRows|adjustCellReservationAtomically|adjustCellReservation)\(|UPDATE relay_cells/ -const RESERVATION_LOCK_CALL = - /this\.(?:lockControlConnectionReservations|insertControlConnectionReservation|claimControlConnectionReservation|releaseSupersededControlConnectionReservations)\(|(?:UPDATE|INTO|DELETE FROM)\s+relay_control_connection_reservations/ - -// The lock helpers themselves, plus the one reporting query that reads both -// tables without locking either. -const ROW_LOCK_ORDER_EXEMPT = [ - 'lockCellInventory', - 'lockGeneralCellInventory', - 'lockCellRows', - 'lockControlConnectionReservations', - 'adjustCellReservation', - 'adjustCellReservationAtomically', - 'insertControlConnectionReservation', - 'claimControlConnectionReservation', - 'releaseSupersededControlConnectionReservations', - 'cellDeploymentStatus' -] - -function methodSpans(lines: string[]): { name: string; start: number; end: number }[] { - const starts: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) starts.push({ name: declaration[1]!, start: index }) - }) - return starts.map((entry, index) => ({ - ...entry, - end: starts[index + 1]?.start ?? lines.length - })) -} - -function pathsTakingCellsBeforeReservations(lines: string[]): string[] { - const offending: string[] = [] - for (const span of methodSpans(lines)) { - if (ROW_LOCK_ORDER_EXEMPT.includes(span.name)) continue - let cell = Number.POSITIVE_INFINITY - let reservation = Number.POSITIVE_INFINITY - for (let index = span.start; index < span.end; index++) { - const line = lines[index]! - if (CELL_LOCK_CALL.test(line)) cell = Math.min(cell, index) - if (RESERVATION_LOCK_CALL.test(line)) reservation = Math.min(reservation, index) - } - if (cell < reservation && reservation !== Number.POSITIVE_INFINITY) { - offending.push(span.name) - } - } - return offending -} - -describe('cell inventory lock call-site census', () => { - it('classifies every call site exactly as recorded', () => { - expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode }))) - }) - - // Why: the census only sees lockCellInventory calls, so a hand-written - // `relay_cells ... FOR UPDATE` would escape classification entirely. - it('routes every relay_cells row lock through a named lock helper', () => { - const lines = storeSource() - const rawSites: string[] = [] - // Whole statements, not a fixed window: a wide column list or a raw - // FOR UPDATE inside query() must not slip past. - const source = lines.join('\n') - const bounds: { name: string; start: number }[] = [] - lines.forEach((line, index) => { - const declaration = DECLARATION.exec(line) - if (declaration) bounds.push({ name: declaration[1]!, start: index }) - }) - const methodAt = (offset: number): string => { - const lineIndex = source.slice(0, offset).split('\n').length - 1 - let name = '' - for (const bound of bounds) if (bound.start <= lineIndex) name = bound.name - return name - } - const tick = String.fromCharCode(96) - const statementCall = new RegExp( - '\\.(queryLocked|query)\\(\\s*' + tick + '([^' + tick + ']*)' + tick, - 'g' - ) - for (const call of source.matchAll(statementCall)) { - const statement = call[2]! - if (!/\bFROM\s+relay_cells\b/.test(statement)) continue - const locks = call[1] === 'queryLocked' || /\bFOR\s+UPDATE\b/.test(statement) - if (!locks) continue - const method = methodAt(call.index) - if (NAMED_LOCK_HELPERS.includes(method)) continue - rawSites.push(method) - } - expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES) - }) - - it('takes the host reservation rows before the shared cell row everywhere', () => { - expect(pathsTakingCellsBeforeReservations(storeSource())).toEqual([]) - }) - - it('leaves no call site taking the inventory without naming a mode', () => { - const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8') - const unclassified = source - .split('\n') - .filter((line) => /lock(?:General)?CellInventory\(\s*\w+\s*\)/.test(line)) - .filter((line) => !line.includes('private async')) - - expect(unclassified).toEqual([]) - }) - - it('derives the same reachability the census claims', () => { - const reachOf = derivedReachability(storeSource()) - - expect(readCallSites().map(({ method }) => reachOf(method))).toEqual( - CENSUS.map((entry) => entry.reach) - ) - }) - - // Why: this is the whole point of the classification. A shorter wait on a - // sweep-reachable site turns contention into a terminal transaction failure - // that counts against the incident gate's relayPostgresRetryExhausted bar. - // Why: the hold distribution is what the 500ms bound will be tuned against, so - // a mode that stops asking for it goes unmeasured in exactly the lane that - // matters. Nothing else in the suite reads the pool-default branch. - it('measures the hold in every lock mode', () => { - const modes: CellInventoryLockMode[] = ['request', 'nowait', 'pool-default'] - - expect(modes.map((mode) => cellInventoryLockOptions(mode).measureHoldMs)).toEqual([ - true, - true, - true - ]) - }) - - it('never puts a sweep-reachable site on the bounded wait', () => { - const reachOf = derivedReachability(storeSource()) - const bounded = readCallSites().filter( - (site) => site.mode === 'request' && ['sweep', 'both'].includes(reachOf(site.method)) - ) - - expect(bounded).toEqual([]) - }) - - it('routes every sweep-only site to NOWAIT so it can skip the tick', () => { - const reachOf = derivedReachability(storeSource()) - const queueing = readCallSites().filter( - (site) => reachOf(site.method) === 'sweep' && site.mode !== 'nowait' - ) - - expect(queueing).toEqual([]) - }) -}) diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts index 44a4b990ea8..1bf3153f3e4 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' const fakes = vi.hoisted(() => ({ @@ -78,7 +77,6 @@ describe('bounded cell-inventory lock wait', () => { // Why: a bound at or above the pool default would fence nothing, and one far // below the hold time would convert ordinary contention into terminal failures. it('keeps the request bound strictly inside the pool default', () => { - expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBe(500) expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS) }) @@ -361,15 +359,6 @@ describe('bounded cell-inventory lock wait', () => { await database.close() }) - // Why: index.ts boots a server on import, so its wiring can only be read. An - // unspread hold metric is invisible: the flush simply omits the fields. - it('spreads the hold counts into the runtime metrics flush', () => { - const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') - const flush = /observability\.start\(\(\) => \(\{([^}]*)\}\)\)/.exec(source) - - expect(flush?.[1]).toContain('...consumeRelayCellInventoryHold(database)') - }) - // Why: 500ms is a first value, not a measurement. Tuning it needs the hold // distribution, which no runtime metric carried. it('reports how long the inventory lock was held to COMMIT', async () => { @@ -492,25 +481,6 @@ describe('background sweeps skip a contended cell inventory', () => { expect(warnings.entries).toEqual([]) await database.close() }) - - it('still aborts the expired evacuation once the inventory is free', async () => { - const database = await openInMemoryRelayDatabase() - const probe = new InventoryLockProbe(database) - let now = 1_000 - const store = new RelayAssignmentStore(probe, () => now) - await store.reconcileCells(CELLS) - const assignment = await store.assign(identity) - await store.activateControl(identity, { - cellId: assignment.cellId, - assignmentEpoch: assignment.assignmentEpoch, - generation: 1 - }) - await store.startEvacuation(identity, 'cell-b') - now += 24 * 60 * 60_000 - - expect(await store.abortExpiredEvacuations()).toBe(1) - await database.close() - }) }) // Returns each inventory lock the run took, as its bound or 'nowait'. diff --git a/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs b/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs index d79d4f91046..628b09176da 100644 --- a/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs +++ b/cloud/dev/scripts/production-cloud-sql-rollout-lock.test.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import { test } from 'node:test' import { LEASED_WORKFLOWS, @@ -21,7 +20,6 @@ import { revisionMintingScripts, workflowFiles } from './cloud-sql-rollout-lock-census.mjs' -import { relayWorkflowFile } from './relay-repository.mjs' const expectedLease = { production: PRODUCTION_LEASE, staging: STAGING_LEASE, selectable: SELECTABLE_LEASE } const leasedFiles = Object.keys(LEASED_WORKFLOWS) @@ -182,29 +180,3 @@ test('census: no workflow rolls out against the shared instance outside the leas assert.ok(!(file in NOT_A_CLOUD_SQL_CANDIDATE), `${file} cannot be both leased and a non-candidate`) } }) - -// The API and auth deploy scripts share this contract but stay in the private repository. -const serviceCapScripts = ['dev/scripts/deploy-relay-blue-green.mjs'] - -test('budgets tagged Cloud Run candidates outside the service-wide instance cap', () => { - for (const file of serviceCapScripts) { - const script = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8') - assert.match(script, /'--no-traffic'/, file) - assert.match(script, /'--max'/, file) - } - const budget = readFileSync( - new URL('../../dev/scripts/relay-cloud-sql-connection-budget.mjs', import.meta.url), - 'utf8' - ) - assert.match(budget, /directly addressable tagged revisions outside service-level caps/) - assert.match( - budget, - /apiCandidate: retainedDirectorRollback \+ inputs\.apiInstances \* inputs\.apiPoolMax/ - ) - const director = readWorkflow(relayWorkflowFile('deploy-relay-production-director.yml')) - const capacity = readWorkflow(relayWorkflowFile('deploy-relay-production-capacity-job.yml')) - const asia = readWorkflow(relayWorkflowFile('operate-relay-asia-admission.yml')) - assert.match(director, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/) - assert.match(capacity, /--max-instances 5/) - assert.match(asia, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/) -}) diff --git a/cloud/dev/scripts/push-gateway-workflow.test.mjs b/cloud/dev/scripts/push-gateway-workflow.test.mjs index 3ee4fabe410..5a8349c1855 100644 --- a/cloud/dev/scripts/push-gateway-workflow.test.mjs +++ b/cloud/dev/scripts/push-gateway-workflow.test.mjs @@ -309,11 +309,6 @@ test('push credentials cannot assume the shared Relay deploy identity', () => { assert.doesNotMatch(terraform('push-gateway.tf'), /member\s*=\s*local\.relay_github_deploy_service_account_member/) }) -// A latest revision needs a successor even when validation is inert. -test('dedicated database admits three simultaneous revision pools', () => { - assert.match(terraform('push-gateway.tf'), /var\.push_max_instances \* var\.push_database_pool_max \* 3 <= 64/) -}) - test('push has only a dedicated database attachment and a narrowly scoped deployment lease', () => { const service = terraform('push-gateway.tf') const database = terraform('push-dedicated-database.tf') diff --git a/cloud/dev/scripts/push-validation-workflow.test.mjs b/cloud/dev/scripts/push-validation-workflow.test.mjs deleted file mode 100644 index e26769cfeb3..00000000000 --- a/cloud/dev/scripts/push-validation-workflow.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' -import { readRelayWorkflow } from './relay-repository.mjs' - -const workflow = readRelayWorkflow('push-deploy.yml') -const position = (name) => { - const index = workflow.indexOf(`- name: ${name}`) - assert.notEqual(index, -1) - return index -} -const capability = position('Require image support for inert validation') -const deploy = position('Deploy the candidate revision with no traffic') -const activation = position('Retire inert validation and activate the verified image') -const shift = position('Shift all traffic to the verified candidate') - -test('the exact build digest must support validation before production boot', () => { - assert.match(workflow, /docker buildx build --push --platform linux\/amd64 --provenance=false --metadata-file/) - assert.match(workflow, /containerimage\.digest/) - assert.doesNotMatch(workflow, /gcloud artifacts docker images describe/) - assert.ok(capability < deploy) - const preflight = workflow.slice(capability, deploy) - assert.match(preflight, /docker run --rm --network none --entrypoint node "\$\{IMAGE\}"/) - assert.match(preflight, /loadPushConfig\(env\)\.mode !== "validation"/) - assert.match(preflight, /validation_mode_not_fail_closed/) -}) - -test('inert validation and credential checks precede deliberate activation of the same digest', () => { - assert.match(workflow.slice(deploy, activation), /--update-env-vars ORCA_PUSH_MODE=validation/) - assert.match(workflow.slice(deploy, activation), /\.mode == "validation"/) - assert.ok(position('Prove the runtime identity can reach FCM') < activation) - const active = workflow.slice(activation, shift) - assert.ok(active.indexOf('gcloud run deploy') < active.indexOf('gcloud run revisions delete')) - assert.match(active, /--image "\$\{IMAGE\}"/) - assert.match(active, /--remove-env-vars ORCA_PUSH_MODE/) - assert.match(active, /\.spec\.containers\[0\]\.image == \$image/) - assert.match(active, /\.spec\.serviceAccountName == \$account/) - assert.match(active, /\.mode == "active"/) - assert.ok(active.indexOf('ACTIVATION_ATTEMPTED=true') < active.indexOf('gcloud run deploy')) - assert.match(workflow, /deletion below must stop its workers/) -}) - -test('production startup connects read-only and gates all background work in validation', () => { - const entry = readFileSync(new URL('../../apps/push/src/index.ts', import.meta.url), 'utf8') - assert.match(entry, /readOnly: config\.mode === 'validation'/) - assert.match(entry, /startPushBackground\(config,/) - assert.doesNotMatch(entry, /worker\.start\(/) -}) diff --git a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs b/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs deleted file mode 100644 index 8283d43ca44..00000000000 --- a/cloud/dev/scripts/relay-asia-admission-workflow.test.mjs +++ /dev/null @@ -1,375 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { relayWorkflowUrl } from './relay-repository.mjs' - -const workflow = readFileSync( - relayWorkflowUrl('operate-relay-asia-admission.yml'), - 'utf8' -) -const iam = readFileSync(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url), 'utf8') -const stagingProof = readFileSync( - relayWorkflowUrl('prove-relay-asia-staging.yml'), - 'utf8' -) -const directorWorkflow = readFileSync( - relayWorkflowUrl('deploy-relay-production-director.yml'), - 'utf8' -) -const terraformReadme = readFileSync( - new URL('../../infra/terraform/README.md', import.meta.url), - 'utf8' -) -const proofIam = readFileSync( - new URL('../../infra/terraform/relay-asia-proof-iam.tf', import.meta.url), - 'utf8' -) -const relayTerraform = readFileSync( - new URL('../../infra/terraform/relay.tf', import.meta.url), - 'utf8' -) -const rolloutEvidence = readFileSync( - new URL('./relay-asia-rollout-evidence.mjs', import.meta.url), - 'utf8' -) -const admissionBudgets = readFileSync( - new URL('../../packages/relay-contract/src/admission-budgets.ts', import.meta.url), - 'utf8' -) - -test('offers the exact audited admission modes under the shared deployment lock', () => { - for (const mode of [ - 'inspect', 'initialize', 'verify', 'register', 'configure', 'promote', 'rollback' - ]) { - assert.match(workflow, new RegExp(`\\b${mode}\\b`)) - } - assert.match(workflow, /production-cloud-sql-rollout/) - assert.match(workflow, /relay-staging-mutation/) - assert.match(workflow, /selector-generation/) - assert.match(workflow, /selector-attempt-id/) -}) - -test('requires exact confirmations and uses the existing admin identity', () => { - assert.match(workflow, /INITIALIZE_ADMISSION_SELECTOR/) - assert.match(workflow, /REGISTER_ASIA_MIGRATION_ONLY/) - assert.match(workflow, /PROMOTE_ASIA_GENERAL/) - assert.match(workflow, /ROLLBACK_ASIA_MIGRATION_ONLY/) - assert.match(workflow, /CONFIGURE_ASIA_DIRECTOR/) - assert.match(workflow, /GCP_RELAY_DEPLOY_SERVICE_ACCOUNT/) - assert.match(workflow, /id_token_audience: \$\{\{ env\.DIRECTOR_ORIGIN \}\}\/v1\/admin\/drain/) - assert.match(iam, /"operate-relay-asia-admission\.yml"/) -}) - -test('discovers generation read-only and explicitly initializes only generation zero', () => { - assert.match(workflow, /leave empty only for inspect/) - assert.match(workflow, /test -z "\$\{EXPECTED_SELECTOR_GENERATION\}"/) - assert.match(workflow, /test "\$\{EXPECTED_SELECTOR_GENERATION\}" = 0/) - assert.match(workflow, /\^\(0\|\[1-9\]\[0-9\]\*\)\$/) - assert.match(workflow, /selector-membership-sha256/) - assert.match(workflow, /\^\[a-f0-9\]\{64\}\$/) - assert.match(workflow, /director-image-digest/) - assert.match(workflow, /\.spec\.containers\[0\]\.image == \$image/) -}) - -test('uploads one sanitized machine-readable admission result', () => { - assert.match(workflow, /sanitize-relay-asia-admission-result\.mjs/) - const upload = /- name: Upload sanitized admission result\n([\s\S]*?)(?=\n - name:)/ - .exec(workflow)?.[1] - assert.ok(upload) - assert.match( - upload, - /if: \$\{\{ inputs\.mode != 'configure' && steps\.admission-operation\.outcome == 'success' \}\}/ - ) - assert.match(upload, /uses: actions\/upload-artifact@v4/) - assert.match( - upload, - /relay-asia-admission-result-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/ - ) - assert.match(upload, /path: \$\{\{ runner\.temp \}\}\/relay-asia-admission-result\/result\.json/) - assert.match(upload, /if-no-files-found: error/) - assert.match(upload, /retention-days: 7/) - assert.ok( - workflow.indexOf('Upload sanitized admission result') > - workflow.indexOf('Upload immutable canary evidence') - ) -}) - -test('binds selector operations and director configuration to reviewed implementations', () => { - assert.match(workflow, /operate-relay-asia-admission\.mjs/) - assert.match(workflow, /prepare-relay-asia-director-cells\.mjs/) - assert.match(workflow, /deploy-relay-blue-green\.mjs/) - assert.match(workflow, /--prune-revisions false/) - assert.doesNotMatch(workflow, /gcloud secrets versions add/) - assert.match(workflow, /orca-cloud-relay-regional-placement-enabled/) - assert.match(workflow, /\.valueSource\.secretKeyRef/) - assert.match(workflow, /jq -er --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/) - assert.doesNotMatch(workflow, /jq -e --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/) - assert.doesNotMatch(workflow, /--regional-placement-enabled/) - assert.doesNotMatch(workflow, /"\$\{\{ inputs\./) - assert.doesNotMatch(workflow, /dns/i) -}) - -test('requires immutable staged evidence and a timed production canary before expansion', () => { - assert.match(workflow, /actions: read/) - assert.match(workflow, /actions\/download-artifact@v4/) - assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/) - assert.match(workflow, /evidence_kind=staging/) - assert.match(workflow, /load-relay-controls\.mjs/) - assert.match(workflow, /--controls 1/) - assert.match(workflow, /--splices 1/) - assert.match(workflow, /--splice-hold-seconds 60/) - assert.match(workflow, /--relay-asia-load-principals 1/) - assert.match(workflow, /--duration-seconds 300/) - assert.match(workflow, /--required-lease-horizons 2/) - assert.match(workflow, /pnpm\/action-setup@v4/) - assert.match(workflow, /Install exact canary dependencies/) - assert.match(workflow, /pnpm install --frozen-lockfile/) - assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/) - assert.ok( - workflow.indexOf('Build the canary Relay contract') < - workflow.indexOf('Run a real five-minute canary control and splice') - ) - assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/) - assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/) - // C28/C29 promotion downloads C27's canary under exactly this name. - assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/) - assert.match(workflow, /id: canary-evidence-upload/) - assert.match(workflow, /Return an unproven canary cell to migration-only/) - assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/) - assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/) - assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/) - assert.match(workflow, /evidence_kind=c27/) - assert.match(workflow, /orca_relay_runtime_metrics/) - assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/) - assert.match(workflow, /retention-days: 7/) - assert.match(workflow, /Require the exact director image before promotion/) - assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) - assert.match(workflow, /\.imageDigest.*IMAGE_DIGEST/) - const provenance = /- name: Verify evidence provenance and rollout binding before authentication\n([\s\S]*?)(?=\n - id: auth)/ - .exec(workflow)?.[1] - assert.ok(provenance) - assert.match(provenance, /\.head_sha \| select\(type == "string" and test\("\^\[a-f0-9\]\{40\}\$"\)\)/) - assert.match(provenance, /--commit-sha "\$\{evidence_commit_sha\}"/) - assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/) -}) - -test('binds each production promotion wave to its exact evidence and canary', () => { - const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1] - assert.ok(cases) - const waves = Object.fromEntries( - [...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], { - evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none', - canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none' - }]) - ) - assert.deepEqual(waves, { - 'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' }, - 'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' }, - 'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' } - }) - assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/) - assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/) - assert.doesNotMatch(workflow, /inputs\.cell-ids == /) -}) - -test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => { - const steps = workflow.split(/\n(?= - )/) - const named = (name) => steps.find((step) => step.includes(`name: ${name}`)) - for (const name of [ - 'Install exact canary dependencies', - 'Build the canary Relay contract', - 'Verify the canary cell state and start the timed canary', - 'Run a real five-minute canary control and splice', - 'Collect regional, Relay SQL, and Cloud SQL canary evidence', - 'Upload immutable canary evidence' - ]) { - assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name) - } - assert.match( - workflow, - /if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/ - ) - const rollback = named('Return an unproven canary cell to migration-only') - assert.match( - rollback, - /if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/ - ) - assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/) - assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) - assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/) - assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/) - const start = named('Verify the canary cell state and start the timed canary') - assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/) - assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/) - assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/) -}) - -test('creates staging evidence only after the bounded launch-path load and rollback', () => { - assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/) - assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/) - assert.match(stagingProof, /pnpm\/action-setup@v4/) - assert.match(stagingProof, /pnpm install --frozen-lockfile/) - assert.match(stagingProof, /pnpm --filter @orca-cloud\/relay-contract build/) - assert.match(stagingProof, /run_phase launch 5 5/) - assert.doesNotMatch(stagingProof, /run_phase control|run_phase mixed/) - assert.match(stagingProof, /--aggregate-controls "\$\(\(controls \* 4\)\)"/) - assert.match(stagingProof, /--aggregate-splices "\$\(\(splices \* 4\)\)"/) - assert.match(stagingProof, /--required-lease-horizons 2/) - assert.match(stagingProof, /--splice-ramp-seconds 120/) - assert.match(stagingProof, /--max-generator-rss-growth-mib 512/) - assert.match(stagingProof, /--relay-asia-load-principals 32/) - assert.match(stagingProof, /ulimit -n/) - assert.match(stagingProof, /--region-behavior-probes 1/) - assert.match(stagingProof, /--capacity-cell-origin https:\/\/c4\.relay-staging\.onorca\.dev/) - assert.match(stagingProof, /--rebind-probes 2/) - assert.match(stagingProof, /--skip-rebind-overflow-check/) - assert.doesNotMatch(stagingProof, /--request-unit-invites|--regional-fallback-probes/) - assert.match(stagingProof, /--aggregate-reader-splices.*echo 5/) - assert.match(stagingProof, /--aggregate-reader-bytes.*echo 12582912/) - assert.match(stagingProof, /--phase-barrier-dir "\$\{proof_dir\}\/\$\{phase\}-barrier"/) - assert.match(stagingProof, /--duration-seconds 210/) - assert.match(stagingProof, /trap stop_shards EXIT/) - assert.match(stagingProof, /if ! wait "\$\{pid\}"; then failed=1; break; fi/) - assert.match(stagingProof, /connectionFailuresByReason/) - assert.match(stagingProof, /--launch-report "\$\{proof_dir\}\/launch\.json"/) - assert.match(stagingProof, /id-token: write/) - assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER/) - assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT/) - assert.doesNotMatch(stagingProof, /STAGING_GCP_DEPLOY_SERVICE_ACCOUNT/) - assert.doesNotMatch(stagingProof, /STAGING_RELAY_LOAD_ACCESS_TOKEN/) - assert.doesNotMatch(stagingProof, /secrets versions access|signing-key-file/) - assert.match(stagingProof, /relay-asia-rollout-evidence\.mjs create-staging/) - assert.match(stagingProof, /Require the exact staging director image before promotion/) - assert.match(stagingProof, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/) - assert.match(stagingProof, /\.imageDigest.*IMAGE_DIGEST/) - assert.match(stagingProof, /Return staging C4 to migration-only/) - assert.match(stagingProof, /steps\.promote\.outcome != 'skipped'/) - assert.match(stagingProof, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{PROMOTE_ATTEMPT_ID\}"/) - assert.match(stagingProof, /--mode rollback[\s\S]*?--expected-generation "\$\{promoted_generation\}"/) - assert.match(stagingProof, /if: \$\{\{ success\(\) \}\}/) - assert.match( - stagingProof, - /recover:\n if: \$\{\{ always\(\) && github\.ref == 'refs\/heads\/main' \}\}/ - ) - assert.match(stagingProof, /needs: prove/) - assert.match(stagingProof, /Recover staging C4 with a fresh identity/) - assert.equal((stagingProof.match(/google-github-actions\/auth@v2/g) ?? []).length, 2) - assert.equal((stagingProof.match(/--mode recover-promotion/g) ?? []).length, 2) - assert.equal((stagingProof.match(/--mode rollback/g) ?? []).length, 2) -}) - -test('keeps the private runner below its 64-port Cloud NAT allocation', () => { - const profile = /run_phase launch (\d+) (\d+)/.exec(stagingProof) - const controlsPerShard = Number(profile?.[1]) - const splicesPerShard = Number(profile?.[2]) - const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1]) - const runtimeStatusSockets = 1 - assert.ok( - controlsPerShard * 4 + splicesPerShard * 4 * 2 + rebindProbes + runtimeStatusSockets < 64 - ) -}) - -test('paces one-source staging upgrades below the Relay anti-abuse ceiling', () => { - const splicesPerShard = Number(/run_phase launch \d+ (\d+)/.exec(stagingProof)?.[1]) - const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1]) - const spliceRampMs = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) * 1000 - const ceiling = Number( - /maxPreAuthAttemptsPerSourcePerMinute: (\d+)/.exec(admissionBudgets)?.[1] - ) - const totalSplices = splicesPerShard * 4 - const attempts = Array.from({ length: totalSplices }, (_, ordinal) => - Math.floor(ordinal * spliceRampMs / (totalSplices - 1)) - ).flatMap((startedAt) => [startedAt, startedAt]) - attempts.push(...Array.from({ length: 4 + rebindProbes }, () => 0)) - const busiestMinute = Math.max(...attempts.map((startedAt) => - attempts.filter((attempt) => attempt >= startedAt && attempt < startedAt + 60_000).length - )) - assert.ok(busiestMinute < ceiling) -}) - -test('reserves rollback time beyond the complete bounded staging proof envelope', () => { - const timeoutMinutes = Number(/timeout-minutes: (\d+)/.exec(stagingProof)?.[1]) - assert.equal(timeoutMinutes, 75) - const spliceRampSeconds = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) - const launchSeconds = 180 + spliceRampSeconds + 210 + 60 - const setupEvidenceAndRollbackSeconds = 10 * 60 - const envelopeMinutes = Math.ceil((launchSeconds + setupEvidenceAndRollbackSeconds) / 60) - assert.ok(timeoutMinutes - envelopeMinutes >= 30) - assert.match(stagingProof, /--ramp-seconds 180/) - assert.match(stagingProof, /--duration-seconds 210/) -}) - -test('binds the staging proof to one least-privilege Google identity', () => { - assert.match( - proofIam, - /github_relay_asia_proof_workflow_file = "prove-relay-asia-staging\.yml"/ - ) - assert.match( - proofIam, - /assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_relay_asia_proof_workflow_file\}@refs\/heads\/main'/ - ) - assert.match(proofIam, /assertion\.environment == 'staging'/) - assert.match(proofIam, /assertion\.event_name == 'workflow_dispatch'/) - assert.match(proofIam, /roles\/logging\.viewer/) - assert.match(proofIam, /roles\/monitoring\.viewer/) - assert.match(rolloutEvidence, /readCloudSqlBackends/) - assert.match(rolloutEvidence, /cloudSql: await readCloudSqlBackends/) - assert.doesNotMatch(proofIam, /compute\.|cloudsql\.|secretmanager\.|roles\/editor|roles\/run\./) -}) - -test('keeps the production US-first switch in durable Secret Manager state', () => { - assert.match(directorWorkflow, /options: \[preserve, enable, disable\]/) - assert.match(directorWorkflow, /default: preserve/) - assert.match(directorWorkflow, /gcloud secrets versions add/) - assert.match(directorWorkflow, /preserve\) desired="\$\{current\}"/) - assert.match(directorWorkflow, /--regional-placement-secret-version "\$\{target_version\}"/) - assert.match(directorWorkflow, /test "\$\{CEILING\}" = "\$\{DIRECTOR_MAX_INSTANCES\}"/) - assert.match(directorWorkflow, /orca-cloud-relay-regional-placement-enabled/) - assert.match(directorWorkflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/) - assert.match(directorWorkflow, /\.version \/\/ \.key/) - assert.match(directorWorkflow, /\.secret \/\/ \.name/) - assert.match(workflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/) - assert.doesNotMatch(directorWorkflow, /--regional-placement-enabled/) - assert.doesNotMatch(workflow, /inputs\.regional-placement-enabled/) -}) - -test('prunes incompatible production revisions only when explicitly confirmed', () => { - assert.match( - directorWorkflow, - /prune-incompatible-revisions:[\s\S]*?default: false[\s\S]*?type: boolean/ - ) - assert.match(directorWorkflow, /PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/) - assert.match( - directorWorkflow, - /test "\$\{REGIONAL_PLACEMENT_MODE\}" = preserve[\s\S]*?test "\$\{CONFIRMATION\}" = PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/ - ) - assert.match( - directorWorkflow, - /--prune-revisions "\$\{PRUNE_INCOMPATIBLE_REVISIONS\}"/ - ) -}) - -test('documents the exact regional-placement secret bootstrap before director rollout', () => { - for (const address of [ - 'google_secret_manager_secret.relay_regional_placement_enabled', - 'google_secret_manager_secret_version.relay_regional_placement_enabled', - 'google_secret_manager_secret_iam_member.relay_regional_placement_runtime_accessor', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_accessor[0]', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_adder[0]', - 'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer[0]' - ]) { - assert.match(terraformReadme, new RegExp(address.replaceAll(/[.[\]]/g, '\\$&'))) - } - assert.match(terraformReadme, /Before the first director deployment/) - assert.match(terraformReadme, /Pass the exact environment tfvars/) - // The Cloudflare records left with the apps root; a -var for a variable this root no longer - // declares is a hard error, so no relay procedure may still tell an operator to pass it. - assert.doesNotMatch(terraformReadme, /manage_artifact_dns/) - assert.match(terraformReadme, /exactly these six additions/) - assert.match(terraformReadme, /version metadata/) - assert.match( - relayTerraform, - /resource "google_secret_manager_secret_iam_member" "relay_regional_placement_deploy_viewer"[\s\S]*?role\s+= "roles\/secretmanager\.viewer"/ - ) -}) diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs deleted file mode 100644 index aabf80a65f0..00000000000 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' -import { relayWorkflowUrl } from './relay-repository.mjs' - -function workflow(name) { - return readFileSync( - fileURLToPath(relayWorkflowUrl(name)), - 'utf8' - ) -} - -test('same-cap wrapper is reusable, canary-bound, and sequential', () => { - const wrapper = workflow('deploy-relay-production-same-cap.yml') - const job = workflow('deploy-relay-production-same-cap-job.yml') - assert.match(wrapper, /options: \[verify, canary-apply, batch-apply, rollback\]/) - assert.match(wrapper, /relay-same-cap-canary-\$\{\{ inputs\.canary-run-id \}\}/) - assert.match(wrapper, /needs: \[gate, cell_1\]/) - assert.match(wrapper, /needs: \[gate, cell_2\]/) - assert.match(wrapper, /needs: \[gate, cell_3\]/) - assert.match(job, /on:\n workflow_call:/) - assert.match(job, /c27\|c28\|c29\|c30\)/) - assert.match(job, /EXPECTED_HARD_CAP=3000/) - assert.match(job, /EXPECTED_REGION=asia-east2/) - assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/) - assert.match(job, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/) - assert.match(job, /--argjson protocol "\$\{PREDECESSOR_REHOME_PROTOCOL\}"/) - assert.match(job, /runtime predecessor mismatch fields=/) - // A rollback interrupted between apply and restore must be resumable. - assert.match(job, /ROLLBACK_RESUME=true/) - assert.match(job, /test "\$\{LIVE_IMAGE_DIGEST\}" = "\$\{DESIRED_IMAGE_DIGEST\}"/) - // Resume must skip BOTH the drain (no restart will clear the flag) and the - // apply (state already converged), and prove convergence instead. - assert.match( - job, - /Reversibly isolate and drain only the selected cell\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/ - ) - assert.match( - job, - /Apply only the selected same-cap template and MIG\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/ - ) - assert.match( - job, - /Require converged Terraform state and a stable MIG on resume\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME == 'true' \}\}/ - ) - assert.match(job, /resume found unconverged resources/) - // A canary or batch cell that failed before its template apply also - // resumes here with template drift from repo changes since its last roll; - // only a plan the reviewed validator approves for the image the cell - // already serves may pass, and resume still applies nothing. - assert.match(job, /requiring reviewed rollback-image drift/) - assert.match( - job, - /--image "\$\{DESIRED_IMAGE\}" \\\n {16}--rollback-image "\$\{DESIRED_IMAGE\}"/ - ) - // The relaxation is only safe if the reviewed validator actually runs on - // the NON-converged branch, in same-cap-cell mode, with the trust config - // the validator requires, restricted to the template-and-MIG change pair. - assert.match( - job, - /if ! terraform -chdir=infra\/terraform show -json[\s\S]{0,220}\| length == 0' >\/dev\/null\n then\n/ - ) - assert.match( - job, - /requiring reviewed rollback-image drift'\n[\s\S]{0,400}?\n {16}--mode same-cap-cell --cell-id "\$\{TARGET_CELL_ID\}" \\\n/ - ) - assert.match( - job, - /Require converged Terraform state and a stable MIG on resume[\s\S]{0,300}CAPACITY_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT \}\}\n {10}DIRECTOR_RUNTIME_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_DIRECTOR_RUNTIME_SERVICE_ACCOUNT \}\}/ - ) - assert.match( - job, - /--rollback-image "\$\{DESIRED_IMAGE\}" \\\n {16}--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}" \\\n {16}--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/ - ) - assert.match( - job, - /host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}"\)"\n {12}echo "\$\{RESUME_REVIEW\}"\n {12}jq -e '\.changes == 2' <<< "\$\{RESUME_REVIEW\}" >\/dev\/null/ - ) - // A resume applies nothing at all, which is what a resume means: the only accepted - // unconverged plan is the template-and-MIG rollback-image drift, and it is left pending. - const resumeStep = job.slice( - job.indexOf('- name: Require converged Terraform state and a stable MIG on resume'), - job.indexOf('- name: Apply only the selected same-cap template and MIG') - ) - assert.equal(resumeStep.split('terraform -chdir=infra/terraform apply').length, 1) - assert.match(job, /resume requires the isolated migration-only cell/) - assert.match(job, /test "\$\{TARGET_INCARNATION\}" = "\$\{SOURCE_INCARNATION\}"/) - assert.match(job, /\(.regionalRehomeProtocol \/\/ 0\) == \$protocol/) - assert.match(job, /\(\.draining == false or \$drainingOk\)/) - // Selector expectations must follow the mutations' returned generations, - // not fixed offsets: isolate is a no-op on a cell a failed canary already - // isolated, and the restore inspect must expect post-restore membership. - assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{EFFECTIVE_SELECTOR_GENERATION\}/) - assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{ISOLATE_GENERATION\}/) - assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_ISOLATE\}"/) - assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_RESTORE\}"/) - assert.match(job, /--expected-migration-only-cells "\$\{RESTORED_MIGRATION_CELLS\}"/) - assert.match(job, /--expected-general-cells "\$\{RESTORED_GENERAL_CELLS\}"/) - assert.match(job, /FAILSAFE_GENERATION/) - // Later batch waves start after ~16-min predecessor rolls, so BOTH evidence - // age checks must scale by wave or cell_2+ can never pass; the bound's - // per-wave step is the cell job timeout, so the two must move together. - assert.match(job, /--required-migration-policy strict \\\n --wave-index "\$\{WAVE_INDEX\}"/) - // Wave 0 must retry freshness-only failures too: one Cloud Monitoring publish - // lag at the sample instant is not health evidence, and single-shot wave 0 - // failed a whole batch on a series that was fresh again a minute later. - assert.match( - job, - /dry-run\.state\.json" \\\n {14}--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/ - ) - assert.doesNotMatch(job, /RETRY_ARGS/) - // Break-glass: the override skips the aggregate 15-minute monitor evidence and - // nothing else. The live per-wave recheck still runs on the override path, off - // the dispatch inputs the rehome inspect below verifies against the director. - assert.match( - job, - /if test -n "\$\{GATE_OVERRIDE_CONFIRMATION\}"; then[\s\S]{0,700}?--no-monitor-state \\\n {14}--expected-selector-generation "\$\{EXPECTED_SELECTOR_GENERATION\}" \\\n {14}--selector-membership-file[\s\S]{0,160}?--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/ - ) - // The override is re-validated here, not trusted from the caller, and it is - // bound to the digest this wave installs. - assert.match( - job, - /test "\$\{GATE_OVERRIDE_CONFIRMATION\}" = \\\n {14}"SKIP_RELAY_MONITOR_GATE \$\{TARGET_IMAGE_DIGEST\}"/ - ) - assert.match(job, /\[\[ "\$\{GATE_OVERRIDE_REASON\}" =~ \^\[\[:print:\]\]\{12,500\}\$ \]\]/) - // Exactly the aggregate-evidence steps are skipped, and only them: every step - // that reads or spends the sealed monitor artifact carries the override guard. - const overrideSkipped = [ - 'Require fresh aggregate monitor evidence reference', - 'Download private aggregate monitor evidence', - 'Verify monitor evidence provenance', - "Download this wave's single-use safety authority", - 'Require safety evidence consumed by this workflow' - ] - for (const name of overrideSkipped) { - assert.match( - job, - new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`) - ) - } - assert.equal( - job.match(/inputs\.gate-override-confirmation == ''/g).length, - overrideSkipped.length - ) - // The wrapper validates the override before anything runs, passes it to every - // cell, seals it into the canary artifact, and prints it in the run summary. - assert.match(wrapper, /--gate-override-reason "\$\{GATE_OVERRIDE_REASON\}" \\\n {12}--gate-override-confirmation "\$\{GATE_OVERRIDE_CONFIRMATION\}"\)/) - // One per cell job in the serial cell_1..cell_10 chain. - assert.equal( - wrapper.match(/gate-override-confirmation: \$\{\{ inputs\.gate-override-confirmation \}\}/g).length, - 10 - ) - assert.match(wrapper, /Aggregate monitor gate overridden \(break-glass\)/) - assert.match(wrapper, /ACTOR: \$\{\{ github\.actor \}\}/) - for (const name of [ - 'Reject previously consumed aggregate safety evidence', - 'Consume aggregate safety evidence for this exact wave' - ]) { - assert.match( - wrapper, - new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`) - ) - } - assert.match(job, /timeout-minutes: 75/) - // Both age gates step by the cell job timeout above; the constant is - // duplicated across the two languages, so pin each copy to it. - for (const source of [ - '../../dev/scripts/relay-monitor-evidence.mjs', - '../../apps/relay-ops/src/incident-live-preflight-cli.ts' - ]) { - const body = readFileSync(fileURLToPath(new URL(source, import.meta.url)), 'utf8') - assert.match(body, /WAVE_PREDECESSOR_TIMEOUT_MS = 75 \* 60_000/) - assert.match(body, /\^\[0-9\]\$/) - } - // Aged-evidence replay via job re-runs is fenced: mutations are - // single-dispatch, so a failed cell needs a fresh gate and monitor run. - assert.match(job, /test "\$\{GITHUB_RUN_ATTEMPT\}" = 1/) - for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) { - assert.match(wrapper, new RegExp(`wave-index: '${index}'`)) - } - assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 1\)/) - assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 2\)/) - assert.match(job, /\$region == "us-central1" and \$protocol == 0 and [.]region == null/) - assert.match(job, /[.]regionalRehomeProtocol \/\/ 0/) - assert.match(job, /runtime predecessor normalized legacy fields=/) - assert.match(job, /probe-relay-rehome-trust[.]mjs/) - assert.doesNotMatch(job, /service_account: \$\{\{ vars\.PRODUCTION_GCP_RELAY_(?:DIRECTOR_)?RUNTIME_SERVICE_ACCOUNT/) - assert.doesNotMatch(job, /roles\/iam\.serviceAccountTokenCreator/) -}) - -// Why: the same-cap caller defines release_lease itself, and a caller-defined job presents the -// caller as job_workflow_ref, so the pair must admit the caller alongside its reusable job. -test('shared deploy WIF admits the exact same-cap reusable workflow pair and the caller itself', () => { - const terraform = readFileSync( - fileURLToPath(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url)), - 'utf8' - ) - const providerStart = terraform.indexOf( - 'resource "google_iam_workload_identity_pool_provider" "github"' - ) - const providerEnd = terraform.indexOf('\nresource "', providerStart + 1) - const sharedProvider = terraform.slice(providerStart, providerEnd) - assert.ok(providerStart >= 0 && providerEnd > providerStart) - assert.match(sharedProvider, /local\.relay_github_workflow_conditions\["github"\]/) - // The pairing itself now lives in the clause the provider renders, once per accepted repository. - assert.match( - terraform, - /assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main' && \(assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_job_workflow_file\}@refs\/heads\/main' \|\| assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main'\)/ - ) -}) - -test('pause and disable precede optional installation and cloud diagnostics', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - const emergency = job.indexOf('Apply emergency durable pause or disable before diagnostics') - const install = job.indexOf('pnpm install --frozen-lockfile') - const revision = job.indexOf('Verify exact serving and rollback director identities') - assert.ok(emergency > 0) - assert.ok(emergency < install) - assert.ok(emergency < revision) - assert.match(job, /inputs\.mode == 'pause' \|\| inputs\.mode == 'disable'/) - assert.match(job, /Seal 24-hour aggregate region observation evidence/) - assert.match(job, /--freshness=25h --limit=30000/) - assert.match(job, /relay-region-observation-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/) - assert.match(job, /test "\$\{RATE_PER_MINUTE\}" = 10/) -}) - -test('a failed enable independently restores and verifies durable disabled state', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - const enable = job.indexOf('Apply exact durable regional rehome enable') - const evidence = job.indexOf('Read fresh aggregate completion and abort evidence') - const summary = job.indexOf('Publish aggregate control evidence') - const recovery = job.indexOf('Fail closed after an unsuccessful enable run') - assert.ok(enable > 0 && enable < evidence && evidence < summary && summary < recovery) - const recoveryStep = job.slice(recovery) - assert.match( - recoveryStep, - /failure\(\) && inputs\.mode == 'enable' && steps\.google-auth\.outcome == 'success'/ - ) - assert.match(recoveryStep, /--mode recover-enable/) - assert.match(recoveryStep, /--expected-control-generation "\$\{EXPECTED_CONTROL_GENERATION\}"/) - assert.match(recoveryStep, /RECOVER_FAILED_REGIONAL_REHOME_ENABLE/) - assert.match(recoveryStep, /\.control\.enabled == false/) - assert.doesNotMatch(recoveryStep, /gcloud|pnpm/) -}) - -test('director rollout has a strict one-time identity bootstrap', () => { - const workflowBody = workflow('deploy-relay-production-director.yml') - const script = readFileSync( - fileURLToPath(new URL('./deploy-relay-blue-green.mjs', import.meta.url)), - 'utf8' - ) - assert.match(workflowBody, /BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY/) - assert.match(workflowBody, /--predecessor-runtime-service-account/) - assert.match(workflowBody, /--expected-rehome-generation/) - assert.match(script, /args\.push\('--service-account', config\['runtime-service-account'\]\)/) - assert.match(script, /director predecessor runtime service account does not match/) - const candidateProof = script.indexOf('await verifyRehomeDisabled(candidate.origin)') - const trafficMove = script.indexOf('operations.updateTraffic(config, [`--to-tags=') - assert.ok(candidateProof > 0 && candidateProof < trafficMove) - assert.equal(script.indexOf('verifyRehomeDisabled', trafficMove), -1) -}) - -test('rehome job pipes every control result through tee under pipefail', () => { - const job = workflow('operate-relay-production-rehome-job.yml') - // Without `shell: bash` the step exit code is tee's, so a thrown inspect/apply passes green. - assert.match(job, /defaults:\n run:\n(?: #.*\n)* shell: bash\n/) - assert.ok((job.match(/\| tee "\$\{RUNNER_TEMP\}/g) ?? []).length >= 5) -}) diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 1e023ce0c9d..ff2c265ee72 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -13,7 +13,6 @@ import { readRelayWorkflow } from './relay-repository.mjs' import { validateCapacityPlan } from './validate-relay-capacity-plan.mjs' const workflow = readRelayWorkflow('deploy-relay-production-same-cap-job.yml') -const capacityWorkflow = readRelayWorkflow('deploy-relay-production-capacity-job.yml') const production = readFileSync( new URL('../../infra/terraform/environments/production.tfvars', import.meta.url), 'utf8' @@ -295,74 +294,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(resolveCellShape('production-gce-c31').status, 1) }) - it('passes the same-cap allowlist on every canary invocation the job runs', () => { - const invocations = workflow.split('prepare-relay-production-capacity-canary.mjs').slice(1) - assert.equal(invocations.length, 4) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.endsWith('\\')) - const call = lines.slice(0, end + 1).join(' ') - assert.match(call, /--approved-cells same-cap/) - // The restore call picks its mode from the cell's entry admission class. - assert.match(call, /--mode (isolate|drain|activate|"\$\{RESTORE_MODE\}")/) - } - }) - it('paces the drain it sends to the selected cell', () => { - const drain = workflow.split('--mode drain')[1] ?? '' - assert.match(drain.split('\n').slice(0, 2).join(' '), /--pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/) - // 5 min is the cell's DRAIN_PACE_WINDOW_MAX_MS; a 2,700-host cell at 2 min overruns the director's sticky lane. - assert.match(workflow, /DRAIN_PACE_WINDOW_MS: '300000'/) - // The transition wait has to outlast the pacing window on top of the leases it waits on. - assert.match(workflow, /--activity restart-safe[\s\S]*?--timeout-ms 1200000/) - }) - it('passes this cell\'s rehome protocol and pool on every plan validation the job runs', () => { - const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1) - assert.equal(invocations.length, 2) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - const call = lines.slice(0, end + 1).join(' ') - assert.match(call, /--mode same-cap-cell/) - assert.match(call, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/) - assert.match(call, /"\$\{POOL_ARGUMENTS\[@\]\}"/) - } - // Each of those steps must build the flag from the resolved pool, and only when there is one. - const builders = workflow.split( - 'if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then\n' + - ' POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")' - ) - assert.equal(builders.length, 3) - assert.equal(workflow.split('POOL_ARGUMENTS=()').length, 3) - }) - // One cell's compute path and nothing else: the template and the MIG bound to it. The cell - // backend service stays out because the capacity role has no compute.backendServices.update, - // so naming it fails the apply after the MIG has already rolled. - it('targets exactly this cell template and MIG on every plan the job runs', () => { - const plans = workflow.split('terraform -chdir=infra/terraform plan').slice(1) - assert.equal(plans.length, 2) - for (const plan of plans) { - const lines = plan.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - const call = lines.slice(0, end + 1).join('\n') - assert.deepEqual( - [...call.matchAll(/-target=([\w.]+)\[\\"\$\{TARGET_CELL_ID\}\\"\]/g)] - .map(([, resource]) => resource), - [ - 'google_compute_instance_template.relay_gce_cell', - 'google_compute_instance_group_manager.relay_gce_cell' - ] - ) - // Any target that is not one of those two, or not scoped to this cell, fails here. - assert.equal(call.split('-target=').length, 3) - } - }) - it('never names a backend service on any plan or apply in the job', () => { - assert.equal(workflow.includes('google_compute_backend_service'), false) - }) it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) @@ -569,25 +504,6 @@ describe('same-cap roll scripts accept every same-cap cell', () => { ) }) - it('pins the capacity identity on every plan validation the job runs', () => { - const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1) - assert.equal(invocations.length, 2) - for (const invocation of invocations) { - const lines = invocation.split('\n') - const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\')) - assert.match( - lines.slice(0, end + 1).join(' '), - /--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}"/ - ) - } - // Both steps must read it from the same repository variable the job already requires. - assert.equal( - workflow.split( - 'CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}' - ).length, - 4 - ) - }) it('decides the predecessor draining rule from the real block, for both classes', () => { // A zero-host cell sheds nothing, and a failed canary's own drain leaves the flag set @@ -777,29 +693,5 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(rolls('resume', { changes: 0 }), 'no-replace') assert.equal(rolls('none', { changes: 0 }), 'no-replace') }) - - it('waits on the image a stranded cell actually serves', () => { - const isolate = workflow - .split('name: Reversibly isolate and drain only the selected cell')[1] - .split('\n - id:')[0] - assert.match(isolate, /--expected-image-digests "\$\{PREDECESSOR_IMAGE_DIGEST\}"/) - // A stranded cell has to come back on a new process, which is what clears the drain. - const after = workflow - .split('name: Verify new incarnation, exact image, protocol, and durable safety')[1] - .split('\n - name:')[0] - assert.match(after, /test "\$\{TARGET_INCARNATION\}" != "\$\{SOURCE_INCARNATION\}"/) - assert.match(after, /if test "\$\{ROLLBACK_RESUME\}" = true; then/) - }) - - it('leaves the US-only capacity job on the default allowlist', () => { - assert.doesNotMatch(capacityWorkflow, /--approved-cells/) - }) }) -// Both trusted versions must prove the same authenticated drain boundary. -it('proves rehome trust for protocol 3 on forward and rollback rolls', () => { - const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0] - assert.match(step, /inputs\.rollback-rehome-protocol != '0'/) - assert.match(step, /inputs\.target-rehome-protocol != '0'/) - assert.match(step, /probe-relay-rehome-trust\.mjs/) -}) diff --git a/cloud/package.json b/cloud/package.json index 0dd2a546699..c029b06d6fa 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -20,8 +20,8 @@ "load:relay:model": "node dev/scripts/run-relay-load-model.mjs", "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", - "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/push-validation-workflow.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4e4443354fa..e3468836373 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -943,7 +943,7 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-session-operation-retention.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ @@ -957,7 +957,6 @@ "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts", "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", - "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx", "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", @@ -1033,7 +1032,7 @@ "date": "2026-09-12", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", "durationSeconds": 22.1, "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." @@ -12353,7 +12352,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-pending-projection-admissions.test.ts src/main/ipc/ssh-pty-legacy-projection.test.ts src/main/ipc/ssh-pty-model-admission.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/main/ssh/relay-protocol-backpressure.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-pty-model-admission.test.ts src/main/ipc/ssh-pty-output-model-migration.test.ts src/main/ssh/ssh-relay-session-model-migration.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/relay/git-response-stream-ownership.test.ts src/relay/pty-handler-output-streaming.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-notification-routing.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/providers/ssh-pty-provider-exit-race.test.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", @@ -12400,7 +12399,6 @@ "src/main/ssh/ssh-relay-session-terminal-error.test.ts", "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts", "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts", - "src/main/ssh/relay-protocol-backpressure.test.ts", "src/relay/protocol-backpressure.test.ts", "src/relay/git-response-stream-ownership.test.ts", "src/relay/pty-handler-output-streaming.test.ts", @@ -12617,13 +12615,6 @@ "invalid-checkpoint cancellation retains the exact delivery until restore response settlement, then retry mints a fresh activation and emits one live source frame" ] }, - { - "file": "src/main/ssh/relay-protocol-backpressure.test.ts", - "assertions": [ - "main SSH decoder accepts one maximum frame plus 1 MiB partial input and rejects one extra byte", - "a throwing continuation clears retained input, releases one pause epoch, and publishes one typed ownership error" - ] - }, { "file": "src/relay/protocol-backpressure.test.ts", "assertions": [ @@ -12812,7 +12803,7 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/main/ssh/relay-protocol-backpressure.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-source-delivery-ledger.test.ts src/main/ssh/ssh-pty-retired-source-deliveries.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-session-data-delivery.test.ts src/main/ssh/ssh-relay-session-recovery-races.test.ts src/main/ssh/ssh-relay-session-incarnation.test.ts src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts src/main/ssh/ssh-relay-session-terminal-error.test.ts src/main/ssh/ssh-pty-recovery-retention-budget.test.ts src/relay/protocol-backpressure.test.ts src/relay/pty-source-credit-ledger.test.ts src/relay/pty-source-credit-scheduler.test.ts src/relay/relay-pty-source-publication.test.ts src/relay/ssh-pty-source-credit-adapter.test.ts --reporter=dot", "result": "passed", "durationSeconds": 2.24, "summary": "Sixteen deterministic SSH relay/session/source/decoder files passed 192 tests, including exit-sealed private recovery, retained stale-transfer cancellation authority, exact private-frame proof watermarks, one retirement record across 10,000 same-PTY token rotations, stale-owner fallback, and scheduler rejection isolation; no live topology was exercised." @@ -15501,7 +15492,7 @@ "oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then distinguish the three settlement outcomes end to end: only a proven refusal releases the reservation and drains a delivery parked behind the watermark; a dropped in-flight settlement must surface as unverifiable with bytes handed to the transport, preserve the durable write-attempted reservation, and emit no duplicate pointer after restart; a settled write that throws mid-pointer is unverifiable, not a refusal; and an Enter whose settlement is lost stays at enter-attempted so restart emits no second Enter. Install the production PTY controller and verify that it routes settled writes through the owning provider and refuses before any byte when the routed provider cannot settle. Census every production PTY provider class and reject a settlement synthesized from the fire-and-forget write.", "commands": [ "pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot" ], @@ -15518,7 +15509,6 @@ "src/main/runtime/orchestration/formatter.test.ts", "src/main/providers/ssh-pty-provider.test.ts", "src/main/providers/ssh-pty-write.test.ts", - "src/main/providers/settled-pty-writer-census.test.ts", "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "src/main/daemon/client.test.ts", "src/main/daemon/daemon-pty-router.test.ts", @@ -15620,13 +15610,6 @@ "file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts", "assertions": ["a refused pointer write drains a delivery parked behind its watermark"] }, - { - "file": "src/main/providers/settled-pty-writer-census.test.ts", - "assertions": [ - "every production IPtyProvider class exposes a settled writer", - "no settled writer synthesizes its settlement from the fire-and-forget write" - ] - }, { "file": "src/main/ipc/pty-controller-ownership-routing.test.ts", "assertions": [ @@ -15666,7 +15649,7 @@ "date": "2026-09-05", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "result": "passed", "durationSeconds": 4.73, "summary": "267 tests passed after the pointer-write path moved to the three-valued WriteSettlement union. New coverage: a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport, a settled write that throws mid-pointer preserves the write-attempted reservation, an Enter whose settlement is lost stays at enter-attempted with no second Enter after restart, a refusal releases the reservation and drains a delivery parked behind its watermark, the production controller refuses before any byte when the routed provider cannot settle, and a census pins the five production IPtyProvider classes and rejects a settlement synthesized from the fire-and-forget write. Each new assertion was verified red against the pre-fix shape." @@ -15675,7 +15658,7 @@ "date": "2026-08-13", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts", "result": "passed", "durationSeconds": 8.22, "summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup." diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs index ef21fed5e34..15258cea96d 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -119,15 +119,13 @@ describe('the actual Bun build and profile-test dependency graph', () => { expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(true) }) - it('retains all selected tests and uses the same selectors as the Bun runner', () => { + it('retains all selected tests and the selectors the Bun runner uses', () => { const tests = discoverBunProfileTests() expect(tests.length).toBeGreaterThan(80) expect(tests.every((file) => inputs.has(file))).toBe(true) expect( bunProfileTestPaths().every((selector) => tests.some((file) => file.includes(selector))) ).toBe(true) - const runner = readFileSync(new URL('./run-bun-profile-tests.mjs', import.meta.url), 'utf8') - expect(runner).toContain('testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact })') }) }) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 664e8268662..795f6070baf 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -193,7 +193,6 @@ "config/scripts/package-electron-runtime-contract.test.mjs": 447, "config/scripts/package-linux-formats.test.mjs": 208, "config/scripts/packaged-browser-lane-contract.test.mjs": 104, - "config/scripts/packaged-hang-watchdog-worker-contract.test.mjs": 161, "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 93, "config/scripts/packaged-source-map-prune.test.mjs": 117, "config/scripts/patched-dependencies-frozen-install.test.mjs": 900, diff --git a/config/scripts/cli-main-module-bundle-parity.test.ts b/config/scripts/cli-main-module-bundle-parity.test.ts deleted file mode 100644 index b545579facf..00000000000 --- a/config/scripts/cli-main-module-bundle-parity.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join, matchesGlob, resolve } from 'node:path' -import { createRequire } from 'node:module' -import { describe, expect, it } from 'vitest' -import { electronViteConfig } from '../../electron.vite.config' -import { GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard' - -const REPO_ROOT = resolve(__dirname, '..', '..') -const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') -const packaging = createRequire(import.meta.url)('../electron-builder.config.cjs') - -function listCliSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) { - return listCliSourceFiles(path) - } - return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') - ? [path] - : [] - }) -} - -// Why: `import type` is erased by tsc, so it needs no emitted module at runtime. -const VALUE_IMPORT_FROM_MAIN = /(? { - const source = readFileSync(file, 'utf-8') - return [ - ...source.matchAll(VALUE_IMPORT_FROM_MAIN), - ...source.matchAll(DYNAMIC_IMPORT_FROM_MAIN) - ].map((match) => ({ - file: file.slice(REPO_ROOT.length + 1), - module: match[1].replace(/\.js$/, '') - })) - }) -} - -function findElectronViteMainEntries(): Record { - const input = electronViteConfig.main?.build?.rollupOptions?.input - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new Error('Expected named main-process inputs') - } - return input -} - -describe('CLI imports of main-process modules', () => { - // Why: electron-vite cleans out/main and emits only its declared entries, so a - // `src/main/*` module the CLI imports but the config omits is deleted by the - // build that runs after `build:cli` — keep source-level feedback ahead of the - // final-artifact runtime verifier. - it('has an electron-vite entry for every main module the CLI imports', () => { - const entries = findElectronViteMainEntries() - const missing = findMainImports().filter( - ({ module }) => entries[module] !== join(REPO_ROOT, 'src', 'main', `${module}.ts`) - ) - - expect(missing).toEqual([]) - }) - - it('guards every CLI main module against Electron imports', () => { - const guarded = new Set(GUARDED_ENTRY_NAMES) - expect(findMainImports().filter(({ module }) => !guarded.has(module))).toEqual([]) - }) - - it('unpacks every CLI main entry for plain Node outside Electron', () => { - const missing = findMainImports().filter( - ({ module }) => - !packaging.asarUnpack.some((pattern: string) => - matchesGlob(`out/main/${module}.js`, pattern) - ) - ) - expect(missing).toEqual([]) - }) - - it('finds the imports it is meant to guard', () => { - // Why: a broken matcher would make the guard above vacuously pass. - expect(findMainImports()).toContainEqual({ - file: join('src', 'cli', 'profile-state-location.ts'), - module: 'persistence/profile-state/profile-state-active-location' - }) - expect(findMainImports()).toContainEqual({ - file: join('src', 'cli', 'handlers', 'agent-hooks.ts'), - module: 'orca-profiles/profile-index-store' - }) - expect(findMainImports().length).toBeGreaterThanOrEqual(2) - expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2) - }) -}) diff --git a/config/scripts/client-hosted-browser-package-coverage.test.mjs b/config/scripts/client-hosted-browser-package-coverage.test.mjs index f7f1ceb5995..204cf162e55 100644 --- a/config/scripts/client-hosted-browser-package-coverage.test.mjs +++ b/config/scripts/client-hosted-browser-package-coverage.test.mjs @@ -19,13 +19,6 @@ describe('client-hosted browser package coverage', () => { expect(steps[boundaries].run).not.toContain(file) }) - it('bundles the WSL browser-network relay with its version stamp', () => { - const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') - - expect(relayBuild).toContain("outfile: join(outDir, 'wsl-browser-network-relay.js')") - expect(relayBuild).toContain("join(outDir, '.browser-network-version')") - }) - it('runs client-hosted Electron lifecycle coverage on native package hosts', () => { const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') const parsedWorkflow = parse(prWorkflow) diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index fc605bac5bd..3e31ccf5a80 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -6,197 +6,6 @@ import { parse } from 'yaml' const projectDir = resolve(import.meta.dirname, '../..') describe('computer-use e2e workflow', () => { - it('cancels superseded pull request runs without cancelling scheduled runs', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - - expect(workflow.concurrency).toEqual({ - group: 'computer-e2e-${{ github.event.pull_request.number || github.ref }}', - 'cancel-in-progress': "${{ github.event_name == 'pull_request' }}" - }) - }) - - it('runs computer-use e2e files serially because they share desktop focus', () => { - const config = readFileSync(join(projectDir, 'tests/e2e/vitest.config.ts'), 'utf8') - - expect(config).toContain('fileParallelism: false') - }) - - it('guards e2e source against fragile waits and Windows Calculator drift', () => { - const driver = readFileSync(join(projectDir, 'tests/e2e/helpers/computer-driver.ts'), 'utf8') - const cliDriver = readFileSync( - join(projectDir, 'tests/e2e/helpers/computer-cli-driver.ts'), - 'utf8' - ) - const windowsStoreE2e = readFileSync( - join(projectDir, 'tests/e2e/computer-windows-store.e2e.ts'), - 'utf8' - ) - - expect(driver).not.toContain('await delay(3500)') - expect(driver).toContain("await waitForComputerWindowTitle('gedit', fileName, 15000)") - expect(cliDriver).toContain('ORCA_DEV_USER_DATA_PATH') - expect(cliDriver).toContain('orca-computer-runtime-') - expect(cliDriver).toContain('retryMissingRuntimeMetadata') - expect(cliDriver).toContain('Could not read Orca runtime metadata') - expect(cliDriver).toContain("'serve', '--no-pairing', '--json'") - - expect(windowsStoreE2e).toContain("app.bundleId === 'ApplicationFrameHost'") - expect(windowsStoreE2e).toContain("app.bundleId === 'win32calc'") - expect(windowsStoreE2e).toContain('buttonIndex >= 0') - expect(windowsStoreE2e).toContain('pane(?:\\s|$)/m') - expect(windowsStoreE2e).toContain('String(clickIndex)') - expect(windowsStoreE2e).not.toContain( - "for (const buttonName of ['One', 'Plus', 'Two', 'Equals'])" - ) - }) - - it('triggers on computer-use shared contracts, scripts, and agent skill changes', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'config/scripts/computer-e2e-workflow.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', - 'config/scripts/computer-use-modifier-safety.test.mjs', - 'config/scripts/computer-use-skill-guidance.test.mjs', - 'config/scripts/computer-use-smoke.mjs', - 'config/scripts/computer-use-smoke.test.mjs', - 'skills/computer-use/SKILL.md', - 'src/main/computer/**', - 'src/main/runtime/rpc/dispatcher.ts', - 'src/main/runtime/rpc/errors.ts', - 'src/main/runtime/rpc/methods/computer*.ts', - 'src/shared/computer-use-*.ts', - 'tests/e2e/vitest.config.ts' - ]) - ) - expect(triggerPaths).not.toContain('src/shared/runtime-types.ts') - }) - - it('runs focused computer-use regression tests in the PR native-smoke job', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const nativeSmokeRuns = workflow.jobs['native-smoke'].steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - const checkout = workflow.jobs['native-smoke'].steps.find( - (step) => step.uses === 'actions/checkout@v6' - ) - const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run')) - const expectedRegressionFiles = [ - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', - 'config/scripts/computer-use-modifier-safety.test.mjs', - 'config/scripts/computer-use-skill-guidance.test.mjs', - 'config/scripts/computer-use-smoke.test.mjs', - 'src/main/computer/computer-provider-lifecycle.test.ts', - 'src/main/computer/computer-provider-unavailable-message.test.ts', - 'src/main/computer/sidecar-client.test.ts', - 'src/main/computer/macos-native-provider-client.test.ts', - 'src/main/computer/macos-native-provider-socket.test.ts', - 'src/main/computer/macos-computer-use-permissions.test.ts', - 'src/main/computer/macos-computer-use-permission-status.test.ts', - 'src/main/computer/desktop-script-provider-client.test.ts', - 'src/main/computer/desktop-script-provider-cache.test.ts', - 'src/main/computer/desktop-script-provider-actions.test.ts', - 'src/main/computer/desktop-script-provider-cache-lifecycle.test.ts', - 'src/main/computer/desktop-script-provider-errors.test.ts', - 'src/main/computer/desktop-script-provider-action-errors.test.ts', - 'src/shared/computer-use-error-recovery.test.ts', - 'src/shared/computer-use-key-spec.test.ts', - 'src/cli/format.test.ts', - 'src/cli/handlers/computer.test.ts', - 'src/cli/handlers/computer-action-routing.test.ts', - 'src/cli/handlers/computer-action-validation.test.ts', - 'src/cli/handlers/computer-state-formatting.test.ts', - 'src/cli/specs/computer.test.ts', - 'src/cli/index.test.ts', - 'src/main/runtime/rpc/dispatcher-computer-errors.test.ts', - 'src/main/runtime/rpc/errors.test.ts', - 'src/main/runtime/rpc/methods/computer.test.ts', - 'src/main/runtime/rpc/methods/computer-actions.test.ts', - 'src/cli/runtime/envelope-schema.test.ts', - 'src/shared/remote-runtime-client.test.ts' - ] - - expect(checkout.with['persist-credentials']).toBe(false) - expect(regressionRun).toBeTruthy() - for (const file of expectedRegressionFiles) { - expect(regressionRun).toContain(file) - } - }) - - it('keeps Linux native imports available without installing the GUI-only stack in PR smoke', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const nativeSmokeInstall = workflow.jobs['native-smoke'].steps.find( - (step) => step.if === "runner.os == 'Linux'" - ) - const scheduledLinuxInstall = workflow.jobs.linux.steps.find((step) => - step.run?.includes('apt-get install') - ) - - expect(nativeSmokeInstall.run).toContain('python3') - expect(nativeSmokeInstall.run).toContain('python3-gi') - expect(nativeSmokeInstall.run).toContain('gir1.2-atspi-2.0') - expect(nativeSmokeInstall.run).toContain('at-spi2-core') - expect(nativeSmokeInstall.run).not.toContain('gedit') - expect(nativeSmokeInstall.run).not.toContain('xvfb') - expect(nativeSmokeInstall.run).not.toContain('xdotool') - expect(scheduledLinuxInstall.run).toContain('gedit') - expect(scheduledLinuxInstall.run).toContain('xvfb') - expect(scheduledLinuxInstall.run).toContain('xdotool') - }) - - it('builds and tests the macOS helper on every trigger without hosted TCC e2e', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const job = workflow.jobs['mac-native-owner-smoke'] - const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string') - const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') - const install = job.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - - expect(job.if).toBeUndefined() - expect(job['runs-on']).toBe('macos-15') - expect(checkout.with['persist-credentials']).toBe(false) - expect(install.with['native-runtime']).toBe('electron') - expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1') - const cleanupRun = runs.find((run) => - run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs') - ) - expect(cleanupRun).toContain( - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' - ) - expect(runs).toContain('pnpm verify:computer-native') - expect( - runs.find((run) => run.includes('config/scripts/build-native-for-platform.test.mjs')) - ).toContain('--config config/vitest.config.ts') - expect(runs.join('\n')).not.toContain('test:e2e:computer') - expect(workflow.jobs.mac).toBeUndefined() - expect(workflow.on.pull_request.paths).toEqual( - expect.arrayContaining([ - 'config/scripts/build-native-for-platform.mjs', - 'config/scripts/build-native-for-platform.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs', - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs', - 'config/scripts/macos-computer-helper-owner-loss-processes.mjs', - 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', - 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs' - ]) - ) - }) - it('uses the cached Electron dependency path for scheduled Linux and Windows e2e', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -220,29 +29,6 @@ describe('computer-use e2e workflow', () => { } }) - it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => { - const benchmark = readFileSync( - join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'), - 'utf8' - ) - const cleanup = readFileSync( - join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'), - 'utf8' - ) - - expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]') - expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]") - expect(benchmark).toContain('cleanupOwnerLossTrial({') - const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)') - const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({') - expect(parseIndex).toBeGreaterThanOrEqual(0) - expect(cleanupIndex).toBeGreaterThanOrEqual(0) - expect(parseIndex).toBeLessThan(cleanupIndex) - expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)') - expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath') - expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'") - }) - it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -279,34 +65,6 @@ describe('computer-use e2e workflow', () => { ) }) - it('re-runs the native-smoke job when the daemon bundle graph changes', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'config/scripts/daemon-boot-smoke.mjs', - 'config/scripts/windows-daemon-workspace-close-repro.mjs', - 'electron.vite.config.ts', - 'config/build-plugins/**', - 'src/main/daemon/**' - ]) - ) - }) - - it('does not run computer-use e2e in PR smoke jobs', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const nativeSmokeRuns = workflow.jobs['native-smoke'].steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - - expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer') - }) - it('builds Electron main output before every computer-use e2e run', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -352,48 +110,4 @@ describe('computer-use e2e workflow', () => { expect(allRuns.join('\n')).toContain('test:e2e:computer') expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter') }) - - it('runs Linux e2e on schedule without advertising hosted macOS TCC coverage', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - const linuxRuns = workflow.jobs.linux.steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'tests/e2e/computer-linux.e2e.ts', - 'tests/e2e/helpers/computer-cli-driver.ts', - 'tests/e2e/helpers/computer-driver.ts' - ]) - ) - expect(triggerPaths).not.toContain('tests/e2e/computer-mac.e2e.ts') - expect(triggerPaths).not.toContain('tests/e2e/computer-mac-safari.e2e.ts') - expect(workflow.jobs.mac).toBeUndefined() - expect(linuxRuns).toContain( - 'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts' - ) - }) - - it('runs every Windows computer-use e2e file in the scheduled Windows job', () => { - const workflow = parse( - readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') - ) - const triggerPaths = workflow.on.pull_request.paths - const windowsRuns = workflow.jobs.windows.steps - .map((step) => step.run) - .filter((run) => typeof run === 'string') - - expect(triggerPaths).toEqual( - expect.arrayContaining([ - 'tests/e2e/computer-windows.e2e.ts', - 'tests/e2e/computer-windows-store.e2e.ts' - ]) - ) - expect(windowsRuns).toContain( - 'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts' - ) - }) }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 958e461ffd9..da6d45c8a23 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -20,9 +20,6 @@ import { createRequire } from 'node:module' import { electronViteConfig } from '../../electron.vite.config' import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' -const targetConfig = readFileSync('config/electron-vite-target.config.cts', 'utf8') -const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') - type BootstrapProcessMock = EventEmitter & { env: Record pid: number @@ -253,11 +250,6 @@ describe('Electron Vite output contract', () => { ) }) - it('rejects prototype properties as build targets', () => { - // Own-property check only: an inherited key like `constructor` must not select a build target. - expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)') - }) - it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => { // Asserted on the values rather than the source text: the ids moved into // dev-electron-bundle-identity.mjs so every dev bundle signs to one cdhash. @@ -265,7 +257,5 @@ describe('Electron Vite output contract', () => { expect(getDevHelperPlistPatches()).toEqual([ { key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID } ]) - expect(devRunner).toContain("'Electron Helper.app',") - expect(devRunner).toContain('setPlistValue(helperPlistPath, key, value)') }) }) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 77b92b1d0c3..d9de91f28da 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -4,53 +4,8 @@ import { parse } from 'yaml' import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8') -const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8') -const shutdownDockerRunner = readFileSync( - 'config/scripts/run-headless-serve-shutdown-docker.mjs', - 'utf8' -) -const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8') -const desktopStartupOracle = readFileSync( - 'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh', - 'utf8' -) -const headlessLinuxProse = headlessLinuxGuide.replace(/\s+/g, ' ') - -function readSystemdUnitBlocks(doc, unitName) { - const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return [...doc.matchAll(new RegExp(`^# /etc/systemd/system/${escapedUnitName}$`, 'gm'))].map( - (match) => { - const start = match.index + match[0].length - const end = doc.indexOf('```', start) - const nextUnitHeaderOffset = doc.slice(start).search(/^# \/etc\/systemd\/system\/.+$/m) - const nextUnitHeader = nextUnitHeaderOffset === -1 ? -1 : start + nextUnitHeaderOffset - if (end === -1 || (nextUnitHeader !== -1 && end > nextUnitHeader)) { - throw new Error(`Missing closing code fence for ${unitName}`) - } - return doc.slice(start, end) - } - ) -} describe('headless serve shutdown PR gate', () => { - it('reads only exact, closed systemd unit blocks', () => { - expect( - readSystemdUnitBlocks('# /etc/systemd/system/orca-serveXservice\n```', 'orca-serve.service') - ).toEqual([]) - expect(() => - readSystemdUnitBlocks('# /etc/systemd/system/orca-serve.service\n', 'orca-serve.service') - ).toThrow('Missing closing code fence for orca-serve.service') - expect(() => - readSystemdUnitBlocks( - '# /etc/systemd/system/orca-serve.service\n' + - 'KillMode=mixed\n' + - '# /etc/systemd/system/other.service\n```', - 'orca-serve.service' - ) - ).toThrow('Missing closing code fence for orca-serve.service') - }) - it('packages Linux artifacts before running the Docker signal oracle', () => { const steps = workflow.jobs.package.steps const packageStep = steps.find((step) => step.name === 'Package unpacked app') @@ -72,130 +27,4 @@ describe('headless serve shutdown PR gate', () => { steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs')) ).toHaveLength(1) }) - - it('keeps readiness polling finite and leak-free', () => { - expect(signalCase).toContain('read_ready_line()') - expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'") - expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}') - expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))') - expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do') - expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break') - expect(signalCase).toContain( - "jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F" - ) - expect(signalCase).not.toContain('tail --pid=') - }) - - it('gives owned shutdown state a bounded cleanup grace', () => { - expect(signalCase).toContain('for shutdown_poll in {0..50}; do') - expect(signalCase).toContain('[[ -z "$listener_after" && -z "$owned_residue" ]]') - expect(signalCase).toContain('((${#survivors[@]} == 0))') - expect(signalCase).toContain('((shutdown_poll < 50)) && sleep 0.1') - }) - - it('checks that a serving-electron signal target owns the ready socket', () => { - const ssRecord = - 'LISTEN 0 128 127.0.0.1:41235 0.0.0.0:* users:(("orca-ide",pid=23,fd=7),("orca-ide",pid=25,fd=8))' - expect([...ssRecord.matchAll(/pid=([0-9]+)/g)].map((match) => match[1])).toEqual(['23', '25']) - expect(signalCase).toContain( - 'listener_before_pids=$(grep -oE \'pid=[0-9]+\' <<<"$listener_before" | cut -d= -f2 || true)' - ) - expect(signalCase).toContain('signal_target_pid=$(head -n1 <<<"$listener_before_pids")') - expect(signalCase).toContain('outside the entrypoint process tree') - }) - - it('runs the original AppImage desktop startup oracle before extraction and signals', () => { - expect(shutdownDockerfile).toContain( - 'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case' - ) - const startupCall = shutdownDockerRunner.indexOf( - 'runDesktopStartupOracle({ image, appImage, platform })' - ) - const extractionCall = shutdownDockerRunner.indexOf( - "'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract" - ) - const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])") - expect(startupCall).toBeGreaterThan(-1) - expect(extractionCall).toBeGreaterThan(startupCall) - expect(signalLoop).toBeGreaterThan(startupCall) - expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'") - }) - - it('preserves startup logs when the launcher exits before its marker', () => { - expect(desktopStartupOracle).toContain('signal_process_group TERM || true') - expect(desktopStartupOracle).toContain('signal_process_group KILL || true') - expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true') - expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true') - expect(desktopStartupOracle).toContain( - 'FAIL: desktop launcher exited before ${reason} (status=${observed_status})' - ) - expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1') - expect(desktopStartupOracle).toContain( - '[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0' - ) - }) - - it('requires the bound AppImage to be executable before launch and extraction', () => { - expect(desktopStartupOracle).toContain( - '[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }' - ) - expect(shutdownDockerRunner).toContain( - '\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\'' - ) - }) - - it('gives the original AppImage enough bounded extraction space', () => { - expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'") - }) - - it('keeps owned Xvfb alive during the documented systemd graceful stop', () => { - const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service') - const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit)) - const managedXvfbUnits = serveUnits.filter((unit) => /^Environment=DISPLAY=/m.test(unit)) - - expect(ownedXvfbUnits).toHaveLength(1) - expect(ownedXvfbUnits[0]).toMatch(/^ExecStart=.*orca-linux\.AppImage serve.*$/m) - expect(ownedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m) - expect(managedXvfbUnits).toHaveLength(1) - expect(managedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m) - }) - - it('distinguishes persisted state from live work during a service restart', () => { - expect(headlessLinuxProse).toContain( - 'The detached terminal daemon is preserved by a different mechanism: it is launched through `systemd-run --user --scope`' - ) - expect(headlessLinuxProse).toContain( - 'These guarantees preserve live processes only when the daemon is in its own' - ) - expect(headlessLinuxProse).toContain( - 'The unscoped fallback remains destructive: a service restart kills every terminal' - ) - expect(headlessLinuxProse).toContain( - 'Treat a stop as destructive unless `health.terminalDaemon.cgroupUnit` names an `orca-daemon-*.scope` on that host' - ) - expect(headlessLinuxProse).toContain( - 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, failed request or lost connection is `unverifiable`' - ) - expect(headlessLinuxGuide).toContain( - 'sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json' - ) - expect(headlessLinuxGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json') - expect(headlessLinuxGuide).not.toContain('Two facts make this safe and predictable') - }) - - it('uses the registered CLI name from ordinary Linux shells', () => { - const commandRule = - 'The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing the GNOME Orca screen reader.' - const substitutionRule = - "From an ordinary shell outside that service user's managed environment, substitute `orca-ide` for `orca` in commands below." - const censusCommand = '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`' - - expect(headlessLinuxProse).toContain(commandRule) - expect(headlessLinuxProse).toContain(substitutionRule) - expect(headlessLinuxProse).toContain(censusCommand) - expect(headlessLinuxGuide).toContain('best-effort dispatcher at `$HOME/.local/bin/orca`') - expect(headlessLinuxProse.indexOf(substitutionRule)).toBeLessThan( - headlessLinuxProse.indexOf(censusCommand) - ) - }) }) diff --git a/config/scripts/mobile-web-app-route-manifest.test.mjs b/config/scripts/mobile-web-app-route-manifest.test.mjs index b4c29d8d9d8..1cbf256bd7a 100644 --- a/config/scripts/mobile-web-app-route-manifest.test.mjs +++ b/config/scripts/mobile-web-app-route-manifest.test.mjs @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -250,23 +250,3 @@ describe('the synthesized RequireContext', () => { expect(() => context('constructor')).toThrow('no route module') }) }) - -describe('the web entry', () => { - it('leaves the suspense boundary to expo-router', async () => { - const entry = await readFile(join(projectDir, 'mobile', 'web-entry', 'index.tsx'), 'utf8') - // A second boundary around the whole tree catches nothing the router has not already caught, - // and would only make the fallback ambiguous about which layer suspended. - expect(entry).not.toContain('Suspense') - }) - - itBundling('because the router already wraps every screen in one', async () => { - // The premise of the test above, read off the copy that is bundled: getQualifiedRouteComponent - // wraps each screen itself, which is what makes the lazy route manifest safe without a - // boundary of our own. - const useScreens = await readFile( - join(projectDir, 'mobile', 'node_modules', 'expo-router', 'build', 'useScreens.js'), - 'utf8' - ) - expect(useScreens).toContain(' { } it('keeps the native Windows registry addon optional and platform-gated', () => { - const rebuildScript = readProject('config/scripts/rebuild-native-deps.mjs') - const ensureScript = readProject('config/scripts/ensure-native-runtime.mjs') expect(packageJson.optionalDependencies['@orca/windows-registry']).toBe('workspace:*') // Why: allowBuilds stops pnpm running node-gyp at install time -- the root // Windows-only rebuild owns this addon so it is built against the right runtime ABI. expect(pnpmWorkspace.allowBuilds['@orca/windows-registry']).toBe(false) - // Why assert the guard and the member separately: the list now carries more - // than one addon, so pinning the whole literal only tested its formatting. - expect(rebuildScript).toContain("rebuildPlatform === 'win32'") - expect(rebuildScript).toContain("'@orca/windows-registry'") - expect(ensureScript).toContain("process.platform === 'win32'") - expect(ensureScript).toContain("'@orca/windows-registry'") if (windowsAddonsInstalled) { expect(packageTargets.win32).toEqual( expect.arrayContaining([ @@ -55,20 +46,10 @@ describe('Electron runtime package contract', () => { }) it('keeps the native Windows process-table addon optional and platform-gated', () => { - const rebuildScript = readFileSync( - join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), - 'utf8' - ) - const ensureScript = readFileSync( - join(projectDir, 'config/scripts/ensure-native-runtime.mjs'), - 'utf8' - ) expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0') // Why: same rule as the registry addon -- allowBuilds stops pnpm running node-gyp at // install time so the Windows-only rebuild owns it with the right runtime ABI. expect(pnpmWorkspace.allowBuilds['@vscode/windows-process-tree']).toBe(false) - expect(rebuildScript).toContain("'@vscode/windows-process-tree'") - expect(ensureScript).toContain("'@vscode/windows-process-tree'") // Why pin the patch: the upstream binding.gyp requires Spectre-mitigated // libraries our build agents do not carry, and the enumeration stops after // 1024 processes -- on a busy host that silently hides the very descendants @@ -92,195 +73,6 @@ describe('Electron runtime package contract', () => { } }) - it('guards package scripts that launch Electron tooling', () => { - const scripts = packageJson.scripts - const guardedScripts = [ - 'start', - 'dev', - 'dev-stable-name', - 'build:unpack', - 'build:win', - 'build:mac', - 'build:mac:release', - 'build:linux', - 'test:e2e', - 'test:e2e:terminal-rendering-golden', - 'test:e2e:posix-profile-index-golden', - 'test:e2e:terminal-rendering-release-evidence', - 'test:e2e:headful' - ] - - for (const scriptName of guardedScripts) { - expect(scripts[scriptName], scriptName).toContain('pnpm run ensure:electron-runtime &&') - } - }) - - it('keeps Windows and Linux package builds off macOS native helper builds', () => { - const scripts = packageJson.scripts - - expect(scripts['build:desktop']).not.toContain('build:computer-macos') - expect(scripts['build:desktop']).not.toContain('build:keyboard-layout-macos') - expect(scripts['build:win']).toContain('pnpm run build:desktop') - expect(scripts['build:win']).not.toContain('pnpm run build ') - expect(scripts['build:win']).not.toContain('build:computer-macos') - expect(scripts['build:win']).not.toContain('build:keyboard-layout-macos') - expect(scripts['build:linux']).toContain('pnpm run build:desktop') - expect(scripts['build:linux']).not.toContain('pnpm run build ') - expect(scripts['build:linux']).not.toContain('build:computer-macos') - expect(scripts['build:linux']).not.toContain('build:keyboard-layout-macos') - expect(scripts['build:mac']).toContain('pnpm run build:computer-macos') - expect(scripts['build:mac']).toContain('pnpm run build:keyboard-layout-macos') - expect(scripts['build:release']).toContain('pnpm run build:native') - expect(scripts['build:release']).not.toContain('build:computer-macos') - }) - - it('runs the web build through the heap-sized Vite wrapper', () => { - expect(packageJson.scripts['build:web']).toContain('node config/scripts/run-vite-web-build.mjs') - expect(packageJson.scripts['build:web']).toContain('node config/scripts/verify-web-build.mjs') - }) - - it('guards release publishing before electron-builder runs', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const macWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') - ) - const releaseCommands = new Map( - parsedWorkflow.jobs.build.strategy.matrix.include.map(({ platform, release_command }) => [ - platform, - release_command - ]) - ) - const macReleaseCommand = macWorkflow.jobs['build-mac'].steps.find( - (step) => step.name === 'Publish release artifacts (macOS)' - ).with.command - - expect([...releaseCommands.keys()].sort()).toEqual(['linux-arm64', 'linux-x64', 'win']) - for (const command of [...releaseCommands.values(), macReleaseCommand]) { - expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron') - expect(command).toContain('electron-builder') - expect(command.indexOf('ensure-native-runtime')).toBeLessThan( - command.indexOf('electron-builder') - ) - } - expect(macReleaseCommand).toContain(' && ORCA_MAC_RELEASE=1 ') - expect(releaseCommands.get('linux-x64')).toContain(' && pnpm exec electron-builder ') - expect(releaseCommands.get('linux-x64')).toContain('--linux AppImage deb rpm --x64') - expect(releaseCommands.get('linux-arm64')).toContain('ORCA_LINUX_ARM64_RELEASE=1') - expect(releaseCommands.get('linux-arm64')).toContain('--linux AppImage deb rpm --arm64') - expect(releaseCommands.get('win')).toContain( - '; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder ' - ) - }) - - it('blocks Linux and macOS release packaging on watcher process fault recovery', () => { - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const macWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') - ) - const assertFaultGate = (steps, publishStepName, expectedCondition) => { - const names = steps.map((step) => step.name) - const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation') - - expect(gate.if).toBe(expectedCondition) - expect(gate['continue-on-error']).toBeUndefined() - expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs') - expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron') - expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) - expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) - } - - assertFaultGate( - releaseWorkflow.jobs.build.steps, - 'Publish release artifacts (Linux)', - "runner.os == 'Linux'" - ) - assertFaultGate( - macWorkflow.jobs['build-mac'].steps, - 'Publish release artifacts (macOS)', - undefined - ) - }) - - it('packages and release-gates the SSH relay watcher child', () => { - const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') - const builderConfig = readFileSync( - join(projectDir, 'config/electron-builder.config.cjs'), - 'utf8' - ) - const remoteCommands = readFileSync( - join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'), - 'utf8' - ) - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const macWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') - ) - - expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") - expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") - expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')") - expect(builderConfig).toContain("from: 'out/relay'") - - // Hashing and remote install probing are manifest-driven, so the contract - // is that both companions are declared once and that both sites read it. - expect(relayArtifactFilenames(true)).toContain('relay-watcher.js') - expect(relayArtifactFilenames(true)).toContain('relay-ai-vault-service.js') - expect(relayBuild).toContain('relayArtifactFilenames(') - expect(remoteCommands).toContain('relayArtifactFilenames(') - - const assertRelayGate = (steps, publishStepName) => { - const names = steps.map((step) => step.name) - const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation') - expect(gate['continue-on-error']).toBeUndefined() - expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs') - expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) - expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) - } - - assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)') - assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)') - const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name) - expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan( - releaseNames.indexOf('Build Windows release artifacts') - ) - }) - - it('packages and verifies the Windows SSH node-pty console-list fallback', () => { - const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') - const relayDeploy = readFileSync(join(projectDir, 'src/main/ssh/ssh-relay-deploy.ts'), 'utf8') - const patchAsset = readFileSync( - join(projectDir, 'config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs'), - 'utf8' - ) - - expect(relayBuild).toContain('copyFileSync(') - expect(relayBuild).toContain('hash.update(readFileSync') - expect(relayBuild).toContain('node-pty-1.1.0-console-list-agent-patch.cjs') - expect(relayDeploy).toContain('assertPatchedNodePtyConsoleListAgent') - expect(relayDeploy.match(/\$\{windowsNodePtyPatchCommand\(nodePath\)\}/g)).toHaveLength(2) - expect(patchAsset).toContain('consoleProcessList = [shellPid];') - expect(patchAsset).toContain('packageJson.version !== EXPECTED_NODE_PTY_VERSION') - }) - - it('pins the Windows release builder to the VS 2022 runner image', () => { - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const windowsReleaseEntry = releaseWorkflow.jobs.build.strategy.matrix.include.find( - ({ platform }) => platform === 'win' - ) - - expect(windowsReleaseEntry.os).toBe('windows-2022') - }) - it('keeps release-cut signing provenance on GitHub-hosted runners', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') @@ -306,360 +98,4 @@ describe('Electron runtime package contract', () => { expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build') expect(releaseWorkflow.jobs['publish-release'].needs).toContain('build-mac') }) - - it('runs the macOS release build in an isolated Blacksmith workflow', () => { - const releaseMacWorkflowText = readFileSync( - join(projectDir, '.github/workflows/release-mac-build.yml'), - 'utf8' - ) - const releaseMacWorkflow = parse(releaseMacWorkflowText) - const buildMacJob = releaseMacWorkflow.jobs['build-mac'] - const checkoutStep = buildMacJob.steps.find((step) => step.name === 'Checkout') - const publishStep = buildMacJob.steps.find( - (step) => step.name === 'Publish release artifacts (macOS)' - ) - - expect(releaseMacWorkflow['run-name']).toBe( - 'Mac release build ${{ inputs.tag }} (${{ inputs.release_run_id }})' - ) - expect(releaseMacWorkflow.on.workflow_dispatch.inputs.tag.required).toBe(true) - expect(releaseMacWorkflow.on.workflow_dispatch.inputs.release_run_id.required).toBe(true) - expect(buildMacJob['runs-on']).toBe('blacksmith-6vcpu-macos-15') - expect(checkoutStep.with.ref).toBe('refs/tags/${{ inputs.tag }}') - expect(publishStep.with.command).toContain('ORCA_MAC_RELEASE=1') - expect(publishStep.with.command).toContain('electron-builder') - expect(publishStep.with.command).toContain('--mac --publish always') - expect(releaseMacWorkflowText).not.toContain('signpath/') - expect(releaseMacWorkflowText).not.toContain('SIGNPATH_') - }) - - it('publishes both Linux release matrix entries', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const publishLinuxStep = parsedWorkflow.jobs.build.steps.find( - (step) => step.name === 'Publish release artifacts (Linux)' - ) - - expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-x64'") - expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-arm64'") - expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}') - }) - - it('keeps Linux postinstall repairing Chromium sandbox permissions', () => { - const afterInstallScript = readFileSync( - join(projectDir, 'resources/linux/packaging/after-install.sh'), - 'utf8' - ) - - expect(afterInstallScript).toContain('chrome-sandbox') - expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') - expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') - expect(afterInstallScript).toContain('is_owned_link()') - expect(afterInstallScript).toContain('readlink -f -- "$link"') - expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]') - expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]') - }) - - it('advances only the skill release ledger in a taggable release-cut commit', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') - const bumpStep = parsedWorkflow.jobs.cut.steps.find( - (step) => step.name === 'Bump package.json and tag' - ) - - const bumpIndex = bumpStep.run.indexOf( - 'npm version "$VERSION" --no-git-tag-version --allow-same-version' - ) - const generateIndex = bumpStep.run.indexOf( - 'node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"' - ) - const commands = bumpStep.run.replace(/^\s*#.*$/gm, '') - // Unanchored: a `git add` chained after `&&` stages just as effectively. - const stagedPaths = [...commands.matchAll(/\bgit add (.+)$/gm)].flatMap((match) => - match[1].trim().split(/\s+/) - ) - // Quotes trimmed and deduped: the index guard names the row a second time. - const mentioned = new Set(commands.match(/resources[/\\]skills[^\s'"]*/g)) - expect(checkoutStep.with['fetch-depth']).toBe(0) - expect(bumpIndex).toBeGreaterThanOrEqual(0) - // Why: the cut is the only point that advances the release ledger, so this - // tag's revision is never rebuilt later — it appends that row, nothing else. - expect(generateIndex).toBeGreaterThan(bumpIndex) - expect(bumpStep.run.indexOf('git add package.json')).toBeGreaterThan(generateIndex) - expect(stagedPaths).toEqual(['package.json', 'resources/skills/release-mapping.json']) - // Every distinct mention must be staged, so a copy, a redirect, or a path - // held in a variable cannot reach the content-addressed artifacts. Matched - // without a trailing slash so `dir="resources/skills"` still counts. - expect([...mentioned]).toEqual(stagedPaths.slice(1)) - // Regeneration is banned job-wide by the generator suite. Here: `-a`, `-am`, - // and `--all` sweep unstaged artifacts in; `--allow-empty` below must not. - expect(commands).not.toMatch(/\bcommit\b[^\n]*(?:\s-[a-z]*a[a-z]*\b|\s--all\b)/) - expect(bumpStep.run).toContain('git diff --cached --quiet') - expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') - }) - - it('keeps release-cut RC retries monotonic across stale attempts', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const versionStep = parsedWorkflow.jobs.cut.steps.find( - (step) => step.name === 'Compute next version' - ) - - expect(versionStep.run).toContain('node config/scripts/release-rc-history.mjs "$1"') - expect(versionStep.run).toContain('tag_matches_current_ref') - expect(versionStep.run).toContain('cutting the next version instead of reusing stale artifacts') - expect(versionStep.run).toContain('git rev-parse "$existing_rc_tag"') - }) - - it('bumps separate Homebrew casks for stable and RC desktop tags', () => { - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const homebrewWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/homebrew-bump.yml'), 'utf8') - ) - - expect(releaseWorkflow.jobs['homebrew-bump'].if).toContain( - "startsWith(needs.cut.outputs.tag, 'v')" - ) - expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain('-rc.') - expect(releaseWorkflow.jobs['homebrew-bump-published-rc-draft'].with.tag).toBe( - '${{ needs.cut.outputs.latest_published_rc_tag }}' - ) - - const resolveCaskStep = homebrewWorkflow.jobs['bump-cask'].steps.find( - (step) => step.name === 'Resolve cask target' - ) - const renderStep = homebrewWorkflow.jobs['bump-cask'].steps.find( - (step) => step.name === 'Render updated cask file' - ) - const copyStep = homebrewWorkflow.jobs['bump-cask'].steps.find( - (step) => step.name === 'Copy cask into tap and open PR' - ) - - expect(resolveCaskStep.run).toContain('token="orca@rc"') - expect(resolveCaskStep.run).toContain('token="orca"') - expect(renderStep.env.CASK_PATH).toBe('${{ steps.cask.outputs.path }}') - expect(copyStep.run).toContain('cp "$CASK_PATH" "tap/$CASK_PATH"') - expect(copyStep.run).toContain('git add "$CASK_PATH"') - }) - - it('installs the Electron package binary in the shared unit-test workflow', () => { - const unitTestWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8') - ) - const installStep = unitTestWorkflow.jobs.test.steps.find( - (step) => step.name === 'Install Electron package binary for tests' - ) - - expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs') - }) - - it('smokes the packaged CLI from outside the checkout in PR checks', () => { - const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') - const parsedWorkflow = parse(prWorkflow) - const smokeStep = parsedWorkflow.jobs.package.steps.find( - (step) => step.name === 'Smoke packaged CLI' - ) - - expect(smokeStep.run).toBe( - 'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked' - ) - }) - - it('keeps terminal scale perf wired to the report budget gate', () => { - const packageScripts = packageJson.scripts - const terminalPerfWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/terminal-perf.yml'), 'utf8') - ) - const steps = terminalPerfWorkflow.jobs['terminal-perf'].steps - const runStep = steps.find((step) => step.name === 'Run terminal scale perf report gate') - const uploadStep = steps.find((step) => step.name === 'Upload terminal perf report') - - expect(packageScripts['test:e2e:terminal-perf:scale:report']).toContain( - 'run-terminal-scale-perf-report-gate.mjs' - ) - expect(runStep.run).toContain('pnpm run test:e2e:terminal-perf:scale:report') - expect(runStep.run).toContain('xvfb-run --auto-servernum') - const manualProfileKnobs = [ - ['ORCA_TERMINAL_PERF_FRAME_COUNT', 'frame_count', 'ORCA_E2E_OPENCODE_FRAME_COUNT'], - [ - 'ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS', - 'frame_interval_ms', - 'ORCA_E2E_OPENCODE_FRAME_INTERVAL_MS' - ], - [ - 'ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS', - 'pressure_output_chars', - 'ORCA_E2E_OPENCODE_PRESSURE_OUTPUT_CHARS' - ], - ['ORCA_TERMINAL_PERF_SCALE_PANES', 'scale_panes', 'ORCA_E2E_OPENCODE_SCALE_PANES'], - [ - 'ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES', - 'scale_cross_workspace_panes', - 'ORCA_E2E_OPENCODE_SCALE_CROSS_WORKSPACE_PANES' - ], - [ - 'ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES', - 'scale_pressure_panes', - 'ORCA_E2E_OPENCODE_SCALE_PRESSURE_PANES' - ], - [ - 'ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES', - 'scale_hidden_pressure_panes', - 'ORCA_E2E_OPENCODE_SCALE_HIDDEN_PRESSURE_PANES' - ] - ] - for (const [workflowEnv, inputName, runnerEnv] of manualProfileKnobs) { - expect(runStep.env[workflowEnv]).toBe(`\${{ inputs.${inputName} }}`) - expect(runStep.run).toContain(runnerEnv) - } - expect(uploadStep.uses).toBe('actions/upload-artifact@v7') - expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}') - }) - - it('keeps platform golden regressions in the manual and release workflows', () => { - const packageScripts = packageJson.scripts - const goldenWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8') - ) - const releaseWorkflow = parse( - readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') - ) - const steps = goldenWorkflow.jobs['golden-e2e'].steps - const goldenPlatformLabels = new Map([ - ['linux', 'Linux'], - ['mac', 'macOS'], - ['windows', 'Windows'] - ]) - const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include - const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort() - const goldenRunSteps = new Map( - goldenPlatforms.map((platform) => { - const label = goldenPlatformLabels.get(platform) - - expect(label, platform).toBeDefined() - - return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)] - }) - ) - const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden'] - const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include - const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence'] - const releaseBuildNeeds = releaseWorkflow.jobs.build.needs - const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs - // Why: Windows release evidence is temporarily paused for CI runner PTY readiness. - const releaseEvidencePlatforms = ['linux', 'mac'] - - expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( - '@terminal-rendering-golden' - ) - expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( - 'terminal-raw-emoji-table-scroll-restore.spec.ts' - ) - expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain( - 'terminal-webgl-atlas-budget.spec.ts' - ) - expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain( - 'terminal-long-table-scroll-restore.spec.ts' - ) - const goldenCommand = packageScripts['test:e2e:terminal-rendering-golden'] - expect(goldenCommand).toContain('--project electron-headless') - expect(goldenCommand).toContain('--project electron-headful') - expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( - 'golden-windows-fresh-startup.spec.ts' - ) - expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( - '@windows-fresh-startup-golden' - ) - expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( - 'golden-posix-profile-index-fsync.spec.ts' - ) - expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( - 'golden-posix-fresh-startup.spec.ts' - ) - expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( - '@posix-profile-index-golden' - ) - expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( - 'terminal-opencode-emoji-table-rendering.spec.ts' - ) - expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( - 'terminal-long-table-scroll-restore.spec.ts' - ) - expect(goldenMatrix).toEqual([ - { os: 'ubuntu-latest', platform: 'linux' }, - { os: 'macos-15', platform: 'mac' }, - { os: 'windows-2022', platform: 'windows' } - ]) - expect(goldenRunSteps.get('linux')?.run).toContain( - 'pnpm run test:e2e:terminal-rendering-golden' - ) - expect(goldenRunSteps.get('linux')?.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - expect(goldenRunSteps.get('mac')?.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - expect(goldenRunSteps.get('windows')).toMatchObject({ - if: "runner.os == 'Windows'", - shell: 'pwsh' - }) - expect(goldenRunSteps.get('windows').run).toContain( - 'pnpm run --if-present test:e2e:windows-fresh-startup-golden' - ) - expect(goldenWorkflow.on.pull_request).toBeUndefined() - expect(goldenWorkflow.on.workflow_dispatch).toBeDefined() - expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden') - expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence') - expect(publishReleaseNeeds).toContain('terminal-rendering-golden') - expect(publishReleaseNeeds).toContain('build') - expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') - expect(releaseGoldenJob['continue-on-error']).toBeUndefined() - expect(releaseGoldenMatrix).toEqual(goldenMatrix) - const releaseLinuxRunStep = releaseGoldenJob.steps.find( - (step) => step.name === 'Run terminal rendering golden on Linux' - ) - expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - expect(releaseLinuxRunStep.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - const releaseMacRunStep = releaseGoldenJob.steps.find( - (step) => step.name === 'Run terminal rendering golden on macOS' - ) - expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - expect(releaseMacRunStep.run).toContain( - 'pnpm run --if-present test:e2e:posix-profile-index-golden' - ) - const releaseWindowsRunStep = releaseGoldenJob.steps.find( - (step) => step.name === 'Run fresh-startup golden on Windows' - ) - expect(releaseWindowsRunStep).toMatchObject({ - if: "runner.os == 'Windows'", - shell: 'pwsh' - }) - expect(releaseWindowsRunStep.run).toContain( - 'pnpm run --if-present test:e2e:windows-fresh-startup-golden' - ) - expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden') - expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden') - expect(releaseEvidenceJob['continue-on-error']).toBe(true) - expect( - releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() - ).toEqual(releaseEvidencePlatforms) - expect(releaseEvidenceJob.steps.map((step) => step.run ?? '')).toContain( - 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence' - ) - }) }) diff --git a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs deleted file mode 100644 index a4998760b67..00000000000 --- a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { readFileSync } from 'node:fs' -import { parse } from 'yaml' -import { describe, expect, it } from 'vitest' - -describe('packaged hang watchdog worker contract', () => { - it('boots the worker from app.asar in PR checks', () => { - const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) - const smokeSource = readFileSync( - 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', - 'utf8' - ) - const smokeStep = workflow.jobs.package.steps.find( - (step) => step.name === 'Smoke packaged hang watchdog worker' - ) - - expect(smokeStep.run).toBe( - 'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked' - ) - expect(smokeSource).toContain( - "process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]" - ) - expect(smokeSource).toContain('const LAUNCH_TIMEOUT_MS = 30_000') - expect(smokeSource).toContain('timeout: LAUNCH_TIMEOUT_MS') - }) - - // Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion. - it('fails the smoke when the packaged worker never reports success', () => { - const smokeSource = readFileSync( - 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', - 'utf8' - ) - - expect(smokeSource).toContain('app.exit(1)') - expect(smokeSource).not.toContain('app.quit()') - expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))') - }) -}) diff --git a/config/scripts/pr-workflow-lint-parity.test.mjs b/config/scripts/pr-workflow-lint-parity.test.mjs deleted file mode 100644 index 648714f244c..00000000000 --- a/config/scripts/pr-workflow-lint-parity.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { readFileSync } from 'node:fs' -import { parse } from 'yaml' -import { describe, expect, it } from 'vitest' - -// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure -// does not mask the rest and oxlint keeps its `--format github` annotations. -// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so -// this gate fails the moment a `lint` step has no counterpart in pr.yml. - -// Flags that only change reporting, so they must not split two otherwise identical commands. -const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter']) -const REPORTING_FLAGS = new Set(['--quiet']) -const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node']) - -function splitCommandChain(command) { - return command - .split(/\n|&&|;/) - .map((part) => part.trim()) - .filter(Boolean) -} - -function canonicalize(command) { - const tokens = command.split(/\s+/) - const canonical = [] - - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index] - if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) { - continue - } - if (REPORTING_FLAGS.has(token)) { - continue - } - if (REPORTING_FLAGS_WITH_VALUE.has(token)) { - index += 1 - continue - } - canonical.push(token) - } - - return canonical.join(' ') -} - -/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */ -function resolveLeafCommands(command, scripts, seen = new Set()) { - const leaves = [] - - for (const part of splitCommandChain(command)) { - const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1] - if (scriptName && scripts[scriptName] && !seen.has(scriptName)) { - leaves.push( - ...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName])) - ) - continue - } - leaves.push(canonicalize(part)) - } - - return leaves -} - -describe('PR workflow lint parity', () => { - it('runs every `pnpm lint` step on pull requests', () => { - const { scripts } = JSON.parse(readFileSync('package.json', 'utf8')) - const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) - - // Scan every job: which one hosts the lint steps is an organizational - // detail that has already been renamed once (verify -> static_analysis). - const workflowCommands = new Set( - Object.values(workflow.jobs) - .flatMap((job) => job.steps ?? []) - .filter((step) => typeof step.run === 'string') - .flatMap((step) => resolveLeafCommands(step.run, scripts)) - ) - - const missing = resolveLeafCommands(scripts.lint, scripts).filter( - (leaf) => !workflowCommands.has(leaf) - ) - - expect( - missing, - `.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` + - 'Add a step for each one so PR CI matches `pnpm lint`.' - ).toEqual([]) - }) -}) diff --git a/config/scripts/ssh-docker-ci-sharding.test.mjs b/config/scripts/ssh-docker-ci-sharding.test.mjs index c3b015727d6..403c059e92c 100644 --- a/config/scripts/ssh-docker-ci-sharding.test.mjs +++ b/config/scripts/ssh-docker-ci-sharding.test.mjs @@ -37,26 +37,6 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', () ]) }) -it('runs all four isolated shards and the special SSH suites exactly once', () => { - expect(job.strategy.matrix.shard).toEqual([1, 2, 3, 4]) - expect(job.strategy['fail-fast']).toBe(false) - const remaining = job.steps.find((step) => step.name === 'Run remaining Docker SSH E2E') - expect(remaining.run).toContain('pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4') - expect(remaining.if).toBe('always()') - expect(remaining['continue-on-error']).toBeUndefined() - expect(readRunner(runners[0])).toContain("'--workers=1'") - expect(job.steps.find((step) => step.name === 'Run Docker SSH watcher isolation E2E').if).toBe( - 'matrix.shard == 1' - ) - expect( - job.steps.find( - (step) => step.name === 'Run Docker SSH terminal parking + startup readiness E2E' - ).if - ).toBe('always() && matrix.shard == 1') - const upload = job.steps.find((step) => step.uses === 'actions/upload-artifact@v7') - expect(upload.with.name).toContain('${{ matrix.shard }}') -}) - it('native Playwright shards preserve every SSH test and project exactly once', async () => { const cli = join(dirname(require.resolve('playwright/package.json')), 'cli.js') const env = { diff --git a/config/scripts/terminal-ime-e2e-workflow.test.mjs b/config/scripts/terminal-ime-e2e-workflow.test.mjs index 162a6d08ace..cb12839a2af 100644 --- a/config/scripts/terminal-ime-e2e-workflow.test.mjs +++ b/config/scripts/terminal-ime-e2e-workflow.test.mjs @@ -45,29 +45,6 @@ describe('terminal IME e2e workflow', () => { expect(nativeIndex).toBeGreaterThan(deterministicIndex) }) - it('keeps IBus lifecycle scoped to owned processes', () => { - const runner = readFileSync( - join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'), - 'utf8' - ) - - expect(runner).toContain( - "['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']" - ) - expect(runner).toContain("spawn('xfwm4', ['--compositor=off']") - expect(runner).toContain("['initial-input-mode', 'hangul']") - expect(runner).toContain("['hangul-keyboard', '2']") - expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')") - expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')") - expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs') - expect(runner).toMatch( - /'test:e2e:headful',\s*'--workers=1',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/ - ) - expect(runner).not.toContain("'--replace'") - expect(runner).not.toContain('killall') - expect(runner).not.toContain('pkill') - }) - it('runs native Wayland independently with CJK fonts and retained evidence', () => { const job = workflow.jobs['linux-wayland'] expect(job.needs).toBeUndefined() @@ -82,13 +59,4 @@ describe('terminal IME e2e workflow', () => { expect(upload.if).toBe('always()') expect(upload.with.name).toBe('terminal-wayland-ime-evidence') }) - - it('bounds blocking native input commands', () => { - const nativeSpec = readFileSync( - join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'), - 'utf8' - ) - - expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3) - }) }) diff --git a/config/scripts/verify-dev-channel-packaging.test.mjs b/config/scripts/verify-dev-channel-packaging.test.mjs index dfde5d461d2..ceef8f76174 100644 --- a/config/scripts/verify-dev-channel-packaging.test.mjs +++ b/config/scripts/verify-dev-channel-packaging.test.mjs @@ -53,19 +53,6 @@ describe('electron-builder dev-channel identity', () => { expect(config.win.verifyUpdateCodeSignature).toBe(false) }) - // Why on every channel: the hook is the only handle electron-builder gives on - // the NSIS uninstaller, and it signs nothing — it relays the file to and from - // the CI SignPath request. Carrying it must not drag a publisherName onto a - // dev build, which is the failure the split above exists to prevent. - it('carries the uninstaller sign hook without changing publisherName semantics', () => { - for (const env of [{}, WIN_ADHOC_ENV]) { - const config = loadConfigWithEnv(env) - expect(typeof config.win.signtoolOptions.sign).toBe('function') - } - expect(loadConfigWithEnv({}).win.signtoolOptions.publisherName).toBe('SignPath Foundation') - expect(loadConfigWithEnv(WIN_ADHOC_ENV).win.signtoolOptions.publisherName).toBeUndefined() - }) - it.each([ ['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'], ['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'], diff --git a/config/scripts/win-crash-survival-e2e.test.mjs b/config/scripts/win-crash-survival-e2e.test.mjs index 40a2e1bb483..9c772eb01eb 100644 --- a/config/scripts/win-crash-survival-e2e.test.mjs +++ b/config/scripts/win-crash-survival-e2e.test.mjs @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { parseArgs } from '../../tests/tools/win-crash-survival-e2e/cli-args.mjs' import { buildCrashAssertions } from '../../tests/tools/win-crash-survival-e2e/crash-assertions.mjs' @@ -13,26 +12,6 @@ import { closeApp, resolveElectronMainPid } from '../../tests/tools/win-update-e import { isPidAlive } from '../../tests/tools/win-update-e2e/daemon-processes.mjs' describe('win-crash-survival-e2e proof contracts', () => { - it('keeps the packaged proof manually dispatchable without a PR trigger', () => { - const workflow = readFileSync('.github/workflows/win-crash-survival-e2e.yml', 'utf8') - expect(workflow).not.toMatch(/^ pull_request:/m) - expect(workflow).toMatch(/^ workflow_dispatch:/m) - expect(workflow).not.toMatch(/^ push:/m) - expect(workflow).toContain('--expect "$env:EXPECT"') - expect(workflow).toContain('exit $LASTEXITCODE') - expect(workflow).toContain("'!config/**/*.test.*'") - expect(workflow).toContain("'!src/**/*.test.*'") - expect(workflow).toContain("'!src/**/*.bench.*'") - expect(workflow).toContain("'!config/reliability-gates.jsonc'") - expect(workflow).toContain("'resources/**'") - expect(workflow).toContain('cache: pnpm') - expect(workflow.indexOf('- name: Setup Node.js')).toBeGreaterThan( - workflow.indexOf('- name: Setup pnpm') - ) - expect(workflow).toContain("if: steps.cache-installer.outputs.cache-hit != 'true'") - expect(workflow).toContain('crash-survival-electron-builder-') - }) - it('requires the full survival oracle, including daemon identity and reattach', () => { const base = { profile: 'survival', @@ -58,15 +37,6 @@ describe('win-crash-survival-e2e proof contracts', () => { ).toBe(false) }) - it('scans for FailFast only after the post-crash input probe', () => { - const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8') - const scanIndex = harness.indexOf('const { events: failFastEvents }') - const probeIndex = harness.indexOf('reattachProven = await proveReattachedShell') - expect(scanIndex).not.toBe(-1) - expect(probeIndex).not.toBe(-1) - expect(scanIndex).toBeGreaterThan(probeIndex) - }) - it('fails closed when the Windows event log query fails', () => { let command = '' expect(() => @@ -162,11 +132,7 @@ describe('win-crash-survival-e2e proof contracts', () => { expect(reattachSentinelMatches('1660|canary|extra', 'canary', 1660)).toBe(false) }) - it('requires the real packaged main for the crash proof but permits fallback cleanup', async () => { - const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8') - expect(harness).toContain( - 'resolveElectronMainPid(session.app, { allowLauncherFallback: false })' - ) + it('resolves the real packaged main but permits fallback cleanup', async () => { expect( await resolveElectronMainPid({ evaluate: async () => 222, diff --git a/config/scripts/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs index f1d3742e583..0f8a651e12f 100644 --- a/config/scripts/windows-process-tree-gyp-path.test.mjs +++ b/config/scripts/windows-process-tree-gyp-path.test.mjs @@ -1,43 +1,13 @@ import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { isAbsolute, join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' const projectDir = resolve(import.meta.dirname, '../..') -const PATCH = readFileSync( - join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch'), - 'utf8' -) const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')" describe('windows-process-tree node-addon-api gyp path', () => { - it('stages headers without a pnpm-sensitive gyp dependency', () => { - expect(PATCH).not.toContain('+ "../../node-addon-api') - expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],') - expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],') - const buildScript = readFileSync( - join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'), - 'utf8' - ) - expect(buildScript).toContain('stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)') - expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.') - const rebuildHelper = readFileSync( - join(projectDir, 'config/scripts/windows-process-tree-gyp-rebuild.mjs'), - 'utf8' - ) - expect(rebuildHelper).toContain("createRequire(join(packageDir, 'package.json'))") - expect(rebuildHelper).toContain("resolve('node-addon-api/package.json')") - expect(rebuildHelper).toContain("'napi.h'") - expect(rebuildHelper).toContain("'napi-inl.h'") - expect(rebuildHelper).toContain("'napi-inl.deprecated.h'") - const rebuildScript = readFileSync( - join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), - 'utf8' - ) - expect(rebuildScript).toContain('stageWindowsProcessTreeNodeAddonApiHeaders()') - }) - // The installed Windows dependency is exercised by the Windows CI lane. it.runIf(process.platform === 'win32')( 'resolves node_addon_api.gyp to a real file from the package directory', diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs deleted file mode 100644 index e7b18363aeb..00000000000 --- a/config/scripts/windows-pty-native-capability-workflow.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' - -const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) - -describe('packaged Windows PTY native capability routing', () => { - it('runs the packaged executable immediately after the unpacked app is built', () => { - const job = workflow.jobs.package_windows - const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app') - const smokeIndex = job.steps.findIndex( - (step) => step.name === 'Smoke packaged Windows PTY native capability' - ) - const smoke = job.steps[smokeIndex] - - expect(job['runs-on']).toBe('windows-2022') - expect(smokeIndex).toBe(packageIndex + 1) - expect(smoke.run).toBe( - 'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe' - ) - expect(smoke.if).toBeUndefined() - expect(smoke['continue-on-error']).toBeUndefined() - expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe( - 'node tests/tools/windows-pty-native-capability-smoke/run.mjs' - ) - }) - - it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => { - const job = workflow.jobs.package_windows - const install = job.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - const nodeCacheSave = job.steps.find( - (step) => step.name === 'Save compiled Node native modules' - ) - const electronCache = job.steps.find( - (step) => step.name === 'Restore compiled Electron native modules' - ) - const build = job.steps.find((step) => step.name === 'Build package inputs') - const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime') - const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') - const verify = workflow.jobs.verify.steps.find( - (step) => step.name === 'Require successful checks' - ) - const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8') - - expect(install.with['native-runtime']).toBe('node') - expect(install.with['persist-native-cache']).toBe('false') - expect(nodeCacheSave.uses).toBe('actions/cache/save@v5') - expect(nodeCacheSave.with.key).toContain('-node-node') - expect(electronCache.with.key).toContain('-electron-node') - for (const cache of [nodeCacheSave, electronCache]) { - expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml') - expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs') - expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs') - } - expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']") - expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')") - expect(build.run).toBe('pnpm run build:release:parallel') - expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') - expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') - expect(workflow.jobs.verify.needs).toContain('package_windows') - expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') - expect(verify.run).toContain('"$PACKAGE_WINDOWS"') - }) - - it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => { - const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8') - const probe = readFileSync( - 'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs', - 'utf8' - ) - const source = `${driver}\n${probe}` - - expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'") - expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i) - expect(source).not.toContain('maxRetries') - expect(source).not.toContain('retryDelay') - expect(source).not.toContain("from 'node:child_process'") - expect(source).not.toContain("require('node:child_process')") - expect(probe).toContain("'System32', 'wscript.exe'") - expect(probe).toContain('real-orca-detached-launcher.vbs') - expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i) - expect(probe).toContain('native.terminateJob(target._pty, target.pid)') - }) -}) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs deleted file mode 100644 index 98d0d5e8e88..00000000000 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ /dev/null @@ -1,498 +0,0 @@ -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' - -const projectDir = resolve(import.meta.dirname, '../..') - -const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) - -describe('Windows signing workflow contract', () => { - it('preflights SignPath module install before Windows signing side effects', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const installStepIndexes = stepNames.flatMap((name, index) => - name === 'Install SignPath PowerShell module' ? [index] : [] - ) - const buildIndex = stepNames.indexOf('Build Windows release artifacts') - const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') - const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') - const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') - - expect(verifyNodePtyIndex).toBe(buildIndex + 1) - expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) - expect(installStepIndexes[0]).toBeLessThan(uploadIndex) - - expect(steps[verifyNodePtyIndex].run).toContain( - 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - ) - expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') - - const uploadThroughDownloadScript = steps - .slice(uploadIndex, downloadIndex + 1) - .map((step) => step.run ?? '') - .join('\n') - - expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') - - const installStep = steps[installStepIndexes[0]] - - expect(installStep.if).toBe("matrix.platform == 'win' && github.run_attempt == 1") - expect(installStep.uses).toBe('./.github/actions/install-signpath-module') - expect(installStep.run).toBeUndefined() - - const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') - const actionStep = installAction.runs.steps[0] - const installRun = actionStep.run - const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( - ([, seconds]) => seconds - ) - - expect(installAction.runs.using).toBe('composite') - expect(actionStep.shell).toBe('pwsh') - expect(installRun).toContain( - 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' - ) - expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') - expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') - expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) - expect(installRun).toContain( - "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" - ) - expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) - expect(sleepSeconds).toContain('15') - expect(sleepSeconds).toContain('30') - expect(installRun).toContain( - 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' - ) - expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop') - expect(installRun).toContain( - 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' - ) - expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') - expect(installRun).not.toContain('SignPath*') - expect(installRun).not.toMatch(/throw\s+\$_/) - }) - - it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => { - const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') - const installRun = installAction.runs.steps[0].run - - // Why: the gallery API 403s during Azure Front Door incidents while its CDN - // stays up, so a pinned nupkg is the fallback. The hash pin is the only - // integrity check on that route — losing it would let any payload install. - const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs - expect(version.default).toMatch(/^4\.\d+\.\d+$/) - expect(sha256.default).toMatch(/^[0-9a-f]{64}$/) - expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256') - expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') - expect(installRun).toContain('throw "SHA-256 mismatch for $source') - expect(installRun).toContain( - 'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg' - ) - - // The module only resolves by name when the folder matches its ModuleVersion. - expect(installRun).toContain( - '$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version' - ) - // The fallback only runs after the gallery route is exhausted, and still - // fails the job when neither route produced a usable module. - expect(installRun.indexOf('$installed = $true')).toBeLessThan( - installRun.indexOf('if (-not $installed)') - ) - expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') - }) - - it('still installs SignPath when the cut ref predates the composite action', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const checkoutIndex = stepNames.indexOf('Checkout') - const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref') - const installIndex = stepNames.indexOf('Install SignPath PowerShell module') - - // Why: the build job checks out the cut tag, which for a hotfix cut from an - // older ref can predate `.github/actions/install-signpath-module`; without - // this restore the `uses: ./…` step dies on a missing action.yml. - expect(restoreIndex).toBeGreaterThan(checkoutIndex) - expect(restoreIndex).toBeLessThan(installIndex) - - const restoreStep = steps[restoreIndex] - const restoreRun = restoreStep.run - - expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}') - expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml') - expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"') - expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions') - - // Why: restoring the action must not turn signing into a soft dependency — - // a missing module still has to fail the Windows job, and the CDN fallback - // still has to reject an unexpected payload. - expect(steps[installIndex]['continue-on-error']).toBeUndefined() - expect(restoreStep['continue-on-error']).toBeUndefined() - - const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs - .steps[0].run - - expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') - expect(installRun).toContain('throw "SHA-256 mismatch for $source') - }) - - it('never recreates Windows signing requests on a workflow rerun', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const skipStep = steps.find((step) => step.name === 'Skip Windows artifact rebuild on rerun') - - expect(skipStep?.if).toBe("matrix.platform == 'win' && github.run_attempt != 1") - expect(skipStep?.run).toContain('Existing signed release assets must be reused') - - const signingStepNames = [ - 'Build Windows release artifacts', - 'Stage unsigned inner PE files for signing', - 'Upload unsigned inner binaries for SignPath', - 'Submit inner binaries signing request', - 'Download signed inner binaries from SignPath', - 'Upload unsigned Windows installer for SignPath', - 'Submit Windows installer signing request', - 'Download signed Windows installer from SignPath', - 'Stage signed Windows release assets', - 'Publish signed Windows release artifacts' - ] - - for (const stepName of signingStepNames) { - const step = steps[stepNames.indexOf(stepName)] - expect(step?.if, stepName).toContain('github.run_attempt == 1') - } - }) - - it('shares one SignPath module install path between release and rehearsal', () => { - const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') - const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) - const installIndex = stepNames.indexOf('Install SignPath PowerShell module') - - // Why: the rehearsal exists to prove the real signing flow, so it must - // install the module exactly the way the release job does. - expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe( - './.github/actions/install-signpath-module' - ) - expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined() - expect(installIndex).toBeLessThan( - stepNames.indexOf('Download signed inner binaries from SignPath') - ) - }) - - it('requires Windows inner binary signatures before publishing', () => { - const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') - const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') - const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') - const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') - - expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) - expect(stepNames[innerVerifyIndex + 1]).toBe('Notify Slack when Windows signing fails') - expect(evidenceIndex).toBe(innerVerifyIndex + 2) - expect(publishIndex).toBe(evidenceIndex + 1) - - expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('true') - expect(steps[innerVerifyIndex].run).toContain("@('Orca.exe', 'resources\\bin\\orca.exe')") - expect(stepNames).not.toContain('Roll back to original installer after failed rebuild') - - const innerChainStepNames = [ - 'Stage unsigned inner PE files for signing', - 'Upload unsigned inner binaries for SignPath', - 'Submit inner binaries signing request', - 'Download signed inner binaries from SignPath', - 'Restore signed inner binaries into unpacked app', - 'Restore signed uninstaller for the installer rebuild', - 'Replace cached elevate.exe with the signed copy', - 'Rebuild NSIS installer from signed unpacked app' - ] - for (const stepName of innerChainStepNames) { - const step = steps[stepNames.indexOf(stepName)] - expect(step, stepName).toBeDefined() - expect(step['continue-on-error'], stepName).toBeUndefined() - } - }) -}) - -// Why these exist: the NSIS uninstaller is generated inside electron-builder's -// uninstaller pass and deleted immediately after being embedded, so the only way -// CI can sign it is the export/import relay through win.signtoolOptions.sign. -// Every link is asserted here the way Orca.exe and conpty_console_list.node are. -describe('Windows NSIS uninstaller signing', () => { - const releaseSteps = () => readWorkflow('.github/workflows/release-cut.yml').jobs.build.steps - const stepNamed = (steps, name) => steps.find((step) => step.name === name) - - const EXPORT_ENV = 'ORCA_WIN_UNINSTALLER_EXPORT_PATH' - const SIGNED_ENV = 'ORCA_WIN_UNINSTALLER_SIGNED_PATH' - - it('exports the uninstaller from the first Windows build', () => { - const build = stepNamed(releaseSteps(), 'Build Windows release artifacts') - - expect(build.env[EXPORT_ENV]).toContain('uninstaller-signing') - expect(build.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') - }) - - // Why this is a test and not a comment: `files` in the electron-builder config - // is all-negation, so app-builder packs whatever is left in the checkout root. - // These steps retry, and a retried attempt would pack an unsigned .exe into - // app.asar — the very defect this chain removes. Every relay path must live - // outside the checkout. - it('keeps every relay path out of the packed checkout', () => { - const relayEnvValues = [ - ...releaseSteps(), - ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps - ].flatMap((step) => [step.env?.[EXPORT_ENV], step.env?.[SIGNED_ENV]].filter(Boolean)) - - expect(relayEnvValues.length).toBe(4) - for (const value of relayEnvValues) { - expect(value).toContain('runner.temp') - expect(value).not.toContain('github.workspace') - } - - const relayScripts = [ - ...releaseSteps(), - ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps - ] - .map((step) => step.run ?? '') - .filter((run) => run.includes('uninstaller-signing')) - - expect(relayScripts.length).toBeGreaterThan(0) - for (const run of relayScripts) { - // Why count occurrences rather than assert `toContain` once: a step - // carrying two relay paths could root the first in RUNNER_TEMP and leave - // the second bare-relative — which resolves against the checkout, and is - // exactly the shape of the defect this test exists to catch. - const mentions = run.match(/uninstaller-signing/g) ?? [] - const rooted = run.match(/Join-Path \$env:RUNNER_TEMP 'uninstaller-signing/g) ?? [] - - expect(rooted.length, run).toBe(mentions.length) - expect(run).not.toContain('$env:GITHUB_WORKSPACE') - } - }) - - it('stages the uninstaller into the same request as the inner binaries', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - - expect(stage.run).toContain('uninstaller-signing\\unsigned\\orca-uninstaller.exe') - expect(stage.run).toContain('uninstaller\\orca-uninstaller.exe') - // No third SignPath request: exactly two submissions, as budgeted for the - // 1h + 4h approval waits inside the 360-minute job cap. - const submissions = releaseSteps().filter( - (step) => step.uses === 'signpath/github-action-submit-signing-request@v2' - ) - expect(submissions).toHaveLength(2) - }) - - it('keeps the uninstaller out of the inner-binary copy-back list', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - const restoreInner = stepNamed( - releaseSteps(), - 'Restore signed inner binaries into unpacked app' - ) - - expect(stage.run).not.toMatch(/\$list\.Add\(['"]uninstaller/) - expect(restoreInner.run).not.toContain('orca-uninstaller.exe') - }) - - it('blocks publication when the exported uninstaller is missing', () => { - const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') - const uninstallerBlock = stage.run.slice(stage.run.indexOf('$exportedUninstaller')) - - expect(uninstallerBlock).toContain('throw "No exported NSIS uninstaller') - expect(uninstallerBlock).not.toContain('catch') - expect(uninstallerBlock).toMatch(/Copy-Item[^\r\n]*-ErrorAction Stop/) - }) - - it('restores exact artifact paths rather than suffix-matching another executable', () => { - for (const [name, path] of [ - ['Restore signed inner binaries into unpacked app', '$relative'], - [ - 'Restore signed uninstaller for the installer rebuild', - "'uninstaller\\orca-uninstaller.exe'" - ] - ]) { - const restore = stepNamed(releaseSteps(), name) - expect(restore.run).toContain(`(Join-Path 'signed-inner' ${path})`) - expect(restore.run).toContain(`(Join-Path 'signed-inner/signing-stage' ${path})`) - expect(restore.run).toContain('if (@($candidates).Count -ne 1)') - expect(restore.run).not.toContain('Select-Object -First 1') - expect(restore.run).not.toContain('-like "*$relative"') - const rehearsal = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') - const rehearseRestore = stepNamed(rehearsal.jobs.rehearse.steps, name) - expect(rehearseRestore.run).toBe(restore.run) - expect(restore.env.SIGNING_POLICY).toBe('release-signing') - expect(rehearseRestore.env.SIGNING_POLICY).toBe( - "${{ inputs.signing-policy-slug || 'test-signing' }}" - ) - expect(restore.run).toContain("$requireValid = $env:SIGNING_POLICY -ne 'test-signing'") - expect(restore.run).toContain( - "if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*')))" - ) - } - }) - - it('requires the CLI signature at its electron-builder extraResources destination', () => { - const require = createRequire(import.meta.url) - const config = require('../electron-builder.config.cjs') - const cli = config.win.extraResources.find((resource) => resource.to === 'bin/orca.exe') - expect(cli).toBeDefined() - const payloadPath = `resources/${cli.to}`.replaceAll('/', '\\') - const rehearsal = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') - for (const gate of [ - stepNamed(releaseSteps(), 'Verify Windows inner binary signatures'), - stepNamed(rehearsal.jobs.rehearse.steps, 'Verify signatures end to end') - ]) { - expect(gate.run).toContain(`foreach ($requiredTarget in @('Orca.exe', '${payloadPath}'))`) - expect(gate.run).toContain( - 'if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }' - ) - expect(gate.run).toContain('foreach ($relative in $targets)') - } - }) - - it('waits for approval even when the notification fails', () => { - const steps = releaseSteps() - const notify = stepNamed( - steps, - 'Notify Slack that inner-binary signing is waiting for approval' - ) - const download = stepNamed(steps, 'Download signed inner binaries from SignPath') - expect(notify['continue-on-error']).toBe(true) - expect(download.if).not.toContain('notify-inner-signing') - expect(download.if).toContain("steps.submit-inner-signing.outcome == 'success'") - }) - - it('re-injects the signed uninstaller into the rebuilt installer', () => { - const steps = releaseSteps() - const restore = stepNamed(steps, 'Restore signed uninstaller for the installer rebuild') - const rebuild = stepNamed(steps, 'Rebuild NSIS installer from signed unpacked app') - const names = steps.map((step) => step.name) - - expect(restore.if).toContain('github.run_attempt == 1') - expect(restore.if).toContain("steps.restore-signed-inner.outcome == 'success'") - expect(restore.run).toContain('orca-uninstaller.exe') - expect(names.indexOf(restore.name)).toBeLessThan(names.indexOf(rebuild.name)) - expect(rebuild.env[SIGNED_ENV]).toContain('uninstaller-signing') - // Default success() gating blocks the rebuild after a failed restore. - expect(rebuild.if).not.toContain('restore-signed-uninstaller') - }) - - // NSIS hides the uninstaller in a compressed data section the bundled 7za - // cannot read, so the gate proves it from the sign hook's digest receipt - // instead of extracting it — and only when the relay actually ran. - it('reports the embedded uninstaller in the inner-binary evidence gate', () => { - const gate = stepNamed(releaseSteps(), 'Verify Windows inner binary signatures') - - expect(gate.env.UNINSTALLER_SIGNING_COMPLETED).toBe( - "${{ steps.restore-signed-uninstaller.outcome == 'success' }}" - ) - expect(gate.run).toContain('.embedded-sha256') - expect(gate.run).toContain("$failures.Add('The NSIS uninstaller signing did not complete.')") - expect(gate.run).toContain("$env:UNINSTALLER_SIGNING_COMPLETED -eq 'true'") - expect(gate.run).toContain('not signed by SignPath Foundation: Uninstall Orca.exe') - // The uninstaller must not join the 7z payload loop, which cannot see it. - expect(gate.run).not.toContain("$targets += 'Uninstall Orca.exe'") - }) - - it('rehearses the uninstaller leg end to end', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const names = steps.map((step) => step.name) - const pack = stepNamed(steps, 'Package Windows app and export the NSIS uninstaller') - const rebuild = stepNamed(steps, 'Build NSIS installer from signed unpacked app') - const verify = stepNamed(steps, 'Verify signatures end to end') - - // --dir never produces an uninstaller, so the rehearsal has to build the - // installer the way release-cut's first Windows pass does. - expect(pack.run).toContain('--win --publish never') - expect(pack.run).not.toContain('--dir') - expect(pack.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') - expect(names).toContain('Restore signed uninstaller for the installer rebuild') - expect(rebuild.env[SIGNED_ENV]).toContain('orca-uninstaller.exe') - expect(verify.run).toContain('.embedded-sha256') - // The receipt only proves the import leg ran. The rehearsal is where the - // shipped uninstaller itself gets checked — the release job cannot install - // onto the runner it publishes from. - expect(verify.run).toContain('shipped: Uninstall Orca.exe') - expect(verify.run).toContain('-tnsis') - expect(verify.run).toContain("-ArgumentList '/S'") - }) - - // This workflow is the merge gate, so it must not be able to fail on its own - // artefact: 7-Zip's NSIS handler is unreliable enough that its output has to - // be corroborated before a signature verdict is drawn from it. - it('never lets an unreliable extract fail the rehearsal', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const verify = stepNamed(steps, 'Verify signatures end to end') - - // The 7-Zip route is only trusted when it reproduces the relayed bytes; - // otherwise it falls through to the install route rather than failing. - expect(verify.run).toContain( - 'Write-Host "7-Zip\'s NSIS output did not match the relayed digest; falling back to a silent install."' - ) - expect(verify.run).toMatch(/\$installedUninstaller = \$null\r?\n\s*\}/) - - // The comparison that is not tautological: a file NSIS wrote out, against - // the digest the sign hook recorded. - expect(verify.run).toContain('$shippedDigest -ne $expectedDigest') - expect(verify.run).toContain('the uninstaller the installer ships is not the relayed one') - - // An installer that prompts must not hang to the 360-minute job cap, and - // the app it launches must not outlive the step holding install-dir handles. - expect(verify.run).toContain('-PassThru') - expect(verify.run).toContain('$installerProcess.WaitForExit(300000)') - expect(verify.run).toContain('the silent install did not exit within 5 minutes') - expect(verify.run).toMatch(/for \(\$attempt = 0; \$attempt -lt 20; \$attempt\+\+\)/) - expect(verify.run).toContain("Get-Process -Name 'orca-terminal-daemon'") - }) - - // resources\elevate.exe is downgraded to advisory because app-builder-lib's - // CopyElevateHelper clobbers it on every nsis pack — a pre-existing defect - // that predates the uninstaller relay and is being tracked separately. The - // escape hatch it needed is the kind that quietly grows until the gate - // asserts nothing, so pin it to exactly that one file. - it('confines the advisory escape hatch to elevate.exe', () => { - const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse - .steps - const verify = stepNamed(steps, 'Verify signatures end to end') - const advisoryCalls = verify.run - .split('\n') - .filter((line) => line.includes('-Advisory') && line.includes('Test-Signature')) - - expect(advisoryCalls).toHaveLength(1) - expect(advisoryCalls[0]).toContain('installed: $relative') - expect(verify.run).toContain("if ($relative -eq 'resources\\elevate.exe')") - - // Both uninstaller verdicts stay fatal — the whole point of the gate. - for (const call of ['relayed: orca-uninstaller.exe', 'shipped: Uninstall Orca.exe']) { - const line = verify.run - .split('\n') - .find((it) => it.includes(`Test-Signature`) && it.includes(call)) - expect(line, call).toBeDefined() - expect(line, call).not.toContain('-Advisory') - } - - // An advisory must still reach the evidence artifact, or downgrading it - // becomes indistinguishable from deleting the check. - expect(verify.run).toContain('ADVISORY (known pre-existing') - expect(verify.run).toContain('$script:advisories.Add($problem)') - }) - - it('wires the electron-builder sign hook that the relay depends on', () => { - const require = createRequire(import.meta.url) - const configPath = resolve(projectDir, 'config/electron-builder.config.cjs') - delete require.cache[require.resolve(configPath)] - const config = require(configPath) - - expect(typeof config.win.signtoolOptions.sign).toBe('function') - delete require.cache[require.resolve(configPath)] - }) -}) diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 0b25d44d3f0..2a77c8a2b3a 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { mobileAccountsRouteTarget } from './mobile-accounts-route' import { @@ -7,12 +6,6 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') -const accountCardsSource = readFileSync( - new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url), - 'utf8' -) - function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() let state = initialState @@ -85,10 +78,4 @@ describe('mobile accounts route', () => { payload: mobileAccountsRouteTarget('host/one') }) }) - - it('opens the home account-usage card through the cold-navigator-safe transition', () => { - expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}') - expect(accountCardsSource).toContain('props.onOpen(host.id)') - expect(accountCardsSource).not.toContain('/accounts`') - }) }) diff --git a/mobile/src/browser/mobile-browser-pane-source.test.ts b/mobile/src/browser/mobile-browser-pane-source.test.ts deleted file mode 100644 index 69cd5af5391..00000000000 --- a/mobile/src/browser/mobile-browser-pane-source.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(new URL('./MobileBrowserPane.tsx', import.meta.url), 'utf8') - -function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('MobileBrowserPane source invariants', () => { - it('mirrors handler refs in a layout effect instead of during render', () => { - const mirrorBlock = sliceBetween( - 'useLayoutEffect(() => {', - ' useEffect(() => {\n lastZoomResetUrlRef.current' - ) - - expect(mirrorBlock).toContain('frameMetadataRef.current = frameMetadata') - expect(mirrorBlock).toContain('layoutRef.current = layout') - expect(mirrorBlock).toContain('dialogRef.current = dialog') - expect(mirrorBlock).toContain('zoomRef.current = zoom') - expect(mirrorBlock).toContain('}, [dialog, frameMetadata, layout, zoom])') - }) -}) diff --git a/mobile/src/components/mobile-agent-icon-assets.test.ts b/mobile/src/components/mobile-agent-icon-assets.test.ts index f4f6325d806..7020d4b4977 100644 --- a/mobile/src/components/mobile-agent-icon-assets.test.ts +++ b/mobile/src/components/mobile-agent-icon-assets.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import type { TuiAgent } from '../../../src/shared/tui-agent' @@ -17,7 +17,6 @@ const GLYPH_AGENT_IDS = new Set([ const dirname = import.meta.dirname const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons') -const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8') // Why: these agents previously loaded from Google's favicon service, which is // unreachable in some regions/offline (#8451). Every one that lacks a glyph @@ -34,12 +33,4 @@ describe('mobile bundled agent icons', () => { ) } }) - - it('wires every favicon-path agent into the bundled asset map', () => { - for (const id of agentsNeedingBundledIcon) { - expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain( - `agent-icons/${id}.png` - ) - } - }) }) diff --git a/mobile/src/files/files-router-seam-census.test.ts b/mobile/src/files/files-router-seam-census.test.ts deleted file mode 100644 index 360b040b2df..00000000000 --- a/mobile/src/files/files-router-seam-census.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - importsExpoRouterValue, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const FILES_ROOT = import.meta.dirname - -/** - * Which modules here hold a router, so the census cannot pass by seeing nothing. - * - * The walk and the two rules are the seam's, shared with every other domain that runs them; what - * stays here is this domain's own evidence: which of its modules are meant to hold a router. - */ -const ROUTER_HOLDERS = ['MobileFilePreviewScreen.tsx', 'MobileFileExplorerPanel.tsx'] - -describe('the files domain reaches the router through the handoff seam', () => { - const files = productFiles(FILES_ROOT) - - it('walks the modules it is written against', () => { - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - }) - - it('imports no router from expo-router, which the page cannot hand a route back through', () => { - expect(files.filter((name) => importsExpoRouterValue(parse(FILES_ROOT, name)))).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen that holds one', () => { - expect(files.filter((name) => callsRouteHandoff(parse(FILES_ROOT, name))).sort()).toEqual( - [...ROUTER_HOLDERS].sort() - ) - }) -}) diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts deleted file mode 100644 index 617dd030c8a..00000000000 --- a/mobile/src/home-host-edit-navigation-source.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const homeSource = readFileSync(new URL('./home/MobileHomeScreen.tsx', import.meta.url), 'utf8') - -describe('Home host edit navigation wiring', () => { - it('uses the cold-navigator-safe edit transition', () => { - expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/) - expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/) - }) -}) diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts deleted file mode 100644 index aedc0543bce..00000000000 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') -const nativeCaptureSource = readFileSync( - new URL('../platform/dictation-capture.ts', import.meta.url), - 'utf8' -) - -function sliceSource(sourceText: string, startPattern: string, endPattern: string): string { - const start = sourceText.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = sourceText.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return sourceText.slice(start, end) -} - -function sliceBetween(startPattern: string, endPattern: string): string { - return sliceSource(source, startPattern, endPattern) -} - -describe('useMobileDictation source invariants', () => { - it('publishes live option refs from committed renders before passive Effects flush', () => { - const refDeclarations = sliceBetween( - 'const clientRef = useRef(client)', - 'useLayoutEffect(() => {' - ) - expect(refDeclarations).not.toContain('.current =') - - const mirrorEffect = sliceBetween('useLayoutEffect(() => {', 'const reportError =') - expect(mirrorEffect).toContain('clientRef.current = client') - expect(mirrorEffect).toContain('enabledRef.current = enabled') - expect(mirrorEffect).toContain('onTranscriptRef.current = onTranscript') - expect(mirrorEffect).toContain('onErrorRef.current = onError') - expect(mirrorEffect).toContain('}, [client, enabled, onTranscript, onError])') - }) - - it('carries audio the capture dropped into the same refusal the budget raises', () => { - const chunkHandler = sliceBetween('const sub = capture.onChunk(', 'return () => sub.remove()') - expect(chunkHandler).toContain('if (chunk.droppedBytes > 0)') - expect(chunkHandler).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') - // Only the page can drop: the seam's native half is where the microphone is. - expect(nativeCaptureSource).toContain('droppedBytes: 0') - }) - - it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { - const pendingChunkClears = source.match(/pendingChunksRef\.current\.clear\(\)/g) ?? [] - const pendingAudioResets = source.match(/pendingAudioBudgetRef\.current\.reset\(\)/g) ?? [] - - expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) - }) - - it('closes the capture on every path that ends a dictation', () => { - const closeAudio = sliceBetween( - 'const closeDictationAudio = useCallback(', - 'const failActiveDictation =' - ) - expect(closeAudio).toContain('capture.end()') - const cleanupSlices = [ - sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), - sliceBetween('const abandonDictation = useCallback(', 'const cancel ='), - sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') - ] - for (const cleanupSlice of cleanupSlices) { - expect(cleanupSlice).toContain('closeDictationAudio()') - } - // The capture hands over its tail before the pending sends are taken, or the finish overtakes - // the last chunk. `mobile-dictation-stop-tail.test.tsx` drives the order this pins. - const stopBody = sliceBetween( - 'const stop = useCallback(async () => {', - 'const abandonDictation =' - ) - expect(stopBody.indexOf('capture.end()')).toBeLessThan( - stopBody.indexOf('await Promise.allSettled') - ) - }) - - it('routes an audio interruption through cancel and a disable through the reporting abort', () => { - const interruptionEffect = sliceBetween('capture.onInterruption(', 'return () => sub.remove()') - const disabledEffect = sliceBetween( - 'useEffect(() => {\n if (!enabled) {', - ' }, [abandonDictation, enabled])' - ) - - expect(interruptionEffect).toContain('void cancel()') - // A disable is not the user's cancel, so it carries the reason the composer shows; the user's - // own cancel is the one end that passes none. `mobile-dictation-input-closed-mid-start.test.tsx` - // drives both halves. - expect(disabledEffect).toContain( - 'void abandonDictation(MOBILE_DICTATION_INPUT_CLOSED_ERROR_MESSAGE)' - ) - expect(source).toContain('const cancel = useCallback(() => abandonDictation(null)') - // Which interruptions end a capture is one predicate both seams read, so a page cannot cancel - // on a kind the device ignores. `dictation-capture.test.ts` drives the rule itself. - expect(nativeCaptureSource).toContain('bridgeAudioInterruptionEndsCapture(event.data)') - }) -}) diff --git a/mobile/src/host-screen/host-route-href-encoding.test.ts b/mobile/src/host-screen/host-route-href-encoding.test.ts deleted file mode 100644 index c1ced2afe90..00000000000 --- a/mobile/src/host-screen/host-route-href-encoding.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Every host-route target the host page builds, and whether its host id survives being one segment. - * - * `useLocalSearchParams` and the persisted host store both answer the decoded id, and the store - * admits any non-empty string, so an id carrying `/`, `?`, `#` or whitespace stops being the single - * segment `matchesRoutePattern` reads it as the moment it is interpolated raw. Inside the shell's - * page that decides where a tap goes: the handoff matches the pathname against the shell's - * `pageRoutes` before it chooses between this document and the native stack. - * - * A census rather than six assertions, because the failure is a habit and not a bug: each of these - * sites was written by copying the one next to it, and the seventh will be too. - */ -const MOBILE_ROOT = join(import.meta.dirname, '..', '..') - -/** The files that build a target from the host list and its row sheet. */ -const HOST_PAGE_SOURCES = [ - 'src/host-screen/host-screen-header.tsx', - 'src/host-screen/host-screen-overlays.tsx', - 'src/host-screen/use-host-worktree-actions.ts', - 'src/agent-history/worktree-navigation-actions.ts' -] - -/** - * The two `/h/${...}` reads that are not targets. - * - * Both compare against a pathname the router answers rather than building a link, so encoding them - * would change what a comparison matches instead of what a tap opens. They are listed by the text - * that makes them comparisons, so a line that stopped being one stops being exempt. - */ -const COMPARISONS = ['pathname === `/h/${hostId}`'] - -const HOST_SEGMENT = /`\/h\/\$\{([^}]*)\}/g - -function hostInterpolations(source: string): string[] { - return [...source.matchAll(HOST_SEGMENT)].map((match) => match[1] ?? '') -} - -describe('every host-route target the host page builds', () => { - it('reaches the census, so the assertion below cannot pass on an empty list', () => { - const found = HOST_PAGE_SOURCES.flatMap((path) => - hostInterpolations(readFileSync(join(MOBILE_ROOT, path), 'utf8')) - ) - expect(found.length).toBeGreaterThanOrEqual(6) - }) - - it('encodes the host id, at every site', () => { - const raw: string[] = [] - for (const path of HOST_PAGE_SOURCES) { - const source = readFileSync(join(MOBILE_ROOT, path), 'utf8') - const exempt = COMPARISONS.filter((text) => source.includes(text)).length - const unencoded = hostInterpolations(source).filter( - (expression) => !expression.includes('encodeURIComponent') - ) - // The comparisons are subtracted rather than matched away, so a file that lost its comparison - // and gained a raw target does not come out even. - if (unencoded.length > exempt) { - raw.push(`${path}: ${unencoded.length - exempt} raw`) - } - } - expect(raw).toEqual([]) - }) - - it('keeps the pathname comparisons out of the count for a stated reason, not by accident', () => { - const source = readFileSync( - join(MOBILE_ROOT, 'src/host-screen/use-host-worktree-actions.ts'), - 'utf8' - ) - // Present, so the exemption above is answering something real; and a comparison, so the - // exemption is not quietly covering a target. - expect(source).toContain(COMPARISONS[0]) - }) -}) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index a93f503bd63..64220f80c11 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -28,7 +28,6 @@ import { act, create } from 'react-test-renderer' import { bridgeId, clientFrame, createFakeRpcClient } from './bridge-host-test-fakes' import { byName, - DEFAULT_ROUTE_GRANTS, trackRenderedScreen, NativeFallback, SCREEN_BUILD_ID as BUILD_ID, @@ -150,7 +149,6 @@ describe('the hybrid shell screen', () => { const tree = await renderScreen(readyState('session-one')) const view = byName(tree, 'ShellViewProbe')[0] expect(view.props.bridgeEnabled).toBe(true) - expect(typeof view.props.onBridgeMessage).toBe('function') // Delivered with no client behind it: there is no host to answer, and nothing throws. await act(async () => { view.props.onBridgeMessage({ nativeEvent: { json: '{"v":1,"type":"ready"}' } }) @@ -634,22 +632,6 @@ describe('a refused update is said beside the page, not in front of it', () => { }) }) -/** - * Last in the file on purpose: it is the case the block above would have poisoned. - * - * Those cases grant the screencast lane and install a client, and before the shared setup reset - * them both, whatever ran next inherited a route granted a lane it never asked for. Deleting the - * reset fails here and nowhere else, because nothing else runs after a case that mutates them. - */ -describe('what one case mutates does not reach the next', () => { - it('starts from the shared route grants and no client', () => { - expect({ grants: dependencies.routeGrants, client: dependencies.client }).toEqual({ - grants: DEFAULT_ROUTE_GRANTS, - client: null - }) - }) -}) - /** * What is on screen between the generation being mounted and the page having a frame. * diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts index 1901b5f368c..b29219f178e 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-boundary.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { BRIDGE_MAX_MESSAGE_BYTES, parseBridgeMessage } from './bridge-caps' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' @@ -95,18 +93,4 @@ describe('the page frame cap at its boundary', () => { expect(read.ok).toBe(false) expect(read.ok ? null : read.refusal).toBe('oversized') }) - - it('reaches the cap through the shared predicate and spells it nowhere else', () => { - // The census half. A copy of the bound inside the client is a second spelling that can only - // drift from the one the reader applies, and it would pass every case above on the day it was - // written. `isBridgeFrameWithinCap` is the one thing both sides call. - const client = readFileSync(join(import.meta.dirname, 'bridge-rpc-client.ts'), 'utf8') - expect(client).toContain('isBridgeFrameWithinCap(') - expect(client).not.toContain('BRIDGE_MAX_MESSAGE_BYTES') - // And the predicate is the reader's own, not a lookalike beside it: the module that parses - // inbound frames is the module that exports it. - const caps = readFileSync(join(import.meta.dirname, 'bridge-caps.ts'), 'utf8') - expect(caps).toContain('export function isBridgeFrameWithinCap') - expect(caps).toContain('if (!isBridgeFrameWithinCap(raw))') - }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts index f4ab3eb9bd3..351810c682a 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-frame-cap-unit.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { BRIDGE_MAX_MESSAGE_BYTES, parseBridgeMessage, utf8ByteLength } from './bridge-caps' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' @@ -28,18 +26,6 @@ const WIDE_CHARACTER = '漢' const WIDE_CHARACTER_COUNT = 250_000 describe('what the frame cap counts', () => { - it('is the unit both shells count, read from their own sources', () => { - const shell = join(import.meta.dirname, '..', '..', '..', 'modules', 'orca-mobile-web-shell') - const swift = readFileSync(join(shell, 'ios', 'MobileWebShellView.swift'), 'utf8') - const kotlin = readFileSync( - join(shell, 'android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt'), - 'utf8' - ) - // The inbound gate on each platform, by the expression it measures with. - expect(swift).toContain('bridgeGate.accepts(byteCount: json.utf8.count)') - expect(kotlin).toContain('bridgeGate.accepts(json.toByteArray(Charsets.UTF_8).size)') - }) - it('refuses a frame under the cap in code units and over it in bytes', async () => { const pad = WIDE_CHARACTER.repeat(WIDE_CHARACTER_COUNT) // The precondition, without which this is just another oversized frame: a sender reading diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts index 4542949ac32..a1abc7c2d3c 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts @@ -1,14 +1,9 @@ import { describe, expect, it } from 'vitest' import { BrowserScreencastOpcode, - type BrowserScreencastFormat, type BrowserScreencastFrame } from '../../transport/browser-screencast-protocol' import { isRpcResponse } from '../../transport/rpc-response-shape' -import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types' -import type { SendRequestOptions } from '../../transport/unvalidated-rpc-request-port' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { BRIDGE_MAX_MESSAGE_BYTES, BRIDGE_MAX_METHOD_CHARS, @@ -23,16 +18,12 @@ import { } from './bridge-caps' import { BRIDGE_HAPTICS_KINDS, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify' import { - BRIDGE_BINARY_FORMATS, - BRIDGE_CONNECTION_STATES, BRIDGE_FAULT_GRANT, - BRIDGE_FOREGROUND_NUDGE_REASONS, BRIDGE_NAVIGATE_BACK_NOTIFY, BRIDGE_PROTOCOL_VERSION, readBridgeClientMessage, readBridgeHostMessage, - type BridgeHostMessage, - type BridgeReplyPayload + type BridgeHostMessage } from './bridge-envelope' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' @@ -592,86 +583,7 @@ describe('the reply reader is the native acceptance predicate', () => { }) }) -describe('type pins', () => { - it('pins the protocol version both sides send', () => { - expect(BRIDGE_PROTOCOL_VERSION).toBe(1) - }) - - it('closes the connection states over the transport union', () => { - const asTransport = (value: (typeof BRIDGE_CONNECTION_STATES)[number]): ConnectionState => value - const asBridge = (value: ConnectionState): (typeof BRIDGE_CONNECTION_STATES)[number] => value - expect(BRIDGE_CONNECTION_STATES.map(asTransport).map(asBridge)).toEqual([ - ...BRIDGE_CONNECTION_STATES - ]) - }) - - it('closes the foreground reasons over the transport union', () => { - const asTransport = ( - value: (typeof BRIDGE_FOREGROUND_NUDGE_REASONS)[number] - ): ForegroundNudgeReason => value - const asBridge = ( - value: ForegroundNudgeReason - ): (typeof BRIDGE_FOREGROUND_NUDGE_REASONS)[number] => value - expect(BRIDGE_FOREGROUND_NUDGE_REASONS.map(asTransport).map(asBridge)).toEqual([ - ...BRIDGE_FOREGROUND_NUDGE_REASONS - ]) - }) - - it('resolves a reply payload to the transport envelope the page hands its callers', () => { - const asRpcResponse = (value: BridgeReplyPayload): RpcResponse => value - const read = readHost(client({ type: 'reply', id: ID, payload: SUCCESS_PAYLOAD })) - const payload = - read.ok && read.message.type === 'reply' && 'payload' in read.message - ? asRpcResponse(read.message.payload) - : null - expect(payload).toEqual(SUCCESS_PAYLOAD) - }) - - it('accepts every option the raw sender declares', () => { - // Both directions: the literal has to satisfy the type, and the type has to have no key the - // literal is missing, so a new option fails to compile until the schema learns it. - const optionKeys: Record = { - timeoutMs: true, - budgetSpansConnect: true, - failWhenDisconnected: true - } - const options: SendRequestOptions = { - timeoutMs: 1000, - budgetSpansConnect: true, - failWhenDisconnected: true - } - expect(Object.keys(optionKeys).toSorted()).toEqual(Object.keys(options).toSorted()) - expect(readClient(client({ type: 'request', id: ID, method: 'm', options })).ok).toBe(true) - }) - - it('bounds the viewport exactly where the desktop terminal contract does', () => { - // A viewport the page sends is replayed on resubscribe by every stream naming that terminal, - // the native screens' included. One the desktop refuses there would kill a stream the page - // never opened, so the two bounds have to be the same number. - // - // Read rather than imported: mobile may not pull a contract *value* into its bundle, and the - // boundary test that enforces that scans this file too. - const contract = readFileSync( - fileURLToPath( - new URL('../../../../src/shared/rpc-contract/terminal-unary-params.ts', import.meta.url) - ), - 'utf8' - ) - const start = contract.indexOf('export const TerminalViewport') - expect(start).toBeGreaterThan(-1) - const declaration = contract.slice(start, contract.indexOf('})', start)) - expect(declaration).toContain(`cols: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_COLS})`) - expect(declaration).toContain(`rows: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_ROWS})`) - }) - - it('closes the binary formats over the screencast protocol', () => { - const asProtocol = (value: (typeof BRIDGE_BINARY_FORMATS)[number]): BrowserScreencastFormat => - value - const asBridge = (value: BrowserScreencastFormat): (typeof BRIDGE_BINARY_FORMATS)[number] => - value - expect(BRIDGE_BINARY_FORMATS.map(asProtocol).map(asBridge)).toEqual([...BRIDGE_BINARY_FORMATS]) - }) - +describe('the screencast frame reader', () => { it('refuses a screencast metadata field that is not a finite number', () => { const keys = [ 'offsetTop', diff --git a/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts b/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts index c4962ebe16c..46faf5f38c1 100644 --- a/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts +++ b/mobile/src/mobile-web-shell/bridge/page-first-paint-through-the-bridge.test.ts @@ -3,8 +3,6 @@ * what is worth proving is that the page's post reaches the shell's cover over a real frame. */ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { createFakeRpcClient } from '../bridge-host-test-fakes' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' import { createRouteScreenPaintReporter } from './page-first-paint' @@ -33,23 +31,6 @@ describe('the page telling the shell it has a frame', () => { expect(pair.pagePaintCount()).toBe(1) }) - it('is reported by the page entry, from the effect that runs after the tree commits', () => { - // The one call site, pinned: every test above drives the client directly, so a deleted line in - // the entry would leave a shell covering a page that has painted and will never say so. - const entry = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'web-entry', 'index.tsx'), - 'utf8' - ) - expect(entry).toContain('createRouteScreenPaintReporter(') - expect(entry).toContain('client.notifyPagePainted()') - // Handed to the route screen rather than called from the wrapper's own effect, which commits - // while the route's chunk is still arriving and the body is empty. - expect(entry).toContain('RouteScreenPaintProvider') - expect(entry).not.toMatch( - /stampPageMountState\(target, 'mounted'\)\s*\n\s*reportRouteScreenPaint/ - ) - }) - it('costs the shell nothing to hear: no request, no subscription, no reply', async () => { const rpc = createFakeRpcClient() const pair = createFakeBridgePortPair({ rpc }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts deleted file mode 100644 index fd23a133375..00000000000 --- a/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -/** - * The hybrid shell flag is the whole of what keeps this feature dark in a native build, so who - * touches it is a product invariant rather than a convention. A second reader is how a dark feature - * stops being dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run - * in a native store build the moment anything flipped it, and none of those would fail a type - * check. - * - * The build-kind fact is the same shape of invariant one level up. `EXPO_PUBLIC_MOBILE_SHELL` is - * what a release workflow sets to build the OTA binary, and a second module spelling it would be a - * second answer to "is this the page's build" that no type check would catch either. - */ -const MOBILE_ROOT = join(import.meta.dirname, '..', '..') -const FLAG_KEY = 'orca:mobileWebShellEnabled' -/** The inlined form. Prose may name the variable; this is the spelling that reads it. */ -const BUILD_SWITCH_READ = 'process.env.EXPO_PUBLIC_' -/** The other spelling Expo's plugin inlines, which is how the rule above would be evaded. */ -const BUILD_SWITCH_BRACKET_READ = "process.env['EXPO_PUBLIC_" -const DEFINITION = 'src/storage/preferences.ts' -/** The one product reader, which only the shared switch decision asks. */ -const FLAG_HOOK = 'src/mobile-web-shell/use-mobile-web-shell-enabled.ts' -/** The one caller of that hook: every route asks this instead, so its list is the whole census. */ -const DECISION = 'src/mobile-web-shell/shell-switch-decision.ts' -const ROUTE = 'app/h/[hostId]/web.tsx' -const HOST_ROUTE = 'app/h/[hostId]/index.tsx' -const AGENT_HISTORY_ROUTE = 'app/h/[hostId]/agent-history/[worktreeId].tsx' -const TASKS_ROUTE = 'app/h/[hostId]/tasks.tsx' -const FILES_ROUTE = 'app/h/[hostId]/files/[worktreeId].tsx' -const FILES_PREVIEW_ROUTE = 'app/h/[hostId]/files/preview/[worktreeId].tsx' -const SOURCE_CONTROL_ROUTE = 'app/h/[hostId]/source-control/[worktreeId].tsx' -const REVIEW_ROUTE = 'app/h/[hostId]/review/[worktreeId].tsx' -const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx' -/** The one switch with no native screen behind it; its route file only re-exports this body. */ -const CATCH_ALL_ROUTE = 'src/mobile-web-shell/catch-all-page-route.tsx' -/** What a switch paints while the decision is `pending`, and the third thing every switch names. */ -const PENDING_SCREEN = 'src/mobile-web-shell/ShellSwitchPendingScreen.tsx' -/** One entry per screen the flag can switch to the page, which is what a review reads. */ -const SWITCHED_ROUTES = [ - HOST_ROUTE, - AGENT_HISTORY_ROUTE, - TASKS_ROUTE, - FILES_ROUTE, - FILES_PREVIEW_ROUTE, - SOURCE_CONTROL_ROUTE, - REVIEW_ROUTE, - SESSION_ROUTE, - CATCH_ALL_ROUTE -] -const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx' -/** The one screen that decides whether that row mounts, which is a build-kind question. */ -const TROUBLESHOOT_ROUTE = 'app/troubleshoot.tsx' -/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files, - * but it is where the native view lives and so the easiest place for a second reader to hide. */ -const TREES = { src: 200, app: 10, modules: 1 } -const SHELL_VIEW = 'modules/orca-mobile-web-shell/src/index.ts' - -function sourceFiles(directory: string): string[] { - return censusSourceFiles(join(MOBILE_ROOT, directory)) - .map((path) => relative(MOBILE_ROOT, path)) - .filter((path) => /\.tsx?$/.test(path) && !path.includes('.test.')) -} - -const SOURCES = Object.keys(TREES) - .flatMap((tree) => sourceFiles(tree)) - .map((path) => ({ - path: path.split('\\').join('/'), - text: readFileSync(join(MOBILE_ROOT, path), 'utf8') - })) - -function filesContaining(needle: string): string[] { - return SOURCES.filter((file) => file.text.includes(needle)) - .map((file) => file.path) - .sort() -} - -/** - * The same matches with the line each was read off, as the failure message for the rules below. - * - * A census that answers only with paths tells a reader which file is wrong and nothing about what - * in it is: the needles here are identifiers, and a file can name one in an import, a call or a - * comment. The snippet is what turns "this list moved" into the edit that moved it. - */ -function matchesOf(needle: string): string { - // Sorted by path then by line number, not as text: `:59:` sorts before `:4:` as a string, which - // reads as a file whose matches are out of order. - return [...SOURCES] - .sort((left, right) => left.path.localeCompare(right.path)) - .flatMap((file) => - file.text - .split('\n') - .flatMap((line, index) => - line.includes(needle) ? [`${file.path}:${index + 1}: ${line.trim()}`] : [] - ) - ) - .join('\n') -} - -describe('who touches the hybrid shell flag', () => { - it('reaches every shipped tree, so the absence assertions below cannot pass vacuously', () => { - const paths = SOURCES.map((file) => file.path) - expect(paths).toContain(DEFINITION) - expect(paths).toContain(FLAG_HOOK) - expect(paths).toContain(DECISION) - expect(paths).toContain(PENDING_SCREEN) - expect(paths).toContain(ROUTE) - for (const route of SWITCHED_ROUTES) { - expect(paths).toContain(route) - } - expect(paths).toContain(DEVELOPER_ROW) - expect(paths).toContain(TROUBLESHOOT_ROUTE) - expect(paths).toContain(SHELL_VIEW) - const trees = Object.keys(TREES) - for (const [tree, floor] of Object.entries(TREES)) { - expect(paths.filter((path) => path.startsWith(`${tree}/`)).length).toBeGreaterThan(floor) - } - expect(paths.filter((path) => !trees.some((tree) => path.startsWith(`${tree}/`)))).toEqual([]) - }) - - it('keeps the storage key itself in one module', () => { - expect(filesContaining(FLAG_KEY)).toEqual([DEFINITION]) - }) - - it('reads the build-time switch in one module', () => { - // `babel-preset-expo` inlines an `EXPO_PUBLIC_*` variable only where it is spelled as a - // literal member expression of `process.env`, so a second read is a second literal to keep in - // step with the workflow input — and a value copied out into a binding is not rewritten at - // all, which reads `undefined` on a device and answers native in a build that is not. - // - // Prose is not a read: the docblocks that name the variable are how a reader finds this - // module, and pinning them would make every wording change a census failure. - expect(filesContaining(BUILD_SWITCH_READ), matchesOf(BUILD_SWITCH_READ)).toEqual([DEFINITION]) - expect( - filesContaining(BUILD_SWITCH_BRACKET_READ), - matchesOf(BUILD_SWITCH_BRACKET_READ) - ).toEqual([]) - }) - - it('answers the build kind through one named function, asked by the flag and the row label', () => { - expect(filesContaining('mobileShellBuildKind'), matchesOf('mobileShellBuildKind')).toEqual( - [DEFINITION, DEVELOPER_ROW].sort() - ) - }) - - it('is read by one hook and by the developer row that writes it, and nowhere else', () => { - expect(filesContaining('loadMobileWebShellEnabled')).toEqual( - [DEFINITION, DEVELOPER_ROW, FLAG_HOOK].sort() - ) - }) - - it('is read by the shared switch decision and by nothing else', () => { - // The narrowest this has ever been, and the reason the rule below is total: a route cannot - // hold a private opinion about the flag — including about the window where it is still `null` - // — without reading it, and this is the only place that reads it. - expect( - filesContaining('useMobileWebShellEnabled'), - matchesOf('useMobileWebShellEnabled') - ).toEqual([DECISION, FLAG_HOOK].sort()) - }) - - it('reaches the switched routes through that decision and no others', () => { - // Each switched route is a screen the flag decides the renderer of, and one more is one more - // place a dark feature could turn itself on. The list grows once per domain series, in the PR - // that switches the route file to MobileWebShellScreen, and never as a side effect of anything - // else. A switched route is inert until MOBILE_WEB_PAGE_ROUTES lists it as well, so an entry - // here can land a PR ahead of that one. - expect(filesContaining('useShellSwitchDecision'), matchesOf('useShellSwitchDecision')).toEqual( - [DECISION, ROUTE, ...SWITCHED_ROUTES].sort() - ) - }) - - it('gives every one of them the same neutral state to paint while the flag is unresolved', () => { - // The rule a sixth switch would otherwise regress past. Reading the flag through the decision - // is not on its own enough: a switch that ignored `pending` and fell through to its native - // screen would satisfy the rule above and still flash native in front of a flag-on user. This - // one says every switch names the neutral screen, which is existence rather than shape — where - // it names it is the route test's business, and `shell-switch-null-flag.test.tsx` drives all - // nine through the states themselves. - expect( - filesContaining('ShellSwitchPendingScreen'), - matchesOf('ShellSwitchPendingScreen') - ).toEqual([PENDING_SCREEN, ROUTE, ...SWITCHED_ROUTES].sort()) - }) - - it('fences the build kind in one place, which the read, the hook and the row gate ask', () => { - // The test that makes a native store build unable to turn the flag on: `__DEV__`, or a binary - // built with the switch set to `ota`. The hook starts its state on it so a native release - // build never reaches the neutral state, which is the same answer `loadMobileWebShellEnabled` - // gives one render later — and two spellings of one build-kind test are two things to keep - // true, where this feature's darkness rests on exactly one. - // - // The Troubleshoot route asks the same question to decide whether to mount the toggle, which - // is the one place a user can switch an OTA build back to the native screens. Asking it rather - // than re-deriving it is why that row and the flag can never disagree about the build. - expect( - filesContaining('mobileWebShellFlagCanBeOn'), - matchesOf('mobileWebShellFlagCanBeOn') - ).toEqual([DEFINITION, FLAG_HOOK, TROUBLESHOOT_ROUTE].sort()) - }) - - it('is written only by the developer row', () => { - expect(filesContaining('saveMobileWebShellEnabled')).toEqual([DEFINITION, DEVELOPER_ROW].sort()) - }) -}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts deleted file mode 100644 index e7d8b6c00d3..00000000000 --- a/mobile/src/mobile-web-shell/mobile-web-shell-router-seam-census.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - expoRouterValueImports, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const SHELL_ROOT = import.meta.dirname - -/** - * The tree that is both halves of the handoff, which is why it needs its own census. - * - * Every other domain here is page code: a screen the shell may serve, where expo-router's own - * router posts no `navigate` and a target outside the page paints Unmatched over it. This tree is - * different — it holds the app end of the seam as well, so the rule cannot be "no router" and has - * to name which modules are the app end. - * - * It is also the tree where the gap showed: `PageRouteUnavailableScreen` shipped with a bare - * `useRouter` and nothing caught it, because the three landed censuses walk `src/session`, - * `src/files` and `src/source-control` and this tree had none. - */ - -/** - * The two modules that ARE the app end of the handoff, and so hold expo-router's own router. - * - * `MobileWebShellScreen` is what hosts the page; its `router.push` is the `onNavigate` the page's - * `navigate` notify arrives at, so routing it through the seam would hand the page's target back to - * the page. `useShellStackPop` is the same for `onNavigateBack`: it pops the native stack the shell - * was pushed onto. Neither has a `.web.*` sibling and neither can run inside the page — the page has - * no shell within it — so neither is a screen the seam exists for. - */ -const SHELL_SIDE_ROUTER_HOLDERS = ['MobileWebShellScreen.tsx', 'use-shell-stack-pop.ts'] - -/** - * The screens here that render on the page and therefore must reach the router through the seam. - * - * One today. `PageRouteUnavailableScreen` is rendered by the catch-all on the device and by its - * `.web.tsx` sibling inside the page, so a bare replace there navigates within the WebView to a - * route the page does not carry instead of leaving it. - */ -const ROUTER_HOLDERS = ['PageRouteUnavailableScreen.tsx'] - -/** - * The expo-router names this tree may import outside those two, and why none is a router. - * - * `useLocalSearchParams` reads the params of the route already mounted and `usePathname` reads - * which route that is. Neither takes a target, so neither can put a screen in front of the page. - * - * A closed list rather than a ban on `useRouter`, for the reason the session census gives: - * expo-router also exports a module-singleton `router` that navigates from a plain function, and a - * rule written against the one spelling would read that as clean. - */ -const NON_NAVIGATING_ROUTER_NAMES = ['useLocalSearchParams', 'usePathname'] - -describe('the mobile-web-shell tree reaches the router through the handoff seam', () => { - const files = productFiles(SHELL_ROOT) - - it('walks the modules it is written against, so the empty list below means something', () => { - expect(files).toEqual(expect.arrayContaining(SHELL_SIDE_ROUTER_HOLDERS)) - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - // The whole tree, not the five files that touch a router: a walk that collapsed to those would - // report an empty finding list for a domain it never read. - expect(files.length).toBeGreaterThan(60) - }) - - it('imports nothing from expo-router that can navigate, outside the two that are the seam', () => { - const offenders = files - .filter((name) => !SHELL_SIDE_ROUTER_HOLDERS.includes(name)) - .map((name) => ({ - name, - imported: expoRouterValueImports(parse(SHELL_ROOT, name)).filter( - (imported) => !NON_NAVIGATING_ROUTER_NAMES.includes(imported) - ) - })) - .filter((entry) => entry.imported.length > 0) - .map((entry) => `${entry.name} (${entry.imported.join(', ')})`) - expect(offenders).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen the page renders', () => { - expect(files.filter((name) => callsRouteHandoff(parse(SHELL_ROOT, name))).sort()).toEqual( - [...ROUTER_HOLDERS].sort() - ) - }) - - it('still holds a real router at the app end, which is what the exemption is for', () => { - // The completeness half: the rule above also passes over a tree whose two seam modules stopped - // navigating at all, which is what it would read as if someone moved them and left this list. - for (const name of SHELL_SIDE_ROUTER_HOLDERS) { - expect(expoRouterValueImports(parse(SHELL_ROOT, name)), name).toContain('useRouter') - } - }) -}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx index 2abeff8fd96..a1b81f1bdc4 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-harness.tsx @@ -68,7 +68,7 @@ export const SCREEN_SNAPSHOT = { host: { id: 'host-1', name: 'Host One', endpoint: 'ws://host-1', lastConnected: 3 } } -export const DEFAULT_ROUTE_GRANTS: readonly string[] = [ +const DEFAULT_ROUTE_GRANTS: readonly string[] = [ 'navigate', 'storage', 'externalLink', diff --git a/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts b/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts deleted file mode 100644 index a1d40ccf49a..00000000000 --- a/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * The shell serves no page older than its floor (`MOBILE_WEB_PAGE_VERSION_FLOOR`), so it keeps no - * path for one: no negotiated `accepts`/`reports`, and no WebView resized for the keyboard. The - * retired names are listed so a reintroduction goes red. - */ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = join(import.meta.dirname, '..', '..') -const SHELL_DIR = import.meta.dirname -const RETIRED = new RegExp( - [ - 'softwareKeyboardWindowInset', - 'viewShortenedBy', - 'pageReadsKeyboardInset', - 'shellPageReadsKeyboardInset', - 'shellKeyboardGeometry', - 'BRIDGE_KEYBOARD_INSET_ACCEPT', - 'BRIDGE_ROUTE_UPDATE_ACCEPT', - 'BRIDGE_SAFE_AREA_ACCEPT', - 'BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT', - 'BRIDGE_SHELL_ACCEPTS', - 'BRIDGE_MAX_PAGE_ACCEPTS', - 'pageReportsPaint', - 'pageOwnsSafeArea', - 'shellPageOwnsSafeArea', - 'shellAccepts', - 'PageReadyDeclaration', - "'keyboard-inset'", - "'route-update'", - "'safe-area-insets'", - "'page-client-identity'" - ].join('|') -) -/** The negotiated fields themselves, in code: a `ready` or `init` declaring what it takes or reports. */ -const NEGOTIATED_FIELD = /\.(accepts|reports)\b|\b(accepts|reports)\??:/ -const COMMENT = /^\s*(\/\/|\*|\/\*)/ -const KEYBOARD_SIZES_VIEW = /\b(padding|margin)?(Bottom|bottom|height|Height)\s*:[^,}\n]*keyboard/i - -/** Lines that bring back a path for an older page, or size the view from the keyboard. */ -export function olderPagePaths(source: string, inShell: boolean): number[] { - return source - .split('\n') - .flatMap((line, index) => - RETIRED.test(line) || - (inShell && - ((NEGOTIATED_FIELD.test(line) && !COMMENT.test(line)) || KEYBOARD_SIZES_VIEW.test(line))) - ? [index + 1] - : [] - ) -} - -function sources(dir: string): string[] { - return censusSourceFiles(dir).filter( - (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) - ) -} - -describe('the shell and a page older than its floor', () => { - it('finds the lines it is looking for', () => { - expect(olderPagePaths('style={{ paddingBottom: keyboardInset }}', true)).toEqual([1]) - expect(olderPagePaths("accepts.includes('keyboard-inset')", false)).toEqual([1]) - expect(olderPagePaths('if (accepts.includes(BRIDGE_SAFE_AREA_ACCEPT)) {', false)).toEqual([1]) - expect(olderPagePaths(' accepts: z.array(z.string()).optional(),', true)).toEqual([1]) - expect(olderPagePaths('return message.reports ?? []', true)).toEqual([1]) - expect(olderPagePaths('session.pageOwnsSafeArea', false)).toEqual([1]) - expect( - olderPagePaths('{ paddingTop: insets.top, paddingBottom: insets.bottom }', true) - ).toEqual([]) - expect(olderPagePaths('publishKeyboardInset(keyboardInset)', true)).toEqual([]) - // The field rule is the shell's code: elsewhere, and in prose, `accepts` is an ordinary word. - expect(olderPagePaths('gate.accepts(byteCount)', false)).toEqual([]) - expect(olderPagePaths(' // the answer is what the caller reports: a page', true)).toEqual([]) - }) - - it('keeps no path for one anywhere in the app or the page entry', () => { - const offenders = [ - ...sources(join(MOBILE_DIR, 'src')), - ...sources(join(MOBILE_DIR, 'web-entry')) - ].flatMap((file) => - olderPagePaths(readFileSync(file, 'utf8'), file.startsWith(SHELL_DIR)).map( - (line) => `${relative(MOBILE_DIR, file)}:${line}` - ) - ) - expect(offenders).toEqual([]) - }) -}) diff --git a/mobile/src/mobile-web-shell/shell-screen-route-census.test.ts b/mobile/src/mobile-web-shell/shell-screen-route-census.test.ts deleted file mode 100644 index 1626e376966..00000000000 --- a/mobile/src/mobile-web-shell/shell-screen-route-census.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -const HOST_ROUTES = join(import.meta.dirname, '..', '..', 'app', 'h', '[hostId]') -const PAGE_ROUTE_REGISTRY = join( - import.meta.dirname, - '..', - '..', - '..', - 'config', - 'scripts', - 'mobile-web-page-routes.mjs' -) - -/** - * Every switch that hands a route to the shell asks whether the route is one the page can be - * given, and asks it in one place. - * - * A route the schema refuses is dropped to `null` by `bridge-host.ts` and reaches the phone as an - * `init` naming no screen, which the page answers with "Update Orca to open this workspace" — a - * failure screen in place of the native screen sitting right behind the switch. Three routes had - * each grown their own copy of the call and two had none at all, which is the state this census - * ends: the predicate is `shellScreenRoute`, and a switch that spells it itself has a second - * spelling of a rule that can only drift from the one the page reads. - * - * The walk is over the route tree rather than a list, so a route added later is held to this - * without anyone remembering to add it here. - */ -function hostRouteFiles(directory: string = HOST_ROUTES, prefix = ''): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const name = prefix === '' ? entry.name : `${prefix}/${entry.name}` - if (entry.isDirectory()) { - return hostRouteFiles(join(directory, entry.name), name) - } - return entry.name.endsWith('.tsx') && !entry.name.includes('.test.') ? [name] : [] - }) -} - -/** - * A route file's body: the file itself, or the module its default export comes from. - * - * `[...page].tsx` is the reason: expo-router 55 reads a file's platform from the first dot of its - * stripped name, so a catch-all cannot carry a `.web.tsx` sibling under `app/` without registering - * a second route, and its body lives under `src/` where the stem is plain. Following the export - * keeps the walk derived from the tree rather than from a list beside it. - * - * Fails closed, which is the half that matters. A shape this cannot resolve is a file whose body - * it never read, and an unread body is exactly where a switch that skips `shellScreenRoute` would - * sit — so an unknown shape is named by the census rather than quietly treated as a non-switch. - */ -type RouteBody = - | { readonly kind: 'source'; readonly text: string } - | { readonly kind: 'unresolved'; readonly why: string } - -/** The module specifier a file's default export comes from, or null when the body is local. */ -function defaultExportSource(parsed: ts.SourceFile): string | null | undefined { - const importedFrom = new Map() - for (const statement of parsed.statements) { - if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { - const clause = statement.importClause - if (clause?.name !== undefined) { - importedFrom.set(clause.name.text, statement.moduleSpecifier.text) - } - const bindings = clause?.namedBindings - if (bindings !== undefined && ts.isNamedImports(bindings)) { - for (const element of bindings.elements) { - importedFrom.set(element.name.text, statement.moduleSpecifier.text) - } - } - } - } - for (const statement of parsed.statements) { - // `export { default } from 'x'`, and `export { Body as default } from 'x'`. - if ( - ts.isExportDeclaration(statement) && - statement.moduleSpecifier !== undefined && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.exportClause !== undefined && - ts.isNamedExports(statement.exportClause) && - statement.exportClause.elements.some((element) => element.name.text === 'default') - ) { - return statement.moduleSpecifier.text - } - if (ts.isExportAssignment(statement) && statement.isExportEquals !== true) { - // `export default Body` where `Body` came from an import: the body is over there. Any other - // expression — a function, a class, a local — is this file's own. - return ts.isIdentifier(statement.expression) - ? (importedFrom.get(statement.expression.text) ?? null) - : null - } - // `export default function …` / `export default class …`: the body is here. - if ( - (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && - statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === - true - ) { - return null - } - } - return undefined -} - -function bodyOf(name: string): RouteBody { - const routeFile = join(HOST_ROUTES, name) - const source = readFileSync(routeFile, 'utf8') - const specifier = defaultExportSource(parse(source)) - if (specifier === undefined) { - return { kind: 'unresolved', why: 'no default export this census recognises' } - } - if (specifier === null) { - return { kind: 'source', text: source } - } - if (!specifier.startsWith('.')) { - return { kind: 'unresolved', why: `default export comes from the package "${specifier}"` } - } - const base = resolve(dirname(routeFile), specifier) - for (const extension of ['.tsx', '.ts']) { - if (existsSync(`${base}${extension}`)) { - return { kind: 'source', text: readFileSync(`${base}${extension}`, 'utf8') } - } - } - return { kind: 'unresolved', why: `no module at "${specifier}"` } -} - -/** The body text, for the rules below; an unresolved shape is caught by its own case first. */ -function read(name: string): string { - const body = bodyOf(name) - return body.kind === 'source' ? body.text : '' -} - -/** - * The one switch that hands over a route the rule refuses, on purpose. - * - * `web.tsx` is `__DEV__`-only and its fallback is `Redirect href="/h/"`, not a native - * screen. Adopting the guard there sends a `..` deep link through that redirect to the host route, - * which this PR keeps native, so the developer lands on the host list with nothing said about why - * the page did not open. Handed over instead, the same id reaches the bridge and comes back as the - * host's own failure screen, which is the better verdict for a route whose whole purpose is to - * open the page deliberately; `mobile-web-shell-route.test.tsx` pins that by name. - * - * Exempted here rather than silently unwalked, so the exception is read when it changes. - */ -const HANDS_OVER_UNJUDGED = ['web.tsx'] - -const parse = (source: string): ts.SourceFile => - ts.createSourceFile('route.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) - -function importsNames(parsed: ts.SourceFile, module: string): string[] { - return parsed.statements.flatMap((statement) => { - if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { - return [] - } - if (!statement.moduleSpecifier.text.endsWith(module)) { - return [] - } - const bindings = statement.importClause?.namedBindings - return bindings !== undefined && ts.isNamedImports(bindings) - ? bindings.elements.map((element) => element.name.text) - : [] - }) -} - -/** - * A switch is a route file that imports the shell screen. - * - * The import rather than ` { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === name - ) { - called = true - } - ts.forEachChild(node, visit) - } - ts.forEachChild(parsed, visit) - return called -} - -describe('the switches that hand a route to the shell', () => { - const switches = hostRouteFiles().filter((name) => mountsShell(parse(read(name)))) - - it('walks the directory every registered route lives under', () => { - // The root above is written out rather than derived, so this is what ties it to the manifest: - // a page route outside `/h/[hostId]` would be a switch this census never reads. - const pathnames = [ - ...readFileSync(PAGE_ROUTE_REGISTRY, 'utf8').matchAll(/pathname: '([^']+)'/g) - ].map((match) => match[1]) - expect(pathnames.length).toBeGreaterThan(0) - expect(pathnames.filter((pathname) => !pathname.startsWith('/h/[hostId]'))).toEqual([]) - }) - - /** - * Every route file's body was read, so `switches` is a census and not a sample. - * - * A shape this cannot resolve is a body it never opened, and every rule below would read that - * file as "not a switch" — the one answer a census must never give by default. - */ - it('resolves every route file to a body, naming any shape it cannot', () => { - expect( - hostRouteFiles() - .map((name) => ({ name, body: bodyOf(name) })) - .filter((entry) => entry.body.kind === 'unresolved') - .map((entry) => `${entry.name}: ${entry.body.kind === 'unresolved' ? entry.body.why : ''}`) - ).toEqual([]) - }) - - it('walks the route tree and finds them, so the rules below cannot pass vacuously', () => { - expect(switches.sort()).toEqual([ - '[...page].tsx', - 'agent-history/[worktreeId].tsx', - 'files/[worktreeId].tsx', - 'files/preview/[worktreeId].tsx', - 'index.tsx', - 'review/[worktreeId].tsx', - 'session/[worktreeId].tsx', - 'source-control/[worktreeId].tsx', - 'tasks.tsx', - 'web.tsx' - ]) - }) - - it('asks shellScreenRoute whether the route is one the page can be given', () => { - // Imports it *and* calls it: an import the call no longer reaches is a switch that stopped - // asking while still looking like one. - expect( - switches - .filter((name) => !HANDS_OVER_UNJUDGED.includes(name)) - .filter((name) => { - const parsed = parse(read(name)) - return ( - !importsNames(parsed, 'shell-screen-route').includes('shellScreenRoute') || - !callsName(parsed, 'shellScreenRoute') - ) - }) - ).toEqual([]) - }) - - it('reads the call rather than the import, on a fixture that has only the import', () => { - // The rule the case above cannot show against the tree, every switch there calling what it - // imports: an import with no call is named. - const importOnly = parse( - "import { shellScreenRoute } from '../../../src/mobile-web-shell/shell-screen-route'\n" + - "import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'\n" + - 'export default function Route() {\n return \n}\n' - ) - expect(mountsShell(importOnly)).toBe(true) - expect(importsNames(importOnly, 'shell-screen-route')).toContain('shellScreenRoute') - expect(callsName(importOnly, 'shellScreenRoute')).toBe(false) - }) - - it('spells the rule nowhere else, so the page and the app cannot disagree about it', () => { - // The copies this census ends. `shellScreenRoute` is the one caller of the schema outside the - // bridge, and a switch that reaches for it again is writing the second spelling back. - expect(switches.filter((name) => read(name).includes('BridgeInitRouteSchema'))).toEqual([]) - // And the exemption names a switch that exists, so it cannot outlive the file it excuses. - expect(switches).toEqual(expect.arrayContaining(HANDS_OVER_UNJUDGED)) - }) -}) diff --git a/mobile/src/mobile-web-shell/shell-view-surface.test.ts b/mobile/src/mobile-web-shell/shell-view-surface.test.ts deleted file mode 100644 index 7d8817147ca..00000000000 --- a/mobile/src/mobile-web-shell/shell-view-surface.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { colors } from '../theme/mobile-theme' - -/** - * What the WebView paints before its document does, read off both shells. - * - * Neither can be proven from a JVM or a Swift unit test: it is a property of a live view. What is - * checkable is that neither shell gives the view a surface of its own, which is what makes the - * frame behind it — the screen's own, in the app's own colours — the thing on screen. - * - * The two defaults differ and both are wrong. Android's WebView paints nothing under a transparent - * background, which is fine; a WKWebView is opaque by default and paints white, so a dark app - * opening a page flashed white for the whole of the page's boot. - */ -const SHELL = join(import.meta.dirname, '..', '..', 'modules', 'orca-mobile-web-shell') - -function source(relative: string): string { - return readFileSync(join(SHELL, relative), 'utf8') -} - -describe('what a mounted view paints before the page does', () => { - it('gives the Android view no surface of its own', () => { - const kotlin = source( - 'android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt' - ) - expect(kotlin).toContain('view.setBackgroundColor(Color.TRANSPARENT)') - }) - - it('gives the iOS view none either, which is not its default', () => { - const swift = source('ios/MobileWebShellView.swift') - expect(swift).toContain('webView.isOpaque = false') - expect(swift).toContain('webView.backgroundColor = .clear') - expect(swift).toContain('webView.scrollView.backgroundColor = .clear') - }) - - it('leaves the app surface as the one colour behind a page, and it is not black', () => { - // What shows through both: the screen's own root, which is where the token is read. - const screen = readFileSync(join(import.meta.dirname, 'MobileWebShellScreen.tsx'), 'utf8') - expect(screen).toContain('backgroundColor: colors.bgBase') - expect(colors.bgBase).not.toBe('#000000') - }) -}) diff --git a/mobile/src/notifications/desktop-notification-channel.test.ts b/mobile/src/notifications/desktop-notification-channel.test.ts index 95ff41ebe35..283f3f668d4 100644 --- a/mobile/src/notifications/desktop-notification-channel.test.ts +++ b/mobile/src/notifications/desktop-notification-channel.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Notifications from 'expo-notifications' import { Platform } from 'react-native' @@ -48,15 +47,3 @@ describe('ensureDesktopNotificationChannel', () => { await expect(ensureDesktopNotificationChannel()).rejects.toThrow('no channels') }) }) - -describe('app boot', () => { - it('creates the channel at startup, not only once a socket subscribes', () => { - // A background push can be the first thing to target 'orca-desktop', and Android - // drops a notification whose channel does not exist. Asserted against the source - // because vitest only collects src/, so app/_layout.tsx has no runtime coverage. - const layout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') - - expect(layout).toContain("from '../src/notifications/desktop-notification-channel'") - expect(layout).toMatch(/^void ensureDesktopNotificationChannel\(\)\.catch\(/m) - }) -}) diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index 6f1b8a4aeec..a55d3809f5f 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { getNotificationNavigationTarget } from './notification-routing' import { @@ -7,8 +6,6 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const rootLayoutSource = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') - function navigationHarness(initialState: HostStackNavigationState | undefined) { const stateListeners = new Set<() => void>() let state = initialState @@ -100,20 +97,6 @@ describe('notification route coordination', () => { it('leaves a host-only notification as a shallow push with nothing to coordinate', () => { expect(getNotificationNavigationTarget({ hostId: 'host-1' })?.sessionTarget).toBeNull() }) - - it('routes notification taps through the coordinated transition, not a bare push', () => { - const start = rootLayoutSource.indexOf('// ─── Notification tap routing ───') - const end = rootLayoutSource.indexOf('// ─── End notification tap routing ───', start) - - // Assert the markers first: a renamed banner would otherwise slice garbage and report a - // missing call instead of the real cause. - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - - const notificationEffect = rootLayoutSource.slice(start, end) - expect(notificationEffect).toContain('openNotificationRoute(target)') - expect(notificationEffect).not.toContain('router.push(') - }) }) it('reuses the current workspace screen and targets the notification pane without pushing', () => { diff --git a/mobile/src/platform/keyboard-geometry-host-os-census.test.ts b/mobile/src/platform/keyboard-geometry-host-os-census.test.ts deleted file mode 100644 index 81b7259395d..00000000000 --- a/mobile/src/platform/keyboard-geometry-host-os-census.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Keyboard and inset arithmetic branches on the host OS, never on `Platform.OS`. - * - * The page runs as `web` while the keyboard it lifts over is the phone's, reported in that OS's own - * terms (iOS counts the home indicator). A `Platform.OS === 'ios'` there takes the Android branch on - * an iPhone page: measured on the iPhone 17 simulator, the terminal lifted 34 pt past native. - */ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) -const GEOMETRY = - /keyboardHeight|keyboardLift|keyboardInset|bottomInset|insets\.bottom|KeyboardAvoidingView|keyboard(Will|Did)(Show|Hide)/ - -const KEYBOARD_READ = - /keyboardHeight|keyboardLift|keyboardInset|useKeyboardOcclusion|useSoftKeyboard/ -const KEYBOARD_IMPORT = /from '[^']*(keyboard|inset)[^']*'/i -const KEYBOARD_FILE = /(keyboard|inset|drawer|lift)[^/]*$/i -const COMMENT = /^\s*(\/\/|\*|\/\*)/ - -/** - * `Platform.OS` reads near keyboard or inset arithmetic, or anywhere in a file that reads the keyboard, - * imports a keyboard/inset module or is named for one: a live-input reopen flag four lines from its - * keyboard height escaped the proximity rule alone. Comments are not reads. - */ -export function platformReadsInGeometry(source: string, file = ''): number[] { - const lines = source.split('\n') - const wholeFile = - KEYBOARD_READ.test(source) || KEYBOARD_IMPORT.test(source) || KEYBOARD_FILE.test(file) - return lines.flatMap((line, index) => - line.includes('Platform.OS') && - !COMMENT.test(line) && - (wholeFile || - lines.slice(Math.max(0, index - 2), index + 3).some((near) => GEOMETRY.test(near))) - ? [index + 1] - : [] - ) -} - -function files(): string[] { - return ['src', 'app'].flatMap((dir) => - censusSourceFiles(fileURLToPath(new URL(`../../${dir}/`, import.meta.url))).filter( - (file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file) && !file.endsWith('host-os.ts') - ) - ) -} - -describe('keyboard and inset arithmetic', () => { - it('finds the reads it is looking for', () => { - expect( - platformReadsInGeometry("const lift = Platform.OS === 'ios' ? keyboardHeight - 1 : 0") - ).toEqual([1]) - expect( - platformReadsInGeometry("keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}") - ).toEqual([]) - }) - - it('reads the host OS, never Platform.OS', () => { - const offenders = files().flatMap((file) => - platformReadsInGeometry(readFileSync(file, 'utf8'), file).map( - (line) => `${relative(MOBILE_DIR, file)}:${line}` - ) - ) - expect(offenders).toEqual([]) - }) -}) diff --git a/mobile/src/platform/keyboard-geometry-owner-census.test.ts b/mobile/src/platform/keyboard-geometry-owner-census.test.ts deleted file mode 100644 index b3bee07b5f4..00000000000 --- a/mobile/src/platform/keyboard-geometry-owner-census.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Who may ask React Native's `Keyboard` for geometry: the keyboard seam, and one named exception. - * - * react-native-web's `Keyboard` has no `metrics` and an `addListener` that never fires, so a page - * screen that asks it directly either throws (a fill sheet crashed the page on `metrics()`) or waits - * forever. The seam has a web sibling that answers from the window instead; everything else reads it. - */ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) -const SRC_DIR = fileURLToPath(new URL('../', import.meta.url)) - -const SEAM = 'src/platform/keyboard-occlusion.ts' -/** A one-shot "open after the keyboard hides" with a timer fallback; on the page, where the event - * never fires, the timer is what opens the sheet, so it cannot wait forever. */ -const TAB_SHEET_AFTER_HIDE = 'src/session/use-mobile-session-terminal-send-actions.ts' - -const asksKeyboard = (line: string): boolean => - line.includes('Keyboard.metrics(') || line.includes('Keyboard.addListener(') - -function callers(): string[] { - return censusSourceFiles(SRC_DIR) - .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) - .filter((file) => readFileSync(file, 'utf8').split('\n').some(asksKeyboard)) - .map((file) => relative(MOBILE_DIR, file)) - .sort() -} - -describe("React Native's Keyboard geometry", () => { - it('is read only by the keyboard seam and the tab sheet that waits for a hide', () => { - // Also the presence precondition: the seam itself must match, or the matcher is broken. - expect(callers()).toEqual([SEAM, TAB_SHEET_AFTER_HIDE].sort()) - }) -}) diff --git a/mobile/src/platform/microphone-screen-lock-census.test.ts b/mobile/src/platform/microphone-screen-lock-census.test.ts deleted file mode 100644 index 992918930e4..00000000000 --- a/mobile/src/platform/microphone-screen-lock-census.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Who may name the screen: one module, and nothing on the page. - * - * The screen a dictation holds awake is a property of the microphone, so the device module that - * opens the mic takes it and gives it back. Everything above that — the composer's five states, the - * pending-audio budget, where a transcript is routed — has no business naming it, and the page has - * no way to: the verb it used to ask through is gone, along with the tag pools, the timeouts and - * the foreground re-acquire that existed to keep the page's idea of the screen and the device's - * agreeing. - * - * A census rather than a rule in a reviewer's head, because the seam grew back twice from exactly - * the shape this forbids: a page that can ask for a wake tag acquires the bookkeeping to track what - * it asked for. Existence, not shape — any import of the package, and any mention of the verb's - * name at all, from any file but the one owner. - */ -import { readFileSync, readdirSync } from 'node:fs' -import { relative, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) - -/** The one module that may hold the screen: the device calls the microphone's capture makes. */ -const SCREEN_LOCK_OWNER = 'src/platform/native-audio-device.ts' - -/** This file names both things it forbids, so it reads every file but itself. */ -const CENSUS = 'src/platform/microphone-screen-lock-census.test.ts' - -const KEEP_AWAKE_PACKAGE = 'expo-keep-awake' - -/** Built rather than written, so the census does not contain the word it bans. */ -const RETIRED_VERB_WORD = `wake${'lock'}` - -function sourceFiles(): string[] { - const found: string[] = [] - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name) - if (entry.isDirectory()) { - walk(full) - continue - } - if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { - found.push(relative(MOBILE_DIR, full)) - } - } - } - for (const root of ['src', 'app']) { - walk(join(MOBILE_DIR, root)) - } - return found.sort() -} - -/** The line, not just the file: a census that answers "somewhere in 1,400 files" is one a reader - * has to re-run by hand before they can act on it. */ -function hits(match: (line: string) => boolean, skip: readonly string[]): string[] { - const found: string[] = [] - for (const file of sourceFiles()) { - if (skip.includes(file)) { - continue - } - const lines = readFileSync(join(MOBILE_DIR, file), 'utf8').split('\n') - for (const [index, line] of lines.entries()) { - if (match(line)) { - found.push(`${file}:${index + 1}: ${line.trim()}`) - } - } - } - return found -} - -/** An import of the package, which is a module that can hold the screen. A test's `vi.mock` of it - * is not one: the mock exists because the graph reaches the owner, which is the rule holding. */ -const importsKeepAwake = (line: string): boolean => - line.includes(`from '${KEEP_AWAKE_PACKAGE}'`) || line.includes(`require('${KEEP_AWAKE_PACKAGE}')`) - -const namesRetiredVerb = (line: string): boolean => - line.toLowerCase().includes(RETIRED_VERB_WORD.toLowerCase()) - -describe('the one module that may hold the screen awake', () => { - it('is the only one that imports the keep-awake package', () => { - expect(hits(importsKeepAwake, [SCREEN_LOCK_OWNER, CENSUS])).toEqual([]) - }) - - it('does import it, so the absence above is the rule holding and not the match missing', () => { - // The presence precondition every absence needs: the matcher finds the one file that should - // match, so a typo in the package name fails here rather than passing everywhere. - expect(hits(importsKeepAwake, [CENSUS]).map((hit) => hit.split(':')[0])).toEqual([ - SCREEN_LOCK_OWNER - ]) - }) - - it('leaves no page-facing name for the screen anywhere in the app', () => { - // The retired verb, in any casing: a grant row, a schema, a server, a tag, a mock or a comment - // pointing at a seam that no longer exists. - expect(hits(namesRetiredVerb, [CENSUS])).toEqual([]) - }) - - it('reads a tree big enough for those absences to mean something', () => { - // A walk that found nothing would pass all three rules above by having nothing to judge. - expect(sourceFiles().length).toBeGreaterThan(900) - expect(sourceFiles()).toContain(SCREEN_LOCK_OWNER) - }) -}) diff --git a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts deleted file mode 100644 index 03b5398939d..00000000000 --- a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' - -const runtimeSource = readMobileSessionRouteSource('./use-mobile-session-terminal-runtime.ts') -const nativeChatSource = readMobileSessionRouteSource( - './use-mobile-session-native-chat-dictation.ts' -) -const sendActionsSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-send-actions.ts' -) -const commandDockSource = readMobileSessionRouteSource('./MobileSessionCommandDock.tsx') -const tabApplicationSource = readMobileSessionRouteSource('./use-mobile-session-tab-application.ts') -const terminalListSource = readMobileSessionRouteSource('./use-mobile-session-terminal-list.ts') -const startupSource = readMobileSessionRouteSource('./use-mobile-session-startup.ts') -const bufferedDraftHookSource = readMobileSessionRouteSource( - '../terminal/use-buffered-terminal-drafts.ts' -) -const keyboardDismissalHookSource = readMobileSessionRouteSource( - './use-agent-send-keyboard-dismissal.ts' -) - -function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { - const start = source.indexOf(anchorStart) - expect(start).toBeGreaterThanOrEqual(0) - // Why: a duplicated start anchor would silently slice the wrong region. - expect(source.indexOf(anchorStart, start + 1)).toBe(-1) - const end = source.indexOf(anchorEnd, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end + anchorEnd.length) -} - -describe('terminal send keyboard dismissal wiring', () => { - it('gates the dismissal on the agent-session predicate', () => { - const slice = sourceSlice( - sendActionsSource, - 'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(', - 'getSendCompletionGeneration\n )' - ) - expect(slice).toContain('dismissSoftwareKeyboard') - expect(keyboardDismissalHookSource).toContain( - 'shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)' - ) - expect(keyboardDismissalHookSource).toContain( - 'origin.generation === getSendCompletionGeneration()' - ) - expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()') - expect(keyboardDismissalHookSource).toContain('return useCallback(') - expect(sendActionsSource).toContain( - "import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'" - ) - }) - - it('invalidates pending terminal sends when the focused input surface changes', () => { - const slice = sourceSlice( - nativeChatSource, - 'const getSendCompletionGeneration = useMobileSendCompletionGeneration({', - '})' - ) - expect(nativeChatSource).toContain( - 'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`' - ) - expect(slice).toContain( - 'surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])' - ) - }) - - it('dismisses after the live input submits, which is the only Enter path', () => { - // terminal-live-input.ts deliberately keeps Enter off the key map, so - // submitLiveInput is the single send seam for the live field. - const slice = sourceSlice( - sendActionsSource, - 'const submitLiveInput = useCallback(() => {', - 'useTerminalTextFieldSubmitBinding(liveInputRef, submitLiveInput)' - ) - expect(slice).toContain('const submit = handleLiveInputSubmit()') - expect(slice).toContain('generation: getSendCompletionGeneration()') - expect(slice).toContain('interaction: getLiveInteractionGeneration()') - expect(slice).toContain('sendOrigin.interaction === getLiveInteractionGeneration()') - expect(slice).toContain('dismissKeyboardAfterAgentSend(') - }) - - it('gives the live field both Enter paths and neither blur', () => { - // The page reaches submitLiveInput through the ref binding, because react-native-web withholds - // onSubmitEditing under an open composition; native reaches it through the editor action. - const slice = sourceSlice( - commandDockSource, - 'ref={bindLiveInputField}', - 'importantForAutofill="no"' - ) - expect(slice).toContain('onSubmitEditing={submitLiveInput}') - // Explicit dismissal replaces RN's blur, which stays off so a shell send - // does not drop focus. - expect(slice).toContain('blurOnSubmit={false}') - }) - - it('dismisses the buffered command send only once the write is accepted', () => { - const slice = sourceSlice( - sendActionsSource, - 'async function handleSend() {', - 'async function handleAccessoryKey(' - ) - const acceptedAt = slice.indexOf( - 'const accepted = terminalInputSend.interpret(response) === true' - ) - const restoreAt = slice.indexOf('restoreRejectedDraft()', acceptedAt) - const dismissAt = slice.indexOf('dismissKeyboardAfterAgentSend(') - const responseAt = slice.indexOf('const response = await terminalInputSend.request(') - const catchAt = slice.indexOf('} catch {') - expect(dismissAt).toBeGreaterThan(0) - expect(responseAt).toBeGreaterThan(0) - expect(acceptedAt).toBeGreaterThan(responseAt) - expect(restoreAt).toBeGreaterThan(acceptedAt) - expect(dismissAt).toBeGreaterThan(responseAt) - expect(slice).toContain( - 'const draftUnchanged =\n accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)' - ) - expect(slice).toContain('dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)') - expect(catchAt).toBeGreaterThan(0) - // Both resolved rejections and transport failures restore the raw draft. - expect(dismissAt).toBeLessThan(catchAt) - expect(slice.slice(catchAt)).not.toContain('dismissKeyboardAfterAgentSend(') - expect(slice.slice(catchAt)).toContain('restoreRejectedDraft()') - }) - - it('keeps buffered Return focused until accepted-agent dismissal runs', () => { - const slice = sourceSlice( - commandDockSource, - 'ref={bindCommandField}', - 'onSubmitEditing={() => void handleSend()}' - ) - expect(slice).toContain('blurOnSubmit={false}') - }) - - it('restores a rejected buffered draft by origin without generation fencing', () => { - const sendSlice = sourceSlice( - sendActionsSource, - 'async function handleSend() {', - 'async function handleAccessoryKey(' - ) - const originAt = sendSlice.indexOf('handle: activeHandle') - const requestAt = sendSlice.indexOf('await terminalInputSend.request(') - const restoreSlice = sourceSlice( - sendActionsSource, - 'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(', - 'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)' - ) - expect(originAt).toBeGreaterThan(0) - expect(originAt).toBeLessThan(requestAt) - expect(restoreSlice).toContain('activeHandle,\n draft') - expect(bufferedDraftHookSource).not.toContain('getSendCompletionGeneration()') - expect(bufferedDraftHookSource).toContain( - 'restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)' - ) - expect(sendSlice.match(/restoreRejectedDraft\(\)/g)).toHaveLength(2) - expect(keyboardDismissalHookSource).toContain( - 'origin.generation === getSendCompletionGeneration()' - ) - }) - - it('keeps buffered draft callbacks scoped to terminal surfaces and prunes ended tabs', () => { - expect(bufferedDraftHookSource).toContain('const handle = activeHandleRef.current') - expect(bufferedDraftHookSource).toContain('invalidateBufferedTerminalDraftRestoration(') - expect(bufferedDraftHookSource).toContain( - 'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))' - ) - expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(') - expect(runtimeSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)') - expect(tabApplicationSource).toContain( - 'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {' - ) - const routeResetSlice = sourceSlice( - startupSource, - '// Why: Expo reuses this screen across worktrees;', - 'clearDelayedActionTimers()\n }' - ) - expect(routeResetSlice).toContain('bufferedTerminalDraftState.resetDrafts()') - expect(routeResetSlice).toContain('bufferedTerminalDraftState.clearPendingRestorations()') - }) - - it('bounds buffered drafts on the terminal.list sweep, against the retained set', () => { - // The drafts record and the pending-restoration map both live as long as the - // session screen does; this one call is the only thing that bounds either. - const slice = sourceSlice( - terminalListSource, - 'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))', - 'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))' - ) - expect(slice).toContain('const retainedHandles = resolveRetainedTerminalHandles(pruneContext)') - expect(slice).toContain('bufferedTerminalDraftState.pruneDrafts(retainedHandles)') - // Not the raw list: terminal.list omits a chat-covered handle while the desktop - // graph reloads, so sweeping drafts against it erases text the user still holds. - expect(slice).not.toContain('pruneDrafts(liveHandles)') - expect(slice.match(/pruneDrafts\(/g)).toHaveLength(1) - expect(bufferedDraftHookSource).toContain( - 'setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))' - ) - expect(bufferedDraftHookSource).toContain( - 'pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)' - ) - }) - - it('leaves the accessory shortcut keys alone, Enter included', () => { - // Why: the accessory bar sits on top of the keyboard — dismissing would - // pull away the very row the user is tapping. - const slice = sourceSlice( - sendActionsSource, - 'async function handleAccessoryKey(', - 'const sendLiveTerminalInput = useCallback(' - ) - expect(slice).not.toContain('dismissKeyboardAfterAgentSend') - }) -}) diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts index 0369a609135..dcda3f3dace 100644 --- a/mobile/src/session/mobile-native-chat-noise.test.ts +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -44,6 +44,7 @@ describe('isNoiseMessage', () => { it('keeps assistant and tool turns', () => { expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + expect(isNoiseMessage(msg('system', 'Conversation interrupted'))).toBe(false) }) it('keeps a user turn that carries tool results', () => { diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts deleted file mode 100644 index a15a62b1535..00000000000 --- a/mobile/src/session/mobile-session-last-tab-close.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSourceFamily } from './mobile-session-route-source-family.test-support' - -const sessionRouteSource = readMobileSessionRouteSourceFamily() - -describe('mobile session last-tab close', () => { - it('preserves terminal identity while an empty snapshot may be transient', () => { - const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') - const end = sessionRouteSource.indexOf('const readMarkdownTab', start) - const block = sessionRouteSource.slice(start, end) - - expect(block).toContain('} else if (active) {') - expect(block).toContain('retainMissingSurfaces: result.tabs.length === 0') - }) - - it('clears stale active identity when closing leaves no tabs', () => { - const start = sessionRouteSource.indexOf('async function handleCloseSessionTab') - const end = sessionRouteSource.indexOf('const bulkCloseActions', start) - const block = sessionRouteSource.slice(start, end) - - expect(block).toContain( - 'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0' - ) - expect(block).toContain('activeSessionTabIdRef.current = null') - expect(block).toContain('activeHandleRef.current = null') - expect(block).toContain( - 'reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)' - ) - }) - - it('ignores a pending terminal handle when the host requests follow navigation', () => { - const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') - const end = sessionRouteSource.indexOf('const readMarkdownTab', start) - const block = sessionRouteSource.slice(start, end) - - const followsHost = block.indexOf("const followsHost = result.navigationIntent === 'follow'") - const pendingHandle = block.indexOf('const pendingActiveTerminalHandle = followsHost') - - expect(followsHost).toBeGreaterThanOrEqual(0) - expect(pendingHandle).toBeGreaterThan(followsHost) - expect(block.slice(pendingHandle, pendingHandle + 150)).toContain('? null') - }) -}) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts deleted file mode 100644 index 7c0ac36e453..00000000000 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' -import { MOBILE_SESSION_ROUTE_SOURCE_FILES } from './mobile-session-route-source-family.test-support' - -const SESSION_FILES = MOBILE_SESSION_ROUTE_SOURCE_FILES -/** The function the route mounts, which C7.7 moved out of the route file and into a component. */ -const ROOT_COMPONENT = 'MobileSessionRouteScreen' -const LOGIC_EXPANSION_NAMES = new Set([ - 'useMobileSessionController', - 'useMobileSessionFoundation', - 'useMobileSessionScreenState', - 'useMobileSessionTerminalRuntime', - 'useMobileSessionFeedbackCapabilities', - 'useMobileSessionNativeChatDictation', - 'useMobileSessionTerminalSubscriptionFoundation', - 'useMobileSessionTerminalSubscription', - 'useMobileSessionTerminalStreamDisplay', - 'useMobileSessionTerminalList', - 'useMobileSessionTabApplication', - 'useMobileSessionDocumentReaders', - 'useMobileSessionDiffComments', - 'useMobileSessionMarkdownActions', - 'useMobileSessionTabReconciliation', - 'useMobileSessionLifecycle', - 'useMobileSessionKeyboardState', - 'useMobileSessionStartup', - 'useMobileSessionPreferenceFocus', - 'useMobileSessionTabSwitching', - 'useMobileSessionTerminalWebview', - 'useMobileSessionTerminalSendActions', - 'useMobileSessionFileActions', - 'useMobileSessionTerminalInput', - 'useMobileSessionAccessorySelection', - 'useMobileSessionAttachments', - 'useMobileSessionTerminalCreateActions', - 'useMobileSessionContentCreateActions', - 'useMobileSessionCloseActions', - 'useMobileSessionBulkClose', - 'useMobileSessionPresentation', - 'useMobileSessionPanelRouteActions' -]) -const SURFACE_EXPANSION_NAMES = new Set([ - 'MobileSessionSurface', - 'MobileSessionHeader', - 'MobileSessionContentRow', - 'MobileSessionActiveContent', - 'MobileSessionCommandDock', - 'MobileSessionSheets' -]) -const CONTENT_COMPONENT_NAMES = ['MarkdownReader', 'DiffLineRow', 'FileReader'] as const -const HOST_COMPONENT_NAMES = new Set([ - 'ActivityIndicator', - 'Animated.View', - 'FlatList', - 'Image', - 'Pressable', - 'SafeAreaView', - 'ScrollView', - 'Text', - 'TextInput', - 'View' -]) - -// Refreshed by C7.2: five clipboard hooks joined the expanded route, which is the whole of the +5 — -// a writer in the diff-note, Markdown and selection actions, a reader in the selection actions and -// the attachment probe. The screen's other four clipboard sites (the terminal's paste, the sheets, -// the quick-command row, the diff-review send) sit outside the walk from `MobileSessionRouteScreen` -// and so do not move this pin. The copy-path sheet also gained the failure toast the other two had. -// Refreshed for `reportDictationFailure`, the whole of that +1: the composer's two dictation -// failure handlers were one policy written twice, and only `onError`'s copy knew about the setup -// sheet, so a refused start showed the desktop's own `voice_dictation_disabled` as a toast. -// -// Refreshed again for the page's keyboard: the screen's own `Keyboard.addListener` pair became the -// `useSoftKeyboard` seam, because react-native-web never fires those events and the live input row -// laid itself out under the IME. +2 hooks (the seam, and the one effect split into a visibility one -// and a height one), +1 effect, -2 registrations and -2 removals for the listener pair, and -6 -// strings: the four event names and the two `'ios'` guards that chose between them. Re-recorded -// against the merged tree, since neither side's hash covers the other's change. -// -// Refreshed for the terminal fields' submit seam: react-native-web withholds `onSubmitEditing` -// when the Enter keydown reports an open composition, which is a soft keyboard's normal mid-word -// state, so both of the dock's fields also bind the page's own line-break signal. +4 hooks and +2 -// callbacks in the send actions — `submitLiveInput` and `submitBufferedDraft`, each with a -// `useTerminalTextFieldSubmitBinding` beside it — and both fields moved their ref, the live one -// its submit handler too. -// Moved once more, -1 hook and -1 callback: `submitBufferedDraft` stopped being a `useCallback`. -// Memoizing it on `[]` froze the per-render `handleSend` it reads, whose guard needs a `client` -// and an `activeHandle` the first render does not have, so the page's line-break submit could -// never pass it. Found by pullfrog on #22300. -// Moved, count unchanged, when the Markdown actions' Back `useEffect` became `useBackClaim`, the -// seam that also claims the key on the page while a draft is dirty. -// Refreshed when the first subscribe began sizing itself from the document's reported cell box: -// the first-subscribe mark ref and `measureViewportOnce` are gone (hooks 282 → 280, callbacks 79 → 78). -// Again when xterm became the only cell measurer: `useTerminalCellBoxRefit` is gone, the frame's -// width ref and `handleTerminalFrameLayout` subscribe a known box on layout (hooks 282, callbacks 79), -// and the pane's `onCellBoxChange` goes to the viewport refit. -// Again when one frame ref replaced the height ref, width ref and width state (hooks 280). -// Again when one `notifyTerminalFrame` took the frame's layout (hooks 281, callbacks 80). -const HEAD_MAIN_HOOK_SHA256 = '004b011722b17ac82c96f0b3c8e303d39b2431a216424e9b86a1ee6a4896f23e' -// Moved when the prompt-cancel flag became one structured-session host support object (main). -// Re-recorded against the merged tree. Again when the frame-layout and cell-box-change callbacks -// named the refs they read in their dependency lists (react-doctor). -// Again when the frame's width and height became one `terminalFrameRef`. -// Again when the frame's layout moved into `notifyTerminalFrame`. -const HEAD_HOOK_BINDING_SHA256 = 'c1bcb859202aaf8d612d3023cb4d895719da513879c61b01545a249a0bda662b' -// Moved when `notifyTerminalFrame` joined and `handleTerminalFrameLayout` became `subscribeIntendedActiveTerminal`. -const HEAD_CALLBACK_IDENTITY_SHA256 = - '9c966301b373c11b27359ef1388c593b638c7f6831186d5c5321553504183e07' -// Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends -// and repo reads inside them now name their `RpcOperation` instead of the raw `sendRequest` port. -// Refreshed in step 6 for the gesture flush, whose `terminal.send` became `terminalInputSend` and -// whose accepted-check became that operation's own verdict, then again when that check was spelled -// `=== true` to match the other four sites reading the same verdict. Refreshed in step 7 for the -// reply casts the checked readers made unnecessary — the markdown tab doc, the worktree record's -// `diffComments` and the browser tab's page id are typed by their schemas now. Refreshed once more -// on the merge, for the display-mode toggle whose send became `terminalDisplayModeSet`. Refreshed -// for the files domain's step 7, which retired the markdown disk fallback's `{ content, truncated, -// byteLength }` cast: the preview reader checks the content and salvages the flag, so `readMarkdownTab` -// reads `fallback.value` directly. The dictation-mode refresh is main's own body again — it forwards -// whatever mode the reply carried, so an absent one leaves the mic as inert as main left it. -// Refreshed on the merge of C7.2 and C7.3, which moved this pin from both sides: the terminal -// subscribe now carries the snapshot byte budget its transport imposes, nothing on a phone and the -// frame cap inside the shell's page, and the Markdown copy action gained the failure branch that -// answers a refused write. Re-recorded against the merged tree, since neither side's hash covers -// the other's body. The hook and string counts are C7.2's and stand. -// Refreshed once more for the two dictation failure handlers, which now both call -// `reportDictationFailure` instead of each choosing between the setup sheet and a toast. -// Moved when the metrics handler compared every field, the row pitch included (main). -// Again when the subscribe waited for web-ready and `handleTerminalWebReady` lost `documentHasInit`. -// Again when the subscribe's inits began carrying the laid-out terminal frame, and its resize too. -// Again when those grids read the one frame ref. -// Again when the subscribe sized its viewport inline instead of through a helper. -// Again when the document took the hold rule and the subscribe stopped holding its grid. -// Again when an init took one options object. -// Again when the frame's layout became one `notifyTerminalFrame`. -// Again when the init option took the message's name, `initialData`. -// Again when the document readers mapped refusal codes through one function and kept truncation. -const HEAD_CALLBACK_BODY_SHA256 = 'ff818790399c8532ead38d047d072caf715070b21311538932da2dc811312b06' -// Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and -// the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed -// again when the reporter took the reply and interpreted it itself, retiring the hand-built -// refusal the timer site passed when it had no reply at all. Refreshed once more for the -// last-visited-worktree effect, whose bare store write became the one writer of that key, so the -// hybrid shell's page mirror sees it as it is written rather than one `init` later. Refreshed for -// the diff-comments effect, which now catches the loader's rejection. Count unchanged. -// Moved again by the keyboard seam above, which is the +1 effect. -// -1 effect for the Markdown actions' Back registration, which is `useBackClaim`'s own now. -// Moved by the capability probe setting that host support object. -const HEAD_EFFECT_SHA256 = '9b045a547ed269acf95db16cc87e33a9035a20c6888fd30e0363e58bb6b7d883' -const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' -// Same pin for the 12 bodies that sit in nested functions rather than callbacks, moved by the same -// rewrite of those send and read expressions. Count unchanged. Refreshed again in step 6 for -// `handleClearTerminal`, whose send became `terminalBufferClear`, in step 7 for the browser tab -// create, whose `{ browserPageId?: string }` cast its schema now carries, and once more for -// `handleCreateTerminal`, whose send became `sessionTabCreateTerminal` and whose `response.ok` -// branch became that operation's own throw-the-host-message acceptance. Refreshed for negotiated -// optimistic placement, which defers to legacy host snapshots when ownership paths disagree. -const HEAD_NESTED_FUNCTION_SHA256 = - '923b5ea7fe3330cbd98213b72736bf1f653115ddb5492cb8eb8306d8ca4f28e8' -// -1 registration and -1 removal: the Markdown actions' `BackHandler` pair is `useBackClaim`'s. -const HEAD_NATIVE_REGISTRATION_SHA256 = - '87d4599f475575131d4d5daa20f0dac579ca6c829353cbb654206ea6965dadae' -const HEAD_NATIVE_REMOVAL_SHA256 = - '089bebbc1e4f2e68c5dbaf189bb605d8d5bb247ef5ca52b9db08cc2661afc097' -const HEAD_TIMER_CREATION_SHA256 = - '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' -const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' -// Six method literals fewer than before step 6: `terminal.send` and `terminal.clearBuffer` went -// first, then `worktree.activate` twice, `session.tabs.createTerminal` and -// `terminal.setDisplayMode`. Each is now fixed at its operation's definition instead of being -// spelled at the call site. Two literals more across C7.2, both of them the toast a refused write -// now shows: "Couldn't copy path" when the sheets moved onto the clipboard seam, taking the count -// from 532 to 533, and "Couldn't copy" when the Markdown copy action gained the failure branch the -// other copy paths already had, taking it to 534. -// -// Two more across C7.7, 534 -> 536: `'web'`, the platform guard the Markdown actions' -// `BackHandler` registration gained so it stops logging on the page, and `"button"`, the -// accessibility role the header's Back control gained so the page serves it by name. Neither is a -// behaviour change on a phone. The same `'web'` moves the effect hash, and `"button"` the host-JSX -// hash. The effect hash moved a second time and back: the diff-notes loader's uncaught rejection -// was caught and then reverted, because the fix moves `matrix-session.diff-notes-worktree.show-1` -// and a golden is a review event — so this hash is the one the uncaught `void` call produces. -// -// 536 -> 530 for the keyboard seam above: the four event names and the two `'ios'` guards left -// with the listener pair. -// -// The host-JSX hash moved for the header's reconnect tap, which now also checks the re-dial exists: -// the page's provider hands out none. Nothing a phone renders or does changed. -// -// 530 -> 529: `'hardwareBackPress'` left with the Markdown actions' registration for `useBackClaim`. -// -// 529 -> 530, and the host-JSX hash: `key="terminal-frame"`, so the page's frame mounts with its -// onLayout rather than reusing the loading View. Native measured 47 rows before and after: its -// frame reported either way, and its window is its frame, so both measure paths agree there. -// -// 531 -> 530, and the host-JSX and style hashes: the key left, and one `contentFrame` View wraps -// every branch and carries the frame's onLayout, so the page's frame mounts with it. The page measured -// 47 rows before and after; native was measured only on main's bundle (47), and the wrapper is a -// flex:1 View around the same flex:1 frame, so its box is the frame's. -// Count unchanged: `'frame-not-laid-out'`, the gate awaiting the frame's first layout, replaced `'measuring-viewport'`. -// 530 -> 532, and the host-JSX hash: the tab bar and the accessory bar take a ref that gives the -// page `keyboardShouldPersistTaps` ('handled', 'always'), which react-native-web ignores. Natively -// the ref is undefined. 532 -> 531: the live input's reopen flag reads the host OS, not an 'android' literal. -// 531 -> 529: the markdown status line moved to `markdownReaderStatusText`. -const HEAD_RUNTIME_STRING_SHA256 = - '4ab2f316f60c234480615136c02273675543f24d653eb76a62b76f6bc986d985' -// Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now; -// their keyboard type and remount key read the host OS. -// Moved again when the terminal frame kept its laid-out width unrounded, for every fit. -// Again when the frame's onLayout wrote one frame ref and notified a new width imperatively. -// Again when the frame's first laid-out layout alone subscribes a held-back document. -// Again when the frame's onLayout made one `notifyTerminalFrame` call. -const HEAD_HOST_JSX_SHA256 = 'f71b1ee495f67a6e389abf597948ffdff1f9a4a10cde6abefd2611ea4dd94245' -const HEAD_LEAF_JSX_SHA256 = '62eb05c6e2ac0be6d553a141fc8aa1641fcb0c678777d5d539f490aab8648417' -const HEAD_STYLE_REFERENCE_SHA256 = - '56a005a1f65b30c11092e3422caef67810e1ec50f66fdd06471c370138b1eeb6' -const HEAD_IDENTITY_FIELD_SHA256 = - '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' -const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' -// Moved when structured-session features became one helper call; the capability strings it reads -// are pinned by that helper's own test. -const HEAD_CAPABILITY_SHA256 = 'ec1159d6e726383bf7121e9c642657fee5b4ebec049cb44ba132a303bb9a61e2' - -type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } -type HookFacts = { - bindings: string[] - callbackBodies: string[] - callbacks: string[] - effects: string[] - hooks: string[] -} - -const printer = ts.createPrinter({ removeComments: true }) -const sourceFiles = new Map() - -function parse(relativePath: string): ts.SourceFile { - const cached = sourceFiles.get(relativePath) - if (cached) { - return cached - } - const filePath = fileURLToPath(new URL(relativePath, import.meta.url)) - const sourceFile = ts.createSourceFile( - relativePath, - readFileSync(filePath, 'utf8'), - ts.ScriptTarget.Latest, - true, - relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - sourceFiles.set(relativePath, sourceFile) - return sourceFile -} - -function canonical(node: ts.Node, sourceFile: ts.SourceFile): string { - return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile).replace(/\s+/g, '') -} - -function hash(values: readonly string[]): string { - return createHash('sha256').update(values.join('\n')).digest('hex') -} - -function readDefinitions(): Map { - const definitions = new Map() - for (const relativePath of SESSION_FILES) { - const sourceFile = parse(relativePath) - const visit = (node: ts.Node): void => { - if (ts.isFunctionDeclaration(node) && node.name) { - definitions.set(node.name.text, { declaration: node, sourceFile }) - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - } - return definitions -} - -function visitLogicalFunction( - name: string, - definitions: ReadonlyMap, - onNode: (node: ts.Node, sourceFile: ts.SourceFile) => void, - active = new Set() -): void { - const definition = definitions.get(name) - if (!definition?.declaration.body) { - throw new Error(`Missing session function: ${name}`) - } - if (active.has(name)) { - throw new Error(`Recursive session function: ${name}`) - } - const nextActive = new Set(active).add(name) - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - LOGIC_EXPANSION_NAMES.has(node.expression.text) - ) { - visitLogicalFunction(node.expression.text, definitions, onNode, nextActive) - return - } - onNode(node, definition.sourceFile) - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) -} - -function readHookFacts(name: string, definitions: ReadonlyMap): HookFacts { - const facts: HookFacts = { - bindings: [], - callbackBodies: [], - callbacks: [], - effects: [], - hooks: [] - } - visitLogicalFunction(name, definitions, (node, sourceFile) => { - if ( - !ts.isCallExpression(node) || - !ts.isIdentifier(node.expression) || - !/^use[A-Z]/.test(node.expression.text) - ) { - return - } - const hookName = node.expression.text - facts.hooks.push(hookName) - const owner = ts.isVariableDeclaration(node.parent) - ? node.parent.name.getText(sourceFile) - : ts.isExpressionStatement(node.parent) - ? '' - : ts.isCallExpression(node.parent) && ts.isIdentifier(node.parent.expression) - ? `` - : '' - const lastArgument = node.arguments.at(-1) - const dependencies = - lastArgument && ts.isArrayLiteralExpression(lastArgument) - ? canonical(lastArgument, sourceFile) - : '' - facts.bindings.push(`${hookName}|${owner}|${dependencies}`) - if (hookName === 'useCallback') { - facts.callbacks.push(`${owner}|${dependencies}`) - facts.callbackBodies.push( - `${owner}|${canonical(node.arguments[0], sourceFile)}|${dependencies}` - ) - } - if (hookName === 'useEffect') { - facts.effects.push(`${canonical(node.arguments[0], sourceFile)}|${dependencies}`) - } - }) - return facts -} - -function readNestedFunctions(definitions: ReadonlyMap): string[] { - const functions: string[] = [] - const visitDefinition = (name: string, active: ReadonlySet): void => { - const definition = definitions.get(name) - if (!definition?.declaration.body || active.has(name)) { - throw new Error(`Invalid nested-function stage: ${name}`) - } - const nextActive = new Set(active).add(name) - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - LOGIC_EXPANSION_NAMES.has(node.expression.text) - ) { - visitDefinition(node.expression.text, nextActive) - return - } - if (ts.isFunctionDeclaration(node) && node.name) { - functions.push(`${node.name.text}|${canonical(node, definition.sourceFile)}`) - return - } - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) - } - visitDefinition(ROOT_COMPONENT, new Set()) - return functions -} - -function readNativeAndTimerFacts(definitions: ReadonlyMap): { - cleanups: string[] - creations: string[] - registrations: string[] - removals: string[] -} { - const registrations: string[] = [] - const removals: string[] = [] - const creations: string[] = [] - const cleanups: string[] = [] - const collect = (node: ts.Node, sourceFile: ts.SourceFile): void => { - if (!ts.isCallExpression(node)) { - return - } - if (ts.isPropertyAccessExpression(node.expression)) { - const receiver = node.expression.expression.getText(sourceFile) - const method = node.expression.name.text - if ( - ['BackHandler', 'AppState', 'Keyboard'].includes(receiver) && - ['addEventListener', 'addListener'].includes(method) - ) { - registrations.push(canonical(node, sourceFile)) - } - if (method === 'remove') { - removals.push(canonical(node, sourceFile)) - } - } - if (ts.isIdentifier(node.expression)) { - if (['setTimeout', 'setInterval', 'requestAnimationFrame'].includes(node.expression.text)) { - creations.push(canonical(node, sourceFile)) - } - if ( - ['clearTimeout', 'clearInterval', 'cancelAnimationFrame'].includes(node.expression.text) - ) { - cleanups.push(canonical(node, sourceFile)) - } - } - } - visitLogicalFunction('FileReader', definitions, collect) - visitLogicalFunction(ROOT_COMPONENT, definitions, collect) - return { cleanups, creations, registrations, removals } -} - -function isRuntimeNode(node: ts.Node): boolean { - for (let parent = node.parent; parent; parent = parent.parent) { - if ( - ts.isImportDeclaration(parent) || - ts.isExportDeclaration(parent) || - ts.isImportTypeNode(parent) || - ts.isTypeNode(parent) - ) { - return false - } - } - return true -} - -function readRuntimeStrings(): string[] { - const values: string[] = [] - for (const relativePath of SESSION_FILES) { - const visit = (node: ts.Node): void => { - if (isRuntimeNode(node)) { - if ( - ts.isStringLiteral(node) || - ts.isNoSubstitutionTemplateLiteral(node) || - ts.isTemplateHead(node) || - ts.isTemplateMiddle(node) || - ts.isTemplateTail(node) - ) { - values.push(node.text) - } - if (ts.isJsxText(node) && node.text.trim()) { - values.push(node.text.replace(/\s+/g, ' ').trim()) - } - } - ts.forEachChild(node, visit) - } - visit(parse(relativePath)) - } - return values.sort() -} - -function readJsxFacts(definitions: ReadonlyMap): { - host: string[] - leaf: string[] - styleReferences: string[] -} { - const host: string[] = [] - const leaf: string[] = [] - const active = new Set() - const visitDefinition = (name: string): void => { - const definition = definitions.get(name) - if (!definition?.declaration.body || active.has(name)) { - throw new Error(`Invalid JSX stage: ${name}`) - } - active.add(name) - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - LOGIC_EXPANSION_NAMES.has(node.expression.text) - ) { - visitDefinition(node.expression.text) - return - } - if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { - const opening = ts.isJsxElement(node) ? node.openingElement : node - const tagName = opening.tagName.getText(definition.sourceFile) - if (SURFACE_EXPANSION_NAMES.has(tagName)) { - visitDefinition(tagName) - return - } - const attributes = opening.attributes.properties - .map((attribute) => { - if (ts.isJsxSpreadAttribute(attribute)) { - return `...${canonical(attribute.expression, definition.sourceFile)}` - } - const attributeName = attribute.name.getText(definition.sourceFile) - if (!attribute.initializer) { - return attributeName - } - if (ts.isStringLiteral(attribute.initializer)) { - return `${attributeName}=${JSON.stringify(attribute.initializer.text)}` - } - return `${attributeName}=${ - attribute.initializer.expression - ? canonical(attribute.initializer.expression, definition.sourceFile) - : '' - }` - }) - .join(',') - ;(HOST_COMPONENT_NAMES.has(tagName) ? host : leaf).push(`${tagName}|${attributes}`) - for (const attribute of opening.attributes.properties) { - ts.forEachChild(attribute, visit) - } - if (ts.isJsxElement(node)) { - for (const child of node.children) { - visit(child) - } - } - return - } - if (ts.isJsxFragment(node)) { - for (const child of node.children) { - visit(child) - } - return - } - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) - active.delete(name) - } - for (const name of CONTENT_COMPONENT_NAMES) { - visitDefinition(name) - } - visitDefinition(ROOT_COMPONENT) - const styleReferences: string[] = [] - for (const record of [...host, ...leaf]) { - for (const match of record.matchAll(/styles\.([A-Za-z0-9_]+)/g)) { - styleReferences.push(match[1]) - } - } - return { host, leaf, styleReferences } -} - -function readCompatibilityFacts(definitions: ReadonlyMap): { - capabilities: string[] - identityFields: string[] - navigation: string[] -} { - const capabilities: string[] = [] - const identityFields: string[] = [] - const navigation: string[] = [] - visitLogicalFunction(ROOT_COMPONENT, definitions, (node, sourceFile) => { - if (!isRuntimeNode(node)) { - return - } - if (ts.isPropertyAssignment(node)) { - const name = node.name.getText(sourceFile) - if (['notifyClients', 'deviceToken', 'clientId'].includes(name)) { - identityFields.push(`${name}|${canonical(node.initializer, sourceFile)}`) - } - if ( - name === 'client' && - ts.isObjectLiteralExpression(node.initializer) && - node.initializer.properties.some( - (property) => property.name?.getText(sourceFile) === 'id' - ) && - node.initializer.properties.some( - (property) => property.name?.getText(sourceFile) === 'type' - ) - ) { - identityFields.push(`client|${canonical(node.initializer, sourceFile)}`) - } - } - if (!ts.isCallExpression(node)) { - return - } - if ( - ts.isPropertyAccessExpression(node.expression) && - node.expression.expression.getText(sourceFile) === 'router' && - ['push', 'replace', 'back'].includes(node.expression.name.text) - ) { - navigation.push(canonical(node, sourceFile)) - } - const callName = ts.isIdentifier(node.expression) - ? node.expression.text - : ts.isPropertyAccessExpression(node.expression) - ? node.expression.name.text - : '' - const callText = canonical(node, sourceFile) - if ( - [ - 'startRuntimeCapabilityProbe', - 'supportsMobileQuickCommands', - 'structuredAgentSessionHostSupport' - ].includes(callName) || - (callName === 'includes' && callText.includes('capabilities.includes')) - ) { - capabilities.push(callText) - } - }) - return { capabilities, identityFields, navigation } -} - -describe('mobile session route extraction parity', () => { - it('preserves hooks, callbacks, effects, and nested action bodies', () => { - const definitions = readDefinitions() - const main = readHookFacts(ROOT_COMPONENT, definitions) - const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( - (name) => readHookFacts(name, definitions).bindings - ) - expect(main.hooks).toHaveLength(281) - expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) - expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) - expect(main.callbacks).toHaveLength(80) - expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) - expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) - expect(main.effects).toHaveLength(24) - expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256) - expect(contentBindings).toHaveLength(14) - expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256) - const nestedFunctions = readNestedFunctions(definitions) - expect(nestedFunctions).toHaveLength(12) - expect(hash(nestedFunctions)).toBe(HEAD_NESTED_FUNCTION_SHA256) - }) - - it('preserves native listeners, timers, identity payloads, and compatibility gates', () => { - const definitions = readDefinitions() - const native = readNativeAndTimerFacts(definitions) - expect(native.registrations).toHaveLength(4) - expect(hash(native.registrations)).toBe(HEAD_NATIVE_REGISTRATION_SHA256) - expect(native.removals).toHaveLength(6) - expect(hash(native.removals)).toBe(HEAD_NATIVE_REMOVAL_SHA256) - expect(native.creations.filter((fact) => fact.startsWith('setTimeout'))).toHaveLength(7) - expect(native.creations.filter((fact) => fact.startsWith('setInterval'))).toHaveLength(1) - expect( - native.creations.filter((fact) => fact.startsWith('requestAnimationFrame')) - ).toHaveLength(1) - expect(hash(native.creations)).toBe(HEAD_TIMER_CREATION_SHA256) - expect(native.cleanups.filter((fact) => fact.startsWith('clearTimeout'))).toHaveLength(11) - expect(native.cleanups.filter((fact) => fact.startsWith('clearInterval'))).toHaveLength(1) - expect(native.cleanups.filter((fact) => fact.startsWith('cancelAnimationFrame'))).toHaveLength( - 1 - ) - expect(hash(native.cleanups)).toBe(HEAD_TIMER_CLEANUP_SHA256) - const compatibility = readCompatibilityFacts(definitions) - expect(compatibility.identityFields).toHaveLength(14) - expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256) - expect(compatibility.navigation).toHaveLength(6) - expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256) - expect(compatibility.capabilities).toHaveLength(6) - expect(hash(compatibility.capabilities)).toBe(HEAD_CAPABILITY_SHA256) - }) - - it('preserves runtime strings, styles, and the expanded JSX tree', () => { - const strings = readRuntimeStrings() - expect(strings).toHaveLength(529) - expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) - const jsx = readJsxFacts(readDefinitions()) - expect(jsx.host).toHaveLength(125) - expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) - expect(jsx.leaf).toHaveLength(61) - expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) - expect(jsx.styleReferences).toHaveLength(173) - expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256) - }) -}) diff --git a/mobile/src/session/mobile-session-route-source-family.test-support.ts b/mobile/src/session/mobile-session-route-source-family.test-support.ts deleted file mode 100644 index 23ae6fb348e..00000000000 --- a/mobile/src/session/mobile-session-route-source-family.test-support.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' - -/** - * The session screen as one source family, rooted at the component the route mounts. - * - * `MobileSessionRouteScreen.tsx` and not the route file: C7.7 made that file a flag switch, whose - * business is which of the two screens to render and not what the session screen does. Walking it - * would pin the switch's own params and literals into an extraction parity hash that is about the - * screen, and the root function this family is read from is the one that calls the controller. - */ -export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [ - './MobileSessionRouteScreen.tsx', - './use-mobile-session-controller.ts', - './use-mobile-session-foundation.ts', - './use-mobile-session-screen-state.ts', - './use-mobile-session-terminal-runtime.ts', - './use-mobile-session-feedback-capabilities.ts', - './use-mobile-session-native-chat-dictation.ts', - './use-mobile-session-terminal-subscription-foundation.ts', - './use-mobile-session-terminal-subscription.ts', - './use-mobile-session-terminal-stream-display.ts', - './use-mobile-session-terminal-list.ts', - './use-mobile-session-tab-application.ts', - './use-mobile-session-document-readers.ts', - './use-mobile-session-diff-comments.ts', - './use-mobile-session-markdown-actions.ts', - './use-mobile-session-tab-reconciliation.ts', - './use-mobile-session-lifecycle.ts', - './use-mobile-session-keyboard-state.ts', - './use-mobile-session-startup.ts', - './use-mobile-session-preference-focus.ts', - './use-mobile-session-tab-switching.ts', - './use-mobile-session-terminal-webview.ts', - './use-mobile-session-terminal-send-actions.ts', - './use-mobile-session-file-actions.ts', - './use-mobile-session-terminal-input.ts', - './use-mobile-session-accessory-selection.ts', - './use-mobile-session-attachments.ts', - './use-mobile-session-terminal-create-actions.ts', - './use-mobile-session-content-create-actions.ts', - './use-mobile-session-close-actions.ts', - './use-mobile-session-bulk-close.ts', - './use-mobile-session-presentation.ts', - './use-mobile-session-panel-route-actions.tsx', - './MobileSessionMarkdownReader.tsx', - './MobileSessionDiffLineRow.tsx', - './MobileSessionFileReader.tsx', - './MobileSessionSurface.tsx', - './MobileSessionHeader.tsx', - './MobileSessionContentRow.tsx', - './MobileSessionActiveContent.tsx', - './MobileSessionCommandDock.tsx', - './MobileSessionSheets.tsx' -] as const - -export function readMobileSessionRouteSource(relativePath: string): string { - return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8') -} - -export function readMobileSessionRouteSourceFamily( - relativePaths: readonly string[] = MOBILE_SESSION_ROUTE_SOURCE_FILES -): string { - return relativePaths - .map( - (relativePath) => `// Source: ${relativePath}\n${readMobileSessionRouteSource(relativePath)}` - ) - .join('\n') -} diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 33872e0271f..4cd040a061a 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { mobileSessionRouteTarget } from './mobile-session-route' import { @@ -7,8 +6,6 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') - function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() let state = initialState @@ -95,20 +92,4 @@ describe('mobile session route', () => { payload: target }) }) - - it('routes the home Resume card through the cold-navigator-safe transition', () => { - expect(homeSource).toContain('onOpenResume={openResume}') - - // The tap handler itself must go through the coordinated transition; its only - // direct push is the shallow noticed host-index route for a proven-missing target. - const handlerStart = homeSource.indexOf('const openResume = useCallback(') - const handlerEnd = homeSource.indexOf('[data.router, openMobileSession]', handlerStart) - expect(handlerStart).toBeGreaterThanOrEqual(0) - expect(handlerEnd).toBeGreaterThan(handlerStart) - - const openResume = homeSource.slice(handlerStart, handlerEnd) - expect(openResume).toContain('openMobileSession({') - expect(openResume.match(/router\.push\(/g)).toHaveLength(1) - expect(openResume).toContain('data.router.push(hostRouteWithNotice(') - }) }) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts deleted file mode 100644 index b457164beed..00000000000 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - readMobileSessionRouteSource, - readMobileSessionRouteSourceFamily -} from './mobile-session-route-source-family.test-support' - -const source = readMobileSessionRouteSourceFamily() -const startupSource = readMobileSessionRouteSource('./use-mobile-session-startup.ts') -const tabReconciliationSource = readMobileSessionRouteSource( - './use-mobile-session-tab-reconciliation.ts' -) -const bulkCloseSource = readMobileSessionRouteSource('./use-mobile-session-bulk-close.ts') -const presentationSource = readMobileSessionRouteSource('./use-mobile-session-presentation.ts') -const tabSwitchingSource = readMobileSessionRouteSource('./use-mobile-session-tab-switching.ts') -const sheetsSource = readMobileSessionRouteSource('./MobileSessionSheets.tsx') -const reconciliationHookSource = readMobileSessionRouteSource( - './use-mobile-session-tabs-reconciliation.ts' -) -const terminalInventoryRecoverySource = readMobileSessionRouteSource( - './use-mobile-terminal-inventory-recovery.ts' -) -const terminalSubscriptionSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-subscription.ts' -) -const terminalListSource = readMobileSessionRouteSource('./use-mobile-session-terminal-list.ts') -const tabReconciliationOwnerSource = readMobileSessionRouteSource( - './use-mobile-session-tab-reconciliation.ts' -) -const autoCreateHookSource = readMobileSessionRouteSource( - './use-initial-session-terminal-autocreate.ts' -) -const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts') -const terminalRuntimeSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-runtime.ts' -) -const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource( - './use-mobile-session-terminal-subscription.ts' -) -const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts') - -function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string { - const start = targetSource.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = targetSource.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return targetSource.slice(start, end) -} - -describe('mobile session startup', () => { - it('auto-creates one terminal for a newly created empty session', () => { - expect(source).toContain('useWorktreeSessionTabsLoaded(worktreeId)') - expect(source).toContain( - 'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()' - ) - - const autoCreateCall = sliceBetween( - 'useInitialSessionTerminalAutoCreate({', - 'const connectionVerdict =', - presentationSource - ) - expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef') - expect(autoCreateCall).toContain( - 'consumeCreationRoute: () => router.setParams({ created: undefined })' - ) - expect(autoCreateCall).toContain("newlyCreatedWorkspace: created === '1'") - expect(autoCreateCall).toContain('visibleTabCount: visibleTabs.length') - expect(autoCreateCall).toContain('createTerminal: () => void handleCreateTerminal()') - - expect(autoCreateHookSource).toContain('shouldAutoCreateInitialSessionTerminal({') - expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree === worktreeId') - expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId') - expect(autoCreateHookSource).toContain("connState === 'connected'") - expect(autoCreateHookSource).toContain('(visibleTabCount > 0 || activeHandle !== null)') - // Why: both callbacks are re-created every render, so the effect must reach them - // through useEffectEvent rather than deps or a render-time ref write. - expect(autoCreateHookSource).toContain('useEffectEvent(args.consumeCreationRoute)') - expect(autoCreateHookSource).toContain('useEffectEvent(args.createTerminal)') - expect(autoCreateHookSource).toContain('consumeCreationRoute()') - expect(autoCreateHookSource).toContain('createTerminal()') - }) - - it('arms the auto-create only until the route has published a tab (#9717)', () => { - // Emptiness after a populated list is a close, not a cold hydrate. - expect(source).toContain( - 'initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0' - ) - - const autoCreateCall = sliceBetween( - 'useInitialSessionTerminalAutoCreate({', - 'const connectionVerdict =', - presentationSource - ) - expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef') - expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs') - }) - - it('delegates stream ownership while retaining degraded polling and a certified sweep', () => { - expect(source).toContain('useMobileSessionTabsReconciliation<') - expect(source).toContain('const applicationRevision = ++appliedSessionTabsRevisionRef.current') - expect(source).toContain('getApplicationRevision: getSessionTabsApplicationRevision') - expect(source).not.toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain("if (AppState.currentState !== 'active')") - expect(reconciliationHookSource).toContain('suspendTerminalInventoryRecovery(true)') - expect(reconciliationHookSource).toContain('controller.poll()') - expect(reconciliationHookSource).toContain('tabsRequest !== null') - expect(reconciliationHookSource).toContain('void refreshTerminalInventory()') - expect(reconciliationHookSource).toContain("AppState.addEventListener('change'") - expect(reconciliationHookSource).toContain('const interval = setInterval(') - expect(reconciliationHookSource).toContain('RECONCILIATION_INTERVAL_MS = 2000') - expect(terminalInventoryRecoverySource).toContain('CERTIFIED_TERMINAL_SWEEP_MS = 60_000') - expect(reconciliationHookSource).toContain('controller.setReconciliationActive(false)') - expect(reconciliationHookSource).toContain('clearInterval(interval)') - expect(reconciliationHookSource).toContain('appStateSubscription.remove()') - }) - - it('binds terminal identity to the shared client before subscription effects run', () => { - expect(foundationSource).toContain('const { client, clientId, state: connState }') - expect(foundationSource).toContain(' clientId,') - expect(terminalRuntimeSource).toContain('useRef(clientId)') - expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId') - expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null') - expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)') - expect(terminalSubscriptionSourceForIdentity).toContain( - "client: { id: clientId, type: 'mobile' as const }" - ) - expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken') - }) - - it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { - expect(terminalSubscriptionSource).toContain( - "if (data.type === 'end' || data.type === 'error')" - ) - expect(terminalSubscriptionSource).toContain('signalTerminalInventoryRecovery()') - expect(terminalInventoryRecoverySource).toContain('actionRef.current = recoveryAction') - expect(terminalInventoryRecoverySource).toContain('pendingSignalScopeRef.current = scopeKey') - expect(terminalInventoryRecoverySource).toContain( - 'committedScope !== null && committedScope !== scopeKey' - ) - expect(terminalListSource).toContain('return terminalInventoryRequest.activate()') - expect(terminalListSource).toContain('if (!isCurrent() || !response.accepted)') - expect(terminalInventoryRecoverySource).toContain( - 'TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750' - ) - expect(terminalInventoryRecoverySource).toContain( - 'refreshTerminalInventory({ allowEmptyLoaded: true })' - ) - }) - - it('loads session tabs without waiting for desktop activation', () => { - const startupEffect = sliceBetween( - 'void (async () => {', - 'return () => {\n disposed = true', - startupSource - ) - - // Both sends migrated to the typed `worktreeActivate` operation in step 6; what these pin is - // unchanged — the plain activation is fired and not awaited, and it goes out before the tab load. - expect(startupEffect).toContain('void worktreeActivate\n .request(client, {') - expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)") - expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)") - expect(startupEffect).toContain('notifyClients: false') - expect(startupEffect).toContain("navigation: 'caller'") - expect(startupEffect).not.toContain('await worktreeActivate\n .request(client, {') - expect(startupEffect.indexOf('worktreeActivate\n .request(client, {')).toBeLessThan( - startupEffect.indexOf('await ensureSessionTabs()') - ) - expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(activation.value)') - expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'") - }) - - it('fails runtime capability gates closed before probing a replacement client', () => { - const capabilityEffect = sliceBetween( - 'const hostQueryReplyInputSupportedRef = useRef(false)', - 'return {\n consumeAcceptedSessionTabs', - tabReconciliationSource - ) - const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityProbe(client,') - - expect(probeStart).toBeGreaterThanOrEqual(0) - for (const reset of [ - 'setBrowserScreencastSupported(null)', - 'setAgentSessionHistorySupported(null)', - 'setQuickCommandsSupported(null)', - 'setShowQuickCommands(false)', - 'hostQueryReplyInputSupportedRef.current = false' - ]) { - const resetIndex = capabilityEffect.lastIndexOf(reset) - expect(resetIndex).toBeGreaterThanOrEqual(0) - expect(resetIndex).toBeLessThan(probeStart) - } - }) - - it('activates an already-selected pending terminal tab after hydration', () => { - expect(source).toContain( - 'const pendingTerminalActivationAttemptRef = useRef(null)' - ) - expect(source).toContain('pendingTerminalActivationAttemptRef.current = null') - - const pendingActivationEffect = sliceBetween( - "if (!client || connState !== 'connected' || !activePendingTerminalTab) {", - 'return {\n bulkCloseActions', - bulkCloseSource - ) - expect(pendingActivationEffect).toContain( - 'pendingTerminalActivationAttemptRef.current === activationKey' - ) - expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,') - expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id') - expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId') - expect(pendingActivationEffect).toContain('notifyClients: false') - expect(pendingActivationEffect).toContain("navigation: 'caller'") - expect(pendingActivationEffect).toContain( - 'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)' - ) - expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()') - }) - - it('keeps ready terminal taps local while publishing caller selection', () => { - const readyTerminalSwitch = sliceBetween( - 'const switchTab = useCallback(', - 'const switchSessionTab = useCallback(', - tabSwitchingSource - ) - - expect(readyTerminalSwitch).not.toContain('focusMobileTerminal(client, handle)') - expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,') - expect(readyTerminalSwitch).toContain('notifyClients: false') - expect(readyTerminalSwitch).toContain("navigation: 'caller'") - }) - - it('keeps background and pending session-tab activation local to the phone', () => { - const activationRequests = source.split('activateMobileSessionTab(client,').slice(1) - - expect(activationRequests).toHaveLength(4) - for (const request of activationRequests) { - expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false') - expect(request.slice(0, request.indexOf('})'))).toContain("navigation: 'caller'") - } - }) - - it('keeps dynamic agent rows above fixed New Tab actions', () => { - const newTabActions = sliceBetween( - 'title="New Tab"', - 'onClose={() => setShowCreateTabDrawer', - sheetsSource - ) - - expect(newTabActions.indexOf('...createTabAgentActions')).toBeLessThan( - newTabActions.indexOf("label: 'Terminal'") - ) - expect(newTabActions.indexOf("label: 'Terminal'")).toBeLessThan( - newTabActions.indexOf("label: 'Browser'") - ) - expect(newTabActions.indexOf("label: 'Browser'")).toBeLessThan( - newTabActions.indexOf("label: 'Markdown Note'") - ) - }) - - it('wires pending-handle recovery through its bounded context (STA-4256)', () => { - const applySessionTabs = sliceBetween( - 'const applySessionTabs = useCallback(', - 'const consumeAcceptedSessionTabs = useCallback(' - ) - const recoveryContext = sliceBetween( - 'const pendingTerminalRecoveryContextCache = useMemo(', - 'const sessionTabsFetchReporting', - tabReconciliationOwnerSource - ) - - const tabsRefWrite = 'sessionTabsRef.current = nextTabs' - const tabsStateWrite = 'setSessionTabs((prev)' - const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null' - const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)' - for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) { - expect(applySessionTabs).toContain(write) - } - expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan( - applySessionTabs.indexOf(tabsStateWrite) - ) - expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan( - applySessionTabs.indexOf(activeStateWrite) - ) - expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()') - expect(recoveryContext).toContain('sessionTabsRef.current,') - expect(recoveryContext).toContain('activeSessionTabIdRef.current') - expect(recoveryContext).toContain( - 'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()' - ) - expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed') - expect(source).toContain('getPendingTerminalRecoveryContextKey,') - expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext') - expect(source).toContain('retryPendingTerminalRecovery()') - }) -}) diff --git a/mobile/src/session/quick-commands-tab-stability-source.test.ts b/mobile/src/session/quick-commands-tab-stability-source.test.ts deleted file mode 100644 index 9c744a5f982..00000000000 --- a/mobile/src/session/quick-commands-tab-stability-source.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import ts from 'typescript' -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' - -const sourcePath = './MobileSessionHeader.tsx' -const source = readMobileSessionRouteSource(sourcePath) -const sheetsSource = readMobileSessionRouteSource('./MobileSessionSheets.tsx') -const sourceFile = ts.createSourceFile( - sourcePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX -) - -function findQuickCommandsTabButtons(): ts.JsxSelfClosingElement[] { - const matches: ts.JsxSelfClosingElement[] = [] - - function visit(node: ts.Node): void { - if ( - ts.isJsxSelfClosingElement(node) && - node.tagName.getText(sourceFile) === 'QuickCommandsTabButton' - ) { - matches.push(node) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return matches -} - -function getQuickCommandsTabSource(): string { - const start = source.indexOf('accessibilityLabel="New tab"') - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('', start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('quick-commands tab stability', () => { - it('keeps the button mounted while preserving the capability gate', () => { - const tabSource = getQuickCommandsTabSource() - const buttons = findQuickCommandsTabButtons() - - expect(buttons).toHaveLength(1) - const tabBar = buttons[0].parent - expect(ts.isJsxElement(tabBar)).toBe(true) - if (!ts.isJsxElement(tabBar)) { - return - } - expect(tabBar.openingElement.tagName.getText(sourceFile)).toBe('View') - const style = tabBar.openingElement.attributes.properties.find( - (attribute): attribute is ts.JsxAttribute => - ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'style' - ) - expect(style?.initializer?.getText(sourceFile)).toBe('{styles.tabBar}') - expect(tabSource).toContain('if (quickCommandsSupported === true)') - expect(tabSource).toContain('setShowQuickCommands(true)') - expect(tabSource).toContain('Desktop update required for quick commands') - expect(tabSource).toContain('Checking desktop capabilities — try again in a moment') - }) - - it('only presents the sheet after support is confirmed', () => { - expect(sheetsSource).toContain('visible={showQuickCommands && quickCommandsSupported === true}') - }) -}) diff --git a/mobile/src/session/session-router-seam-census.test.ts b/mobile/src/session/session-router-seam-census.test.ts deleted file mode 100644 index 9f73a5434ca..00000000000 --- a/mobile/src/session/session-router-seam-census.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - expoRouterValueImports, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const SESSION_ROOT = import.meta.dirname - -/** - * Which modules here hold a router, so the census cannot pass by seeing nothing. - * - * Four, and each for a different target. The foundation hook bounces a deleted workspace back to - * its host; the file-tap handlers push a preview route from a terminal link or a chat path; the - * notification hook consumes a pane tap by rewriting this route's own params; the review route - * body replaces to the session screen and pops the stack behind it. Only the first two and the - * last can leave the page, which is the whole reason the third is in the list anyway — a - * `setParams` on expo-router's router and a `setParams` on the handoff's are the same call, and - * listing it here is what stops someone later giving it back its own `useRouter` because "it never - * navigates". - */ -const ROUTER_HOLDERS = [ - 'MobileDiffReviewRouteScreen.tsx', - 'use-mobile-file-tap-handlers.ts', - 'use-mobile-session-foundation.ts', - 'use-notification-pane-navigation.ts' -] - -/** - * The expo-router names this domain may still import, and why each one is not a router. - * - * `useFocusEffect` reads whether this screen is the focused one in the document's own stack and - * `useLocalSearchParams` reads the params of the route already mounted. Neither takes a target, so - * neither can put a screen in front of the page; both are the page's own router answering about the - * page's own route, which is exactly what it is for. - * - * A closed list rather than a ban on `useRouter`: the hazard is anything that navigates, and - * expo-router exports a module-singleton `router` that does it from a plain function. A rule written - * against the one spelling would have read that as clean. - */ -const NON_NAVIGATING_ROUTER_NAMES = ['useFocusEffect', 'useLocalSearchParams'] - -describe('the session domain reaches the router through the handoff seam', () => { - const files = productFiles(SESSION_ROOT) - - it('walks the modules it is written against', () => { - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - expect(files.length).toBeGreaterThan(200) - }) - - it('imports nothing from expo-router that can navigate', () => { - const offenders = files - .map((name) => ({ - name, - imported: expoRouterValueImports(parse(SESSION_ROOT, name)).filter( - (imported) => !NON_NAVIGATING_ROUTER_NAMES.includes(imported) - ) - })) - .filter((entry) => entry.imported.length > 0) - .map((entry) => `${entry.name} (${entry.imported.join(', ')})`) - expect(offenders).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen that holds one', () => { - expect(files.filter((name) => callsRouteHandoff(parse(SESSION_ROOT, name))).sort()).toEqual( - [...ROUTER_HOLDERS].sort() - ) - }) - - it('still reaches expo-router for the two names that answer about its own route', () => { - // The completeness half: the rule above also passes over a domain that imports nothing at all, - // which is what it would read as if someone moved these hooks and left the list behind. - const imported = new Set( - files.flatMap((name) => expoRouterValueImports(parse(SESSION_ROOT, name))) - ) - expect([...imported].sort()).toEqual([...NON_NAVIGATING_ROUTER_NAMES].sort()) - }) -}) diff --git a/mobile/src/session/terminal-field-submit-binding-wiring.test.ts b/mobile/src/session/terminal-field-submit-binding-wiring.test.ts deleted file mode 100644 index 25a4761af85..00000000000 --- a/mobile/src/session/terminal-field-submit-binding-wiring.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support' - -const sendActionsSource = readMobileSessionRouteSource( - './use-mobile-session-terminal-send-actions.ts' -) - -const BINDING_CALL = 'useTerminalTextFieldSubmitBinding(' - -/** The handler each field hands its submit binding, in source order. */ -function readBoundSubmitNames(source: string): string[] { - const names: string[] = [] - let at = source.indexOf(BINDING_CALL) - while (at >= 0) { - const args = source.slice(at + BINDING_CALL.length, source.indexOf(')', at)) - const handler = args.split(',')[1]?.trim() - if (handler) { - names.push(handler) - } - at = source.indexOf(BINDING_CALL, at + 1) - } - return names -} - -/** - * The rule: a handler bound through `useTerminalTextFieldSubmitBinding` must not be frozen on an - * empty dependency list. - * - * `handleSend` is a per-render function whose guard reads `client`, `activeHandle` and `canSend`, - * all absent on the first render, so a submit memoized on `[]` is one closure for the life of the - * component and the field's line-break submit can never pass that guard. The field's own - * `onSubmitEditing` prop keeps working, which is what hid it. - * - * Why a source rule and not a render: every check that mounts its own submit writes the identity - * under test itself, so the render rig's probe route could catch neither the defect nor the fix. - * `terminal-field-submit-binding-send.test.tsx` reads the behaviour instead and catches spellings - * this cannot; this one is the cheap guard beside it. - */ -describe('the submit handlers the terminal fields bind', () => { - it('names one handler per bound field', () => { - expect(readBoundSubmitNames(sendActionsSource)).toEqual([ - 'submitLiveInput', - 'submitBufferedDraft' - ]) - }) - - it('freezes none of them on an empty dependency list', () => { - for (const name of readBoundSubmitNames(sendActionsSource)) { - const declaredAt = sendActionsSource.indexOf(`const ${name} = `) - expect(declaredAt).toBeGreaterThanOrEqual(0) - const boundAt = sendActionsSource.indexOf(`${BINDING_CALL}`, declaredAt) - expect(boundAt).toBeGreaterThan(declaredAt) - expect(sendActionsSource.slice(declaredAt, boundAt)).not.toContain('}, [])') - } - }) - - // The other half of the rule: a handler that is memoized must name what it reads, so the closure - // moves when the send guard's inputs do. - it('gives the live input submit the dependencies its closure reads', () => { - const declaredAt = sendActionsSource.indexOf('const submitLiveInput = ') - const boundAt = sendActionsSource.indexOf(BINDING_CALL, declaredAt) - const slice = sendActionsSource.slice(declaredAt, boundAt) - expect(slice).toContain('handleLiveInputSubmit') - expect(slice).toContain('activeSessionTab') - }) -}) diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts index d52f4861fd4..4935d514f36 100644 --- a/mobile/src/source-control/mobile-pr-create.test.ts +++ b/mobile/src/source-control/mobile-pr-create.test.ts @@ -158,32 +158,6 @@ describe('mobile create form gating parity', () => { ) }) - it('desktop gate hard-blocks on positive unresolved review evidence', () => { - // Mobile lacks review-lookup signals, so it fails closed on ambiguity: the - // shared desktop gate must return false even when eligibility looks ready. - expect( - shouldOpenChecksPanelCreateComposer({ - activeReview: null, - isFolder: false, - branch: 'feature/x', - hostedReviewCreation: eligibility({ canCreate: true }), - reviewLookup: 'positive_unresolved' - }) - ).toBe(false) - }) - - it('desktop gate hard-blocks during a hard refresh error', () => { - expect( - shouldOpenChecksPanelCreateComposer({ - activeReview: null, - isFolder: false, - branch: 'feature/x', - hostedReviewCreation: eligibility({ canCreate: true }), - hasHardRefreshError: true - }) - ).toBe(false) - }) - it('fails closed on an unavailable review lookup even when eligibility looks ready', () => { // The existing-review lookup could not prove there is no PR; mobile has no // review-lookup signal of its own, so create must be blocked. diff --git a/mobile/src/source-control/source-control-router-seam-census.test.ts b/mobile/src/source-control/source-control-router-seam-census.test.ts deleted file mode 100644 index e34d4ad93d8..00000000000 --- a/mobile/src/source-control/source-control-router-seam-census.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - callsRouteHandoff, - importsExpoRouterValue, - parse, - productFiles -} from '../navigation/router-seam-census.test-support' - -const SOURCE_CONTROL_ROOT = import.meta.dirname - -/** - * Which modules here hold a router, so the census cannot pass by seeing nothing. - * - * One holder, not one per screen: the hub's router is taken once in the openers hook and passed - * down through the state hook to the runners and the panel. So this domain's whole reach into the - * router is that single call, and the rules below say so rather than counting screens. - * - * `use-mobile-source-control-runners.ts` is the case a value/type rule is written for: it named - * expo-router only to write `ReturnType`, which is a value import in a type - * position and keeps the module in the graph. `RouteHandoff` is the seam's own name for that type. - */ -const ROUTER_HOLDERS = ['use-mobile-source-control-openers.ts'] - -describe('the source-control domain reaches the router through the handoff seam', () => { - const files = productFiles(SOURCE_CONTROL_ROOT) - - it('walks the modules it is written against', () => { - expect(files).toEqual(expect.arrayContaining(ROUTER_HOLDERS)) - expect(files).toContain('use-mobile-source-control-runners.ts') - expect(files).toContain('MobileSourceControlPanel.tsx') - }) - - it('imports no router from expo-router, which the page cannot hand a route back through', () => { - expect( - files.filter((name) => importsExpoRouterValue(parse(SOURCE_CONTROL_ROOT, name))) - ).toEqual([]) - }) - - it('takes the router from useRouteHandoff at every screen that holds one', () => { - expect( - files.filter((name) => callsRouteHandoff(parse(SOURCE_CONTROL_ROOT, name))).sort() - ).toEqual([...ROUTER_HOLDERS].sort()) - }) -}) diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts deleted file mode 100644 index 4eb61e3f1da..00000000000 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it } from 'vitest' -import { - readFlattenedMobileTasksHookSignatures, - readMobileTasksSemanticSource, - readMobileTasksStyleSource -} from './mobile-tasks-source-family.test-support' -import { readFlattenedMobileTasksRenderTokens } from './mobile-tasks-render-parity.test-support' -import { - readFlattenedMobileTasksCoreStatements, - readMobileTasksDeclarationSignatures -} from './mobile-tasks-execution-parity.test-support' - -const hash = (parts: string[] | string): string => - createHash('sha256') - .update(Array.isArray(parts) ? parts.join('\n') : parts) - .digest('hex') - -// Bound requests change source signatures the same way bound provider, workspace-creation and -// settings requests did: the method string and the envelope read leave the screen and an operation -// name arrives. The behaviour they used to pin is pinned by the recordings in -// mobile/rpc-foundation/goldens instead, which did not move. -// -// The screen-holdout migration takes the last two sends out of this family — the filter sheet's -// linear.selectWorkspace and the screen-root hook's repo.list. Hook, statement, declaration, render -// and style counts are all unchanged, and `semantics` is a pure deletion of four lines, none in: -// two `rpc:` call signatures and the two method literals they carried. The render-token hash moves -// because the picker's handler now names an operation instead of the client. -// -// Step 7's first half moves four of the six again, and moves nothing else. Checked readers on the -// item and list operations delete the reply casts these consumers carried, plus the three shape -// tests the reader now answers for: both `Array.isArray(payload)` guards on the checks read and the -// `typeof count === 'number'` fallback on the item count. Hook, statement, declaration and render -// counts are unchanged, and the render-token hash does not move at all — nothing this family sees -// changed inside a JSX tree. `semantics` is a pure deletion of ten lines. -// -// Round-1 review moves four, and names what each one is. The reaction reader stops matching -// `content` against an arm set mobile invented and forwards it, so `DetailComment` loses the eight -// phantom arms and `COMMENT_REACTION_EMOJI` stops being keyed by them: that is ten string literals -// gone and the `?? ''` fallback's one added, the whole of `semantics`' 3,290 -> 3,281. The eight -// alias-only bindings the deleted casts left behind (`const result = created` and its seven -// siblings) are inlined, which moves the hook and statement hashes without moving their counts. -// Only those eight: the Linear arm of task creation keeps its own `result`, which is a declaration -// with a name rather than an alias for one. -// No `rpc:` signature and no `jsx:` signature moves, the render-token hash does not move, and -// counts stay at 350 hooks, 417 statements and 194 declarations. -// -// The `gitlab.todos` fixture correction moves the same three hashes once more and no others: the -// to-do row is checked now, so the reader's cast is gone from the list-loading hook and the row -// type it forwarded is declared by what the reader proves. Counts are unchanged again, and -// `semantics` does not move, because no RPC call, runtime string or JSX host signature does. -// -// Round 2 moves two, and only because one member widens. `GitHubDetailFile.viewerViewedState` is -// `string` rather than the host's three arms, because the reader forwards it now: that is the -// declaration hash and the three arm literals, `semantics` 3,281 -> 3,278. `status` keeps its arms -// and moves nothing, because its only consumer sends it back as a param the host validates against -// the same set. Hook, statement and render hashes do not move; nothing executable changed. -// -// Step 7's tasks-2 half moves the hook, statement, declaration and semantic hashes once more, for -// the board, runtime, search, workspace-source and workspace-create operations, and moves no count: -// hooks stay at 350, statements at 417, declarations at 194. `semantics` is a pure deletion of four -// lines, 3,278 -> 3,274, and all four are the literals inside the one inline cast type this half -// deletes in use-mobile-tasks-project-detail-loading.tsx — `'DISMISSED'`, `'VIEWED'`, `'UNVIEWED'` -// and the `['status']` index into GitHubDetailFile. No method literal and no `rpc:` call signature -// moves. -// -// The hook and statement hashes also move for comment text alone: `normalized` reads a statement's -// full span, so a comment nested inside one is hashed with it. They move once more when the two -// halves are deduplicated: the project pane's five collection casts and the assignee list's are -// deleted where the entity schemas from the item half now type those rows. -// -// Round 2 of tasks-2 moves the hook and statement hashes a last time, and only those two. One -// statement changes: the Linear list cast (`found as LinearIssue[]`) becomes `found`, because the -// nine members linearIssueRowSchema requires make the value assignable to the mobile alias without -// it. The rest is comment text nested inside statements — three `SAFETY:` lines rewritten to argue -// from the schemas that landed instead of the fixtures round 1 deleted. Counts are unchanged at 350 -// hooks and 417 statements; the declaration, semantic, render and style hashes do not move, which -// is the evidence that deleting the cast changed no type and no call. -// -// The pullfrog pass on the same round moved both once more, again by comment text alone: the -// GitHub search `SAFETY:` line now separates the two members the schema requires (`items`, -// `labels`) from the eight it only types. No statement, type or call changed; counts hold. -// C2.1 swaps the workspace-creation push onto `hostNewWorktreeSessionRoute`, which already built -// this href with both segments encoded. Three of the family move and nothing else does: the hook -// list, because the handler's statements changed shape; the statement hash, for the same reason; -// and `semantics`, which is a pure deletion of two lines — the `URLSearchParams` construction and -// the raw `/h/${hostId}/session/...` template it fed. No RPC call, method literal or JSX host -// signature changed, and the render and style hashes did not move. - -// C2.1 swaps the two clipboard writes onto the platform seam, so the comment-review hook gains one -// hook call and one statement. Two of the family move: the hook list and the statement hash, each -// by one entry. `semantics` does not — no RPC call, method literal or JSX host signature changed — -// and the render and style hashes hold. - -// C2.8 names the status bar's Back control for the shell, which has no native chrome behind it to -// announce one. `accessibilityRole="button"` and `accessibilityLabel="Back"` on that one Pressable -// move the two hashes a JSX prop must move, and only those two. `semantics` 3,272 -> 3,274: the -// element's host signature widens (`jsx:Pressable:style,onPress` -> the same plus the two props) -// and the two new runtime strings `"button"` and `"Back"` arrive. The render-token stream gains -// the eight tokens those two attributes are, 35,195 -> 35,203. No RPC call signature and no method -// literal moves, and the hook, statement, declaration and style hashes do not move at all, which -// is the evidence that nothing executable changed. -// -// The same commit gives that control the `hitSlop={8}` its four siblings carry, so its touch -// target is no longer the glyph alone. One more line of `semantics` changes and no line is added -// or removed — the host signature gains `hitSlop` — so the count holds at 3,274 and only the hash -// moves. The render-token stream gains the four tokens that one attribute is, 35,203 -> 35,207. -// Nothing else in the family moves. - -const SCREEN_RPC_SCREEN_HOOKS = '0f66df2141117dfec2f8a0adb3f598312e6fda8e80833a365a645796f5ab48c3' -const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const SCREEN_RPC_STATEMENTS = 'dd8f33cb3cf96f5c39abac397cb77e35f59079291033a1866ead462b041ab979' -// Saved Linear selections now accept unknown persisted values; reconciliation tests cover them. -const MAIN_REBASED_DECLARATIONS = 'ec77d34712c7c4ab19ac6a4d57878f32d22aba790d2420cc14c2c0f000d120e9' -const SCREEN_RPC_SEMANTICS = 'e07a63387d57106483ee703ec6c19dea593e0eca5c651758f42bcb36254850b7' -// StatusDot's spacing moved to the tasks title row as `gap: spacing.sm`; same 8 px. -const PRE_REFACTOR_STYLES = '11504e9f655d4d45deb25d942158a0ee99a1f98007ad1630bb03e1bdfe1e73de' -const SCREEN_RPC_RENDER_TREE = '086742f95f1e87fb89d8c67ffd9f7a229799ae05115f9f4bcc1a925e56dcc8bb' - -describe('Mobile Tasks refactor parity', () => { - it('preserves recursively flattened hook and dependency order', () => { - const screenHooks = readFlattenedMobileTasksHookSignatures('MobileTasksScreen') - expect(screenHooks).toHaveLength(351) - expect(hash(screenHooks)).toBe(SCREEN_RPC_SCREEN_HOOKS) - - const diffHooks = readFlattenedMobileTasksHookSignatures('GitHubPrFileDiff') - expect(diffHooks).toHaveLength(3) - expect(hash(diffHooks)).toBe(PRE_REFACTOR_DIFF_HOOKS) - }) - - it('preserves every screen statement in execution order', () => { - const statements = readFlattenedMobileTasksCoreStatements() - expect(statements).toHaveLength(418) - expect(hash(statements)).toBe(SCREEN_RPC_STATEMENTS) - }) - - it('preserves every moved top-level declaration', () => { - const declarations = readMobileTasksDeclarationSignatures() - expect(declarations).toHaveLength(194) - expect(hash(declarations)).toBe(MAIN_REBASED_DECLARATIONS) - }) - - it('preserves RPC calls, runtime strings, and JSX host signatures', () => { - const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_274) - expect(hash(semantics)).toBe(SCREEN_RPC_SEMANTICS) - }) - - it('preserves render expressions and event handlers in tree order', () => { - const tokens = readFlattenedMobileTasksRenderTokens() - expect(tokens).toHaveLength(35_207) - expect(hash(tokens)).toBe(SCREEN_RPC_RENDER_TREE) - }) - - it('preserves every StyleSheet property and value', () => { - expect(hash(readMobileTasksStyleSource())).toBe(PRE_REFACTOR_STYLES) - }) -}) diff --git a/mobile/src/terminal/document/document-parse-time-effects.test.ts b/mobile/src/terminal/document/document-parse-time-effects.test.ts index 0acddfb78b3..86b8433ed14 100644 --- a/mobile/src/terminal/document/document-parse-time-effects.test.ts +++ b/mobile/src/terminal/document/document-parse-time-effects.test.ts @@ -1,13 +1,9 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { - DECLARATION_KINDS, documentModuleNames, documentModuleSource, exportedLifecycleFunctions, - parseModule, parseTimeEffects, - readsTheDocument, sequenceCalls, topLevelDeclarationsReachAnElement } from '../../test-support/webview-document-census' @@ -71,43 +67,6 @@ describe('the document modules at parse time', () => { } }) - it('would report a planted element read, which the statement filter cannot see', () => { - // The second reader has its own precondition. A `const` initialised from the document is a - // declaration by shape and a parse-time element read by effect — the exact form that survived - // a remount holding the first mount's node — and the statement-kind filter waves it through. - const planted = - "import { scope } from './document-scope'\n" + - "const indicator = document.getElementById('scroll-indicator')\n" + - 'export function n() {\n return indicator ?? scope.term\n}\n' - expect(parseTimeEffects('planted', planted)).toEqual([ - "planted: indicator = document.getElementById('scroll-indicator')" - ]) - // And the element reader the case above spends on every module: the same plant, seen by it. - expect(topLevelDeclarationsReachAnElement('planted', planted)).toBe(true) - // And the other direction, because a reader that flagged every initialiser would agree with - // the empty list above only by refusing everything: a plain literal is not work. - const inert = - "import { scope } from './document-scope'\n" + - 'const options = { capture: true, passive: false }\n' + - 'export function n() {\n return options.capture && scope.term !== null\n}\n' - expect(parseTimeEffects('inert', inert)).toEqual([]) - }) - - it('would report one, so the empty list above is a measurement', () => { - // The precondition. A walk that matched nothing would agree with an empty expectation just as - // happily, so the same reader is aimed at a module that does have a top-level effect: this - // test file itself, whose `describe` call is exactly the shape the rule refuses. - const source = readFileSync( - new URL('./document-parse-time-effects.test.ts', import.meta.url), - 'utf8' - ) - const running = parseModule('probe', source).body.filter( - (statement) => !DECLARATION_KINDS.has(statement.type) - ) - expect(running.length).toBeGreaterThan(0) - expect(readsTheDocument({ type: 'Identifier', name: 'document' })).toBe(true) - }) - it('start and stop: the sequence calls every one there is, and undoes them in reverse', () => { // Moving an effect out is only correct if something calls it, and a count cannot say that: a // module could export a start nobody runs and the count would agree as soon as the literal @@ -120,9 +79,6 @@ describe('the document modules at parse time', () => { MODULES.filter((name) => name !== THE_SEQUENCE).flatMap((name) => exportedLifecycleFunctions(moduleSource(name), keyword, 'TerminalDocumentScope') ) - expect(moduleSource('selection-overlay')).toContain( - 'export function stopSelectionOverlay(scope: TerminalDocumentScope) {\n stopEdgeScroll(scope)' - ) const started = sequenceCallsTo('startTerminalDocument') // `cancelDocumentFrames` is the frame registry's undo rather than a module's stop, and it is @@ -145,12 +101,4 @@ describe('the document modules at parse time', () => { ) expect(sequenceCallsTo('stopTerminalDocument').at(-1)).toBe('cancelDocumentFrames') }) - - it('would name a start the sequence forgot, which is what the comparison above is for', () => { - // The precondition, planted rather than argued: a module that exports a start nobody calls is - // the failure the set comparison exists to catch, and the reader has to say its name. - const planted = sequenceCallsTo('startTerminalDocument') - expect(planted).not.toContain('startReflow') - expect([...planted, 'startReflow'].sort()).not.toEqual(planted.slice().sort()) - }) }) diff --git a/mobile/src/terminal/document/host-seams.test.ts b/mobile/src/terminal/document/host-seams.test.ts index 17d709ddc42..c000062acf0 100644 --- a/mobile/src/terminal/document/host-seams.test.ts +++ b/mobile/src/terminal/document/host-seams.test.ts @@ -7,7 +7,6 @@ import { notify } from './host-notify' import { flog } from './viewport-transform' import { attachWebglAddon } from './webgl-recovery' import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams' -import { documentSourceText } from './document-module-source.test-support' /** * The host seams the page sets, and the window reads and writes they default to. @@ -324,38 +323,4 @@ describe("the document's viewport", () => { window.dispatchEvent(new Event('resize')) expect(scope.panX).toBe(0) }) - - it('is read through the seam everywhere, never off the window directly', () => { - // The default in `document-host-seams.ts` is the one window read, so the census runs over - // every other module; a raw read elsewhere sizes a page terminal to the whole page. - const raw = documentSourceText() - .split('\n') - .filter((line) => /window\.inner(Height|Width)|\binner(Height|Width)\b/.test(line)) - expect(raw).toEqual([ - ' return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }' - ]) - }) - - it('maps a client point into the grid only through viewportPoint', () => { - // On the page the host's origin is not the window's, so a client coordinate used against - // anything but another client coordinate lands rows low. Differences of two need no origin. - const arithmetic = documentSourceText() - .split('\n') - .filter((line) => !/^\s*(\*|\/\/)/.test(line)) - .filter((line) => /client[XY]\s*[-+*/<>]|[-+*/<>]=?\s*[\w.[\]]*client[XY]\b/.test(line)) - .map((line) => line.trim()) - .sort() - expect(arithmetic).toEqual([ - 'const dx = Math.abs(e.clientX - gesture.startX)', - 'const dx = Math.abs(mt.clientX - scope.tapCandidate.x)', - 'const dx = Math.abs(t.clientX - scope.longPressOrigin.x)', - 'const dx = a.clientX - b.clientX,', - 'const dy = Math.abs(e.clientY - gesture.startY)', - 'const dy = Math.abs(mt.clientY - scope.tapCandidate.y)', - 'const dy = Math.abs(t.clientY - scope.longPressOrigin.y)', - 'dy = a.clientY - b.clientY', - 'return viewportPoint(scope, (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2)', - 'return { x: clientX - frame.left, y: clientY - frame.top }' - ]) - }) }) diff --git a/mobile/src/terminal/terminal-cell-box-census.test.ts b/mobile/src/terminal/terminal-cell-box-census.test.ts deleted file mode 100644 index ea0e9ece12c..00000000000 --- a/mobile/src/terminal/terminal-cell-box-census.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url)) - -// xterm is the only measurer of the cell box. These are the ways to measure a glyph without it, -// matched by technique rather than by name, so a rename cannot hide one. xterm's own code is the -// generated engine, which the walk leaves out. -const GLYPH_MEASUREMENT: [string, RegExp][] = [ - ['canvas text metrics', /\bmeasureText\s*\(/], - ['offscreen canvas', /\bOffscreenCanvas\b/], - ['font bounding box', /\b(font|actual)BoundingBox(Ascent|Descent|Left|Right)\b/] -] -// A probe span: a created span, a run of one glyph, and its laid-out width, all in one file. -const PROBE_SPAN = [ - /createElement\(\s*['"`]span['"`]\s*\)/, - /['"`][A-Za-z0-9]['"`]\s*\.repeat\s*\(/, - /\b(offsetWidth|getBoundingClientRect)\b/ -] - -function productSources(): { path: string; text: string }[] { - return censusSourceFiles(SOURCE_ROOT) - .map((path) => ({ path, name: relative(SOURCE_ROOT, path).replaceAll('\\', '/') })) - .filter(({ name }) => /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) - .map(({ path, name }) => ({ path: name, text: readFileSync(path, 'utf8') })) -} - -function glyphMeasurements(text: string): string[] { - const found = GLYPH_MEASUREMENT.filter(([, pattern]) => pattern.test(text)).map(([kind]) => kind) - return PROBE_SPAN.every((pattern) => pattern.test(text)) ? [...found, 'probe span'] : found -} - -describe('the cell box has one measurer', () => { - it('walks the product sources, including the document that reports the box', () => { - const paths = productSources().map((source) => source.path) - expect(paths).toContain('terminal/document/laid-out-cell-box.ts') - expect(paths).toContain('terminal/document/terminal-init.ts') - }) - - it('recognises each technique it fences', () => { - expect(glyphMeasurements("ctx.measureText('W')")).toEqual(['canvas text metrics']) - expect(glyphMeasurements('new OffscreenCanvas(1, 1)')).toEqual(['offscreen canvas']) - expect(glyphMeasurements('m.fontBoundingBoxAscent + m.fontBoundingBoxDescent')).toEqual([ - 'font bounding box' - ]) - expect( - glyphMeasurements( - "const s = document.createElement('span'); s.textContent = 'W'.repeat(32); s.offsetWidth" - ) - ).toEqual(['probe span']) - }) - - it('has no file under mobile/src measure a glyph outside xterm', () => { - const found = productSources().flatMap(({ path, text }) => - glyphMeasurements(text).map((kind) => `${path}: ${kind}`) - ) - expect(found).toEqual([]) - }) -}) diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index abf1bd01045..50fcda6ec52 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -2,23 +2,12 @@ import type { RefObject } from 'react' import { describe, expect, it, vi } from 'vitest' import type { ConnectionState } from '../transport/types' import type { TerminalWebViewHandle } from './TerminalWebView' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { TERMINAL_FOREGROUND_RECOVERY_DELAY_MS, recoverActiveTerminalAfterForeground, shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' -const lifecycleSource = readMobileSessionRouteSource('../session/use-mobile-session-lifecycle.ts') - -function sliceSessionSource(startPattern: string, endPattern: string): string { - const start = lifecycleSource.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = lifecycleSource.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return lifecycleSource.slice(start, end) -} - type RecoveryHarness = { activeHandleRef: RefObject terminalRefs: RefObject> @@ -139,37 +128,4 @@ describe('terminal foreground recovery', () => { expect(harness.subscribeToTerminal).not.toHaveBeenCalled() }) - - it('is wired to AppState foregrounding in the session screen', () => { - const foregroundPredicate = sliceSessionSource( - 'const shouldRecover = shouldRecoverTerminalOnAppStateChange(', - 'previousAppState = nextAppState' - ) - - expect(lifecycleSource).toContain('shouldRecoverTerminalOnAppStateChange') - expect(foregroundPredicate).toContain('Platform.OS') - expect(lifecycleSource).toContain('recoverActiveTerminalAfterForeground({') - expect(lifecycleSource).toContain("AppState.addEventListener('change'") - const readinessInvalidation = lifecycleSource.indexOf( - 'terminalRef.prepareForForegroundRecovery()' - ) - const replay = lifecycleSource.indexOf('recoverActiveTerminalAfterForeground({') - expect(readinessInvalidation).toBeGreaterThanOrEqual(0) - expect(replay).toBeGreaterThan(readinessInvalidation) - }) - - it('re-runs a deferred recovery once the session screen reconnects', () => { - // Why: resume usually lands mid-reconnect; the session screen must retry - // recovery on the connState→connected transition or blanked panes stay - // stale until a manual tab switch. - expect(lifecycleSource).toContain( - "pendingForegroundRecoveryRef.current = outcome === 'deferred'" - ) - const reconnectRetry = sliceSessionSource( - "if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)", - 'recoverActiveTerminalAfterForeground({' - ) - expect(reconnectRetry).toContain('pendingForegroundRecoveryRef.current = false') - expect(reconnectRetry).toContain("AppState.currentState !== 'active'") - }) }) diff --git a/mobile/src/terminal/terminal-input-connection-gate.test.ts b/mobile/src/terminal/terminal-input-connection-gate.test.ts index 4d55c31bcf4..747f0eaab4c 100644 --- a/mobile/src/terminal/terminal-input-connection-gate.test.ts +++ b/mobile/src/terminal/terminal-input-connection-gate.test.ts @@ -1,28 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate' import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const runtimeSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-runtime.ts' -) -const sendActionsSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-send-actions.ts' -) -const terminalInputSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-input.ts' -) -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') - -function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { - const start = source.indexOf(anchorStart) - expect(start).toBeGreaterThanOrEqual(0) - // Why: a duplicated start anchor would silently slice the wrong region. - expect(source.indexOf(anchorStart, start + 1)).toBe(-1) - const end = source.indexOf(anchorEnd, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end + anchorEnd.length) -} describe('terminal input connection gate', () => { it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => { @@ -83,74 +61,9 @@ describe('terminal input connection gate', () => { }) }) -describe('session route offline-compose wiring', () => { - it('derives both gates from the shared resolver', () => { - expect(runtimeSource).toContain('resolveMobileTerminalInputGate({') - }) - - it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { - const bufferedInput = sourceSlice( - commandDockSource, - 'ref={bindCommandField}', - 'onSubmitEditing={() => void handleSend()}' - ) - expect(bufferedInput).toContain('editable={canCompose}') - - const liveCapture = sourceSlice( - commandDockSource, - 'ref={bindLiveInputField}', - 'importantForAutofill="no"' - ) - expect(liveCapture).toContain('editable={canSend}') - }) - - it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { - const sendButton = sourceSlice( - commandDockSource, - 'styles.sendButton,', - 'accessibilityLabel="Send command"' - ) - expect(sendButton).toContain('disabled={!canSend}') - }) - - it('holds composed text when the return key submits offline', () => { - const handleSend = sourceSlice( - sendActionsSource, - 'async function handleSend()', - 'sendingRef.current = true' - ) - expect(handleSend).toContain('!canSend') - }) - - it('keeps the live/buffered mode toggle reachable offline', () => { - const modeToggle = sourceSlice( - commandDockSource, - 'liveInputEnabled && styles.accessoryKeyActive', - 'onPress={toggleLiveInput}' - ) - expect(modeToggle).toContain('disabled={!canCompose}') - }) - - it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { - const hookCall = sourceSlice( - runtimeSource, - 'useTerminalLiveInputCommit({', - 'setLiveInputCapture' - ) - expect(hookCall).toContain("connected: connState === 'connected'") - }) - +describe('terminal send request shape', () => { it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => { - // Live mirror, buffered send, and gesture arrows must all opt out of the - // connect wait — a parked send replays stale bytes into the PTY. Accessory - // keys get the same option inside terminal-live-accessory-raw-send.ts. - expect(sendActionsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') - expect(terminalInputSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') - const optionUses = [sendActionsSource, terminalInputSource].flatMap( - (source) => source.match(/TERMINAL_INPUT_SEND_OPTIONS/g) ?? [] - ).length - // Two owner imports plus one buffered, one live, and one gesture send. - expect(optionUses).toBe(5) + // A parked send replays stale bytes into the PTY once the socket comes back. expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) }) diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts deleted file mode 100644 index 9929f09c37d..00000000000 --- a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') -const sendActionsSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-send-actions.ts' -) - -// Why: iOS terminates an active keyboard-dictation (and IME) session whenever -// JS writes a value into the focused field that differs from the native text -// (RN applies it via setTextAndSelection / _setAttributedString). Terminal -// inputs therefore must echo the raw field text in their controlled value and -// apply dash normalization only on the send/mirror path. See stablyai/orca#7925. -describe('terminal iOS dictation write-back', () => { - it('does not write normalized text back into the buffered command input value', () => { - expect(commandDockSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}') - expect(commandDockSource).not.toContain('setInput((previousText) => normalizeTerminalTextInput') - }) - - it('still normalizes the buffered command text at send time', () => { - expect(sendActionsSource).toContain('normalizeTerminalTextInput(draft)') - }) -}) diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts deleted file mode 100644 index 573dcf04bbf..00000000000 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') - -describe('terminal iOS IME keyboard', () => { - it('does not force terminal inputs onto the ASCII-only iOS keyboard', () => { - expect(commandDockSource).not.toContain("'ascii-capable'") - expect(commandDockSource).not.toContain('"ascii-capable"') - }) - - it('subscribes live capture to onChange so the marked-text report survives', () => { - // onChangeText hands over only a string, discarding the preedit report that - // decides whether the text may reach the PTY at all. - expect(commandDockSource).toContain('onChange={handleLiveInputChange}') - expect(commandDockSource).not.toContain('onChangeText={handleLiveInputChange}') - }) - - it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { - expect(commandDockSource).not.toContain('textContentType="none"') - expect(commandDockSource).toContain('autoComplete="off"') - }) -}) diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 89830162921..40a03d14592 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -2,7 +2,6 @@ import { Terminal } from '@xterm/xterm' import { describe, expect, it, vi } from 'vitest' import { createTerminalDocumentScope } from './document/document-scope' -import { documentModuleSource } from './document/document-module-source.test-support' import { emitKeyboardAvoidanceMetrics } from './document/keyboard-avoidance-metrics' import { commitFitScale } from './document/fit-scale' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' @@ -193,45 +192,6 @@ describe('terminal keyboard-avoidance WebView metrics', () => { }) }) - it('refreshes metrics after every buffer geometry reset', () => { - // Four places change the buffer's geometry, and each owes a fresh emit after it: a stale - // content-bottom row is what lifts the keyboard over the wrong line. Read from each module's - // own source, so a fifth site added in a new module is not silently uncovered. - // A text-scale resize reports through the fit it schedules, whose commit emits (pinned below). - const directEmit = 'emitKeyboardAvoidanceMetrics(scope)' - const blocks = [ - ['terminal-init', 'export function resize(', directEmit], - ['reflow', 'export function reflow(', directEmit], - ['host-message-router', "} else if (msg.type === 'clear') {", directEmit], - ['text-scaling', 'export function applyTextScale(', 'applyFitScale(scope'] - ] as const - - for (const [module, opener, emit] of blocks) { - const source = documentModuleSource(module) - const start = source.indexOf(opener) - expect(start, `${module} no longer carries ${opener}`).toBeGreaterThanOrEqual(0) - const block = source.slice(start, source.indexOf('\n}', start)) - const emitAt = block.lastIndexOf(emit) - const geometryAt = block.includes('.resize(') - ? block.indexOf('.resize(') - : block.indexOf('.reset(') - expect(emitAt, `${module} does not emit metrics`).toBeGreaterThanOrEqual(0) - expect(emitAt, `${module} emits before it resizes`).toBeGreaterThan(geometryAt) - } - }) - - it('reports a text-scale change only through the fit it commits', () => { - // One emit site: a pinch release and a settings change both end in the fit commit, so the - // release carries no emit of its own and needs no word back on whether a refit is pending. - const textScaling = documentModuleSource('text-scaling') - const start = textScaling.indexOf('export function applyTextScale(') - const applyTextScale = textScaling.slice(start, textScaling.indexOf('\n}', start)) - expect(applyTextScale).not.toMatch(/return (true|false)/) - expect(documentModuleSource('surface-touch-gestures')).not.toContain( - 'emitKeyboardAvoidanceMetrics' - ) - }) - it('reports the row pitch as drawn, fit scale included, and none before a cell is measured', () => { // Desktop display mode keeps the desktop's rows and the fit shrinks the grid by width. const buffer = { cursorY: 0, viewportY: 0, type: 'normal', getLine: () => undefined } diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts deleted file mode 100644 index 565b355d771..00000000000 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' - -const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') -const terminalRuntimeSource = readMobileSessionRouteSource( - '../session/use-mobile-session-terminal-runtime.ts' -) -const nativeChatSource = readMobileSessionRouteSource( - '../session/use-mobile-session-native-chat-dictation.ts' -) -const liveInputStatusSource = readFileSync( - new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url), - 'utf8' -) -const commandInputStylesSource = readFileSync( - new URL('../session/mobile-session-command-input-styles.ts', import.meta.url), - 'utf8' -) -const liveInputFocusSource = readFileSync( - new URL('./use-terminal-live-input-focus.ts', import.meta.url), - 'utf8' -) -const sendCompletionGenerationSource = readFileSync( - new URL('../session/use-mobile-send-completion-generation.ts', import.meta.url), - 'utf8' -) - -function liveInputBarBlock(): string { - const start = commandDockSource.indexOf('{liveInputEnabled ? (') - expect(start).toBeGreaterThanOrEqual(0) - const end = commandDockSource.indexOf(') : (', start) - expect(end).toBeGreaterThan(start) - return commandDockSource.slice(start, end) -} - -describe('terminal live input affordance', () => { - it('keeps the live status row wired as the keyboard focus control', () => { - const block = liveInputBarBlock() - - expect(block).toContain('onPress={focusLiveInput}') - expect(block).toContain('accessibilityRole="button"') - expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"') - expect(block).toContain( - 'accessibilityHint="Typed text is sent directly to the active terminal"' - ) - expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') - expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') - expect(block).toContain('showSoftInputOnFocus') - expect(block).toContain('liveInputText={liveInputCapture}') - expect(terminalRuntimeSource).toContain('useTerminalLiveInputFocus({') - expect(nativeChatSource).toContain('useMobileSendCompletionGeneration({') - expect(nativeChatSource).toContain('onBlur: resetLiveInputFocus') - expect(sendCompletionGenerationSource).toContain('return () => {') - expect(sendCompletionGenerationSource).toContain('onBlur()') - expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') - expect(liveInputFocusSource).toContain('lifecycleIdentity,') - expect(liveInputFocusSource).toContain('resetLiveInputFocus') - expect(liveInputFocusSource).toContain('keyboardHeight: context.keyboardHeight') - expect(liveInputFocusSource).toContain( - 'scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)' - ) - }) - - it('makes the live keyboard target visible instead of status-only chrome', () => { - expect(liveInputStatusSource).toContain("'Tap to show keyboard'") - expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") - expect(liveInputStatusSource).toContain('ellipsizeMode="head"') - expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') - expect(commandInputStylesSource).toContain('borderWidth: 1') - expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') - }) -}) diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 9b8a5b24b0e..51a349175be 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -1,59 +1,15 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import type { RpcResponse } from '../transport/types' -import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { terminalViewportUpdate } from './mobile-terminal-operations' import { isTerminalViewportRefitTargetCurrent, reduceTerminalFrameHeightRefit, resolveTerminalUpdateViewportCapability, type TerminalFrameHeightRefitEvent, - type TerminalFrameHeightRefitState, - type TerminalUpdateViewportCapability + type TerminalFrameHeightRefitState } from './terminal-viewport-refit-state' -const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8') -const sessionSource = [ - readMobileSessionRouteSource('../session/use-mobile-session-keyboard-state.ts'), - readMobileSessionRouteSource('../session/MobileSessionActiveContent.tsx'), - readMobileSessionRouteSource('../session/use-mobile-session-terminal-webview.ts') -].join('\n') - describe('terminal viewport refit', () => { - it('refits when the window dimensions change (fold/unfold, rotation)', () => { - // Why: a PTY fitted on the folded cover screen must be re-measured when - // the window grows, or the terminal renders in a fraction of the display. - expect(hookSource).toContain('useWindowDimensions()') - const start = hookSource.indexOf('const { width: windowWidth, height: windowHeight }') - expect(start).toBeGreaterThanOrEqual(0) - const resizeEffect = hookSource.slice(start) - expect(resizeEffect).toContain('viewportMeasuredRef.current = false') - expect(resizeEffect).toContain('scheduleViewportRefit()') - expect(resizeEffect).toContain( - '[windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit]' - ) - }) - - it('still refits when the tab strip toggles visibility', () => { - const start = hookSource.indexOf('const prevTabStripVisibleRef') - expect(start).toBeGreaterThanOrEqual(0) - const tabEffect = hookSource.slice(start, hookSource.indexOf('useWindowDimensions()')) - expect(tabEffect).toContain('viewportMeasuredRef.current = false') - expect(tabEffect).toContain('scheduleViewportRefit()') - }) - - it('refits the PTY when terminal text scale changes', () => { - // Why: mobile text size must change the real PTY grid, not just scale pixels - // in the WebView, or wrapped CLI output diverges from what the shell sees. - const start = hookSource.indexOf('const prevTextScaleRef = useRef(textScale)') - expect(start).toBeGreaterThanOrEqual(0) - const textScaleEffect = hookSource.slice(start, start + 600) - expect(textScaleEffect).toContain('prevTextScaleRef.current === textScale') - expect(textScaleEffect).toContain('viewportMeasuredRef.current = false') - expect(textScaleEffect).toContain('scheduleViewportRefit()') - expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]') - }) - it('coalesces keyboard-visible frame-height churn into one refit after close', () => { let state: TerminalFrameHeightRefitState = { frameHeight: 600, @@ -84,17 +40,6 @@ describe('terminal viewport refit', () => { expect(refitCount).toBe(2) }) - it('routes imperative height notifications through the keyboard-aware reducer', () => { - const start = hookSource.indexOf('const notifyFrameHeightRefitEvent = useCallback(') - expect(start).toBeGreaterThanOrEqual(0) - const notifier = hookSource.slice(start, start + 1_300) - expect(notifier).toContain('reduceTerminalFrameHeightRefit(') - expect(notifier).toContain("{ type: 'frame-height', height }") - expect(notifier).toContain("{ type: 'keyboard-visibility', visible }") - expect(notifier).toContain('viewportMeasuredRef.current = false') - expect(notifier).toContain('scheduleViewportRefit({ heightOriginated: true })') - }) - it('re-defers a height refit if the keyboard reopens before the debounce fires', () => { // Settle while the keyboard is up -> deferred (pending), no refit. let r = reduceTerminalFrameHeightRefit( @@ -126,95 +71,6 @@ describe('terminal viewport refit', () => { expect(ran.state.pending).toBe(false) }) - it('re-checks the keyboard at fire time only for height-originated refits', () => { - const start = hookSource.indexOf('refitTimerRef.current = setTimeout(') - expect(start).toBeGreaterThanOrEqual(0) - const timerBody = hookSource.slice(start, start + 900) - // The height flag scopes the guard; forced/width refits stay unguarded. - expect(timerBody).toContain('if (heightOriginatedRefitRef.current)') - expect(timerBody).toContain("type: 'refit-committed'") - expect(timerBody).toContain('if (!decision.shouldRefit)') - }) - - it('suppresses refits while native chat covers the active terminal', () => { - // Why: native chat renders the transcript, not the grid — a refit there would - // reflow the desktop PTY to phone dims the user never sees. - const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(') - const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart) - const fitIndex = hookSource.indexOf('ref.fitDimensions(', timerStart) - expect(timerStart).toBeGreaterThanOrEqual(0) - expect(coveredCheck).toBeGreaterThan(timerStart) - expect(fitIndex).toBeGreaterThan(coveredCheck) - expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef') - }) - - it('is wired into the session screen', () => { - expect(sessionSource).toContain('useTerminalViewportRefit({') - expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') - expect(sessionSource).toContain('textScale: terminalTextScale') - expect(sessionSource).toContain('connState,') - expect(sessionSource).toContain('notifyTerminalFrame({ width, height })') - expect(sessionSource).toContain('notifyTerminalFrameHeight(Math.round(frame.height))') - expect(sessionSource).toContain('notifyTerminalFrameWidth()') - // One seam for both facts: they come apart for a floating keyboard, which is open yet covers - // nothing the screen has to lift for. - expect(sessionSource).toContain('const softKeyboard = useSoftKeyboard()') - expect(sessionSource).toContain('notifyKeyboardVisibility(softKeyboard.visible)') - expect(sessionSource).toContain('setKeyboardHeight(softKeyboard.height)') - }) - - it('does not rerender SessionScreen for frame-height-only layout changes', () => { - // The imperative notifier keeps a dock-settling burst off React's render path. - expect(sessionSource).not.toContain('setTerminalFrameHeight') - expect(sessionSource).not.toContain('const [terminalFrameHeight,') - }) - - it('defers height-only window resizes while the keyboard is visible', () => { - const start = hookSource.indexOf('const prevWindowDimsRef') - const windowEffect = hookSource.slice(start, start + 1_100) - expect(windowEffect).toContain( - 'prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible' - ) - }) - - it('forces a refit on iOS foreground and connection recovery', () => { - const foregroundEffect = hookSource.slice( - hookSource.indexOf("if (Platform.OS !== 'ios')"), - hookSource.indexOf('const previousConnStateRef') - ) - const reconnectEffect = hookSource.slice( - hookSource.indexOf('const previousConnStateRef'), - hookSource.indexOf('disposedRef.current = false') - ) - - expect(foregroundEffect).toContain("AppState.addEventListener('change'") - expect(foregroundEffect).toContain('viewportMeasuredRef.current = false') - expect(foregroundEffect).toContain('scheduleForcedViewportRefit()') - expect(reconnectEffect).toContain("connState !== 'connected'") - expect(reconnectEffect).toContain('viewportMeasuredRef.current = false') - expect(reconnectEffect).toContain('scheduleForcedViewportRefit()') - }) - - it('bypasses the equal-dimensions guard for resume and reconnect refits', () => { - expect(hookSource).toContain('const forceRefit = forceNextRefitRef.current') - expect(hookSource).toContain( - 'if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows)' - ) - const forceRead = hookSource.indexOf('const forceRefit = forceNextRefitRef.current') - const updateViewport = hookSource.indexOf('terminalViewportUpdate.request(rpc,') - expect(forceRead).toBeGreaterThanOrEqual(0) - expect(updateViewport).toBeGreaterThan(forceRead) - }) - - it('prefers the in-place updateViewport RPC over resubscribe', () => { - const rpcIndex = hookSource.indexOf('terminalViewportUpdate.request(rpc,') - const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)') - const resubscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)') - expect(rpcIndex).toBeGreaterThanOrEqual(0) - expect(cacheUpdateIndex).toBeGreaterThan(rpcIndex) - expect(resubscribeIndex).toBeGreaterThan(rpcIndex) - }) - it('falls back to legacy resubscribe when an older desktop lacks updateViewport', () => { const unsupported = { id: 'old-host', @@ -229,52 +85,8 @@ describe('terminal viewport refit', () => { error: { code: 'temporary_failure', message: 'retryable' } }) ).toBe('unknown') - - let capability: TerminalUpdateViewportCapability = 'unknown' - let probeCount = 0 - for (let refit = 0; refit < 10; refit += 1) { - if (capability === 'unsupported') { - continue - } - probeCount += 1 - capability = resolveTerminalUpdateViewportCapability(unsupported) - } - expect(probeCount).toBe(1) - - const responseCheckIndex = hookSource.indexOf('if (outcome?.updated)') - const unsubscribeIndex = hookSource.indexOf('unsubscribeTerminal(handle)', responseCheckIndex) - const subscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)', unsubscribeIndex) - expect(responseCheckIndex).toBeGreaterThanOrEqual(0) - expect(unsubscribeIndex).toBeGreaterThan(responseCheckIndex) - expect(subscribeIndex).toBeGreaterThan(unsubscribeIndex) - expect(hookSource).toContain("updateViewportCapabilityRef.current !== 'unsupported'") - expect(hookSource).toContain("updateViewportCapabilityRef.current = 'unknown'") - }) - - it('reflows the local xterm scrollback after a successful updateViewport', () => { - // Why: updateViewport may only record an informational mobile viewport in - // desktop mode. Reflow local scrollback only after the server says it - // actually applied phone-fit to the PTY. - const appliedIndex = hookSource.indexOf('if (outcome.applied)') - const reflowIndex = hookSource.indexOf('ref.reflow(dims.cols, dims.rows, frame)') - const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)') - // Assert each anchor exists before ordering: a missing marker yields -1 and would - // let the ordering comparisons pass vacuously. - expect(appliedIndex).toBeGreaterThanOrEqual(0) - expect(cacheUpdateIndex).toBeGreaterThanOrEqual(0) - expect(reflowIndex).toBeGreaterThanOrEqual(0) - expect(reflowIndex).toBeGreaterThan(appliedIndex) - expect(reflowIndex).toBeGreaterThan(cacheUpdateIndex) - }) - - it('checks refit freshness after updateViewport resolves before side effects', () => { - // Why: rapid dock/sidebar resizing can complete RPCs out of order; a stale - // response must not update the viewport cache or locally reflow the old dims. - const responseIndex = hookSource.indexOf('terminalViewportUpdate.request(rpc,') - const postRpcCurrentIndex = hookSource.indexOf('if (!isCurrentTarget())', responseIndex) - const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)') - expect(postRpcCurrentIndex).toBeGreaterThan(responseIndex) - expect(postRpcCurrentIndex).toBeLessThan(cacheUpdateIndex) + // A method_not_found refusal latches the capability, so a refit stops re-probing. + expect(resolveTerminalUpdateViewportCapability(unsupported)).toBe('unsupported') }) it('only treats updateViewport as applied when the runtime updated the subscriber', () => { diff --git a/mobile/src/terminal/terminal-webview-consumer-census.test.ts b/mobile/src/terminal/terminal-webview-consumer-census.test.ts deleted file mode 100644 index cb57e4a4755..00000000000 --- a/mobile/src/terminal/terminal-webview-consumer-census.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Nothing above the terminal component knows which of the two it has. - * - * The page's `TerminalWebView.web.tsx` is resolved by the bundler, not chosen by a caller, so the - * whole substitution rests on every consumer holding only `terminal-webview-contract` — the props - * and the handle — and on the one that renders the component naming it without an extension. A - * consumer that reached into `TerminalWebView.tsx` for a type, or imported `terminal-webview-html` - * for the document string, would work natively and break on the page in a way no native test sees. - * - * Scanned rather than listed, so a new consumer joins the rule by existing. - */ - -const terminalDir = import.meta.dirname -const sessionDir = join(terminalDir, '..', 'session') - -/** The modules that may reach into the component's own file or the document's HTML. */ -const ALLOWED_INSIDE_TERMINAL = new Set([ - 'TerminalWebView.tsx', - 'TerminalWebView.web.tsx', - 'terminal-web-document-mount.ts', - 'terminal-webview-html.ts', - 'terminal-webview-html.web.ts', - // Test scaffolding that drives the document's own modules; it is not shipped in either build. - 'terminal-webview-mouse-test-harness.ts' -]) - -const FORBIDDEN_ABOVE_THE_CONTRACT = [ - /from '(\.\.\/terminal|\.)\/TerminalWebView\.(web\.)?tsx?'/, - /from '(\.\.\/terminal|\.)\/terminal-webview-html'/, - /from '(\.\.\/terminal|\.)\/terminal-webview-engine(-css)?\.generated'/, - /from '(\.\.\/terminal|\.)\/document\// -] - -function productModules(directory: string): string[] { - return readdirSync(directory) - .filter((name) => /\.tsx?$/.test(name)) - .filter((name) => !name.includes('.test') && !name.includes('.test-support')) - .sort() -} - -function offenders(directory: string, skip: (name: string) => boolean): string[] { - const found: string[] = [] - for (const name of productModules(directory)) { - if (skip(name)) { - continue - } - const source = readFileSync(join(directory, name), 'utf8') - for (const pattern of FORBIDDEN_ABOVE_THE_CONTRACT) { - if (pattern.test(source)) { - found.push(`${name}: ${pattern.source}`) - } - } - } - return found -} - -describe('the terminal component contract', () => { - it('is all that src/session imports of the terminal', () => { - expect(offenders(sessionDir, () => false)).toEqual([]) - // The precondition: a scan that read no session module would report nothing either. - const rendering = readFileSync(join(sessionDir, 'TerminalPaneView.tsx'), 'utf8') - expect(rendering).toContain("from '../terminal/TerminalWebView'") - expect(rendering).toContain("from '../terminal/terminal-webview-contract'") - }) - - it('is all that the terminal directory itself imports, outside the component and its document', () => { - expect(offenders(terminalDir, (name) => ALLOWED_INSIDE_TERMINAL.has(name))).toEqual([]) - expect(productModules(terminalDir).length).toBeGreaterThan(40) - }) - - it('would report a consumer that named the component file', () => { - // The scan tested on the text it is meant to refuse, so an empty offender list above is a - // measurement rather than a regex that matches nothing. - const planted = "import { TerminalWebView } from '../terminal/TerminalWebView.tsx'\n" - expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(planted))).toBe(true) - const html = "import { XTERM_HTML } from '../terminal/terminal-webview-html'\n" - expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(html))).toBe(true) - const extensionless = "import { TerminalWebView } from '../terminal/TerminalWebView'\n" - expect(FORBIDDEN_ABOVE_THE_CONTRACT.some((pattern) => pattern.test(extensionless))).toBe(false) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 3f3aab326fe..96a4327f090 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -1,6 +1,4 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it, vi } from 'vitest' @@ -8,15 +6,8 @@ import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated' import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { createTerminalDocumentScope } from './document/document-scope' import { attachWebglAddon, startWebglRecovery } from './document/webgl-recovery' -import { - documentModuleSource, - documentSourceText -} from './document/document-module-source.test-support' import { XTERM_HTML } from './terminal-webview-html' -// The document's own source, so a rule about what the document does is read where it is written. -const terminalHtmlSource = documentSourceText() - function createWebglRecoveryHarness(failSecondAttach = false) { const timers: Array<() => void> = [] const addons: Array<{ @@ -151,58 +142,6 @@ describe('terminal WebView bundled engine', () => { expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i) }) - it('reports WebView message handler failures instead of swallowing them', () => { - const start = terminalHtmlSource.indexOf('function handleIncomingMessage') - // Bounded by the next declaration in the same module: ruling 24 took the resize listener out - // of the bridge, so the handler is followed by the start that installs the transport. - const end = terminalHtmlSource.indexOf('function startMessageBridge', start) - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - const handlerSource = terminalHtmlSource.slice(start, end) - - expect(handlerSource).toContain('reportEngineError(') - expect(handlerSource).toContain("'terminal init failed'") - expect(handlerSource).toContain("'terminal message failed'") - expect(handlerSource).not.toContain('catch(ex) {}') - }) - - it('classifies runtime errors by a document-scoped ever-ready latch', () => { - // Why: init() flips `ready` false on every re-init (live width reflow keeps the - // old surface visible meanwhile), so the fatal default and the init-catch must - // key off `everReady` — otherwise a transient reflow error blanks a live - // terminal behind the fatal overlay. The latch stays set for the document. - // Ruling 21: the latch's initial value is in the scope factory, not in a parse-time write. - expect(terminalHtmlSource).toContain('everReady: false,') - expect(terminalHtmlSource).toContain('scope.everReady = true') - expect(terminalHtmlSource).toContain('fatal === undefined ? !scope.everReady : !!fatal') - expect(terminalHtmlSource).toContain("msg && msg.type === 'init' && !scope.everReady") - expect(terminalHtmlSource).not.toMatch(/fatal === void 0 \? !scope\.ready\b/) - }) - - it('bounds error capture and non-fatal reporting on a degraded engine', () => { - // Why: a constructed-but-broken engine can throw per render frame; both - // onerror capture sites must cap the buffer and non-fatal notifies must - // stop flooding RN while fatal reports always emit. - // Both sites: the document's own reporter, and the shell's inline handler that catches what - // fails before the document has run at all. - // `dirname`, not the module URL: a DOM-environment case has no `file:` URL to convert. - const shell = readFileSync( - join(import.meta.dirname, 'terminal-webview-html', 'document-shell.ts'), - 'utf8' - ) - // The shell's buffer is a global because it is older than any document; the document appends to - // it through the seam, so the two sites now spell the same cap over the same list differently. - expect(shell).toContain('window.__engineErrors.length < 20') - expect(terminalHtmlSource).toContain('const captured = scope.capturedEngineErrors()') - expect(terminalHtmlSource).toContain('if (captured.length < 20) {') - // The global is read in one place, the seam's own default, and the reporter no longer names it. - expect(documentModuleSource('host-notify')).not.toContain('window.__engineErrors') - expect(documentModuleSource('document-host-seams')).toContain( - 'window.__engineErrors = window.__engineErrors ?? []' - ) - expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5') - }) - it('recreates WebGL once after context loss, then stays on the DOM renderer', () => { const { addons, logged, term, timers } = createWebglRecoveryHarness() diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 48de712bcaa..fe090b1436e 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -6,7 +6,6 @@ import { resetTerminalDataReplyAuthority, resumeTerminalDataReplyAuthority } from './document/query-reply' -import { documentModuleSource } from './document/document-module-source.test-support' type QueryReplyGate = { forward: (data: string) => void @@ -50,32 +49,6 @@ function createQueryReplyGate(notify: (message: unknown) => void): { } describe('mobile terminal query replies', () => { - it('forwards xterm-generated data only after initial replay drains', () => { - const bridge = documentModuleSource('query-reply') - const init = documentModuleSource('terminal-init') - // The listener is armed by the bridge, and what it forwards is the gate's own call. - expect(bridge).toContain('term.onData(') - expect(bridge).toContain('forwardTerminalDataReply(scope, data)') - // The terminal takes keystrokes from the host, never from its own textarea: stdin is enabled so - // xterm generates replies, and the textarea is read-only so the phone's keyboard cannot type. - expect(init).toContain('disableStdin: false') - // Both are the bridge's own doing, where the listener it arms is: it takes the key handler and - // the textarea in the same breath. - expect(bridge).toContain('term.attachCustomKeyEventHandler(') - expect(bridge).toContain('term.textarea.readOnly = true') - }) - - it('mutes a replacement terminal until its own replay drains', () => { - const init = documentModuleSource('terminal-init') - const initIndex = init.indexOf('export function init(') - const disableIndex = init.indexOf('resetTerminalDataReplyAuthority(scope)', initIndex) - const enableIndex = init.indexOf('attachTerminalQueryReplyBridge(scope,', disableIndex) - - expect(initIndex).toBeGreaterThanOrEqual(0) - expect(disableIndex).toBeGreaterThan(initIndex) - expect(enableIndex).toBeGreaterThan(disableIndex) - }) - it('suppresses replay, then forwards live queries queued behind its boundary', () => { const messages: unknown[] = [] const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) @@ -117,10 +90,5 @@ describe('mobile terminal query replies', () => { gate.forward('\x1b[3;4R') expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) - const router = documentModuleSource('host-message-router') - const clearStart = router.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = router.indexOf("} else if (msg.type === 'reset-zoom')", clearStart) - expect(clearStart).toBeGreaterThanOrEqual(0) - expect(router.slice(clearStart, clearEnd)).toContain('resumeTerminalDataReplyAuthority(scope)') }) }) diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts deleted file mode 100644 index 82948f541eb..00000000000 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { - documentModuleSource, - documentSourceText -} from './document/document-module-source.test-support' - -const DOCUMENT_SOURCE = documentSourceText() - -// The reflow logic is the document's; the message dispatch and the handle wiring are the -// component's. Both are read as source, because the document is source now. -const reflowSource = documentModuleSource('reflow') -// The handle is built by the controller both components share, which is where the wiring is read. -const handleSource = readFileSync( - new URL('./use-terminal-webview-controller.ts', import.meta.url), - 'utf8' -) - -function reflowFnBody(): string { - const start = reflowSource.indexOf('export function reflow(scope: TerminalDocumentScope') - expect(start).toBeGreaterThanOrEqual(0) - return reflowSource.slice(start) -} - -describe('terminal WebView reflow', () => { - it('skips the alternate screen so TUI snapshots are not mutated', () => { - // Why: alt-screen snapshots are repainted by the PTY; a local resize there - // can drop SGR attributes (white text). Reflow must early-return. - expect(reflowFnBody()).toContain('if (!scope.term || isAlternateBufferActive(scope)) {') - }) - - it('rewraps the local buffer via term.resize to the new cols', () => { - expect(reflowFnBody()).toContain('scope.term.resize(nextCols, nextRows)') - }) - - it('preserves the user scroll position across the rewrap', () => { - const body = reflowFnBody() - // At the live bottom -> stay pinned; scrolled up -> hold distance-from-bottom. - expect(body).toContain('const wasAtBottom = buffer.viewportY >= buffer.baseY') - expect(body).toContain('scope.term.scrollToBottom()') - expect(body).toContain('rewrapped.baseY - distanceFromBottom - rewrapped.viewportY') - }) - - it('is no-op when the dimensions are unchanged', () => { - expect(reflowFnBody()).toContain( - 'if (nextCols === scope.term.cols && nextRows === scope.term.rows) {' - ) - }) - - it('is dispatched by the reflow WebView message and exposed on the handle', () => { - expect(DOCUMENT_SOURCE).toContain("} else if (msg.type === 'reflow') {") - expect(DOCUMENT_SOURCE).toContain('reflow(scope, msg.cols!, msg.rows!)') - expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows, frame })") - }) - - it('does not locally resize hidden WebViews to a one-column grid', () => { - // The floor belongs to the one fit the app and the document share. - const gridFitSource = readFileSync(new URL('./terminal-grid-fit.ts', import.meta.url), 'utf8') - expect(gridFitSource).toContain('export const MIN_FIT_COLS = 20') - expect(gridFitSource).toContain('if (!(cols >= MIN_FIT_COLS)) {') - }) -}) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts deleted file mode 100644 index bb0cc649412..00000000000 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { webviewPageSource } from './document/document-module-source.test-support' - -const DOCUMENT_SOURCE = webviewPageSource() - -// The RN wrapper and the pending-message queue are TypeScript; everything the WebView runs is the -// generated document. Concatenated so assertions resolve regardless of file. -const source = - readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') + - readFileSync(new URL('./use-terminal-webview-controller.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-ready-promises.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html/document-markup.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html/document-style.ts', import.meta.url), 'utf8') + - DOCUMENT_SOURCE -const sessionSource = readFileSync( - new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), - 'utf8' -) -const sessionHelperSource = readFileSync( - new URL('../session/mobile-session-route-helpers.ts', import.meta.url), - 'utf8' -) - -function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('TerminalWebView scroll routing', () => { - it('keeps Android touch drags inside the terminal WebView', () => { - expect(source).toContain('nestedScrollEnabled') - }) - - it('maps a downward pull at the bottom to older scrollback rows', () => { - expect(source).toContain('const deltaY = scope.touchGesture.lastY - y') - expect(source).toContain('scope.smoothScrollOffsetY -= deltaY') - expect(source).toContain( - 'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH)' - ) - - const nextViewportY = simulateNormalBufferPull({ - baseY: 120, - viewportY: 120, - startY: 300, - endY: 340, - cellHeight: 20 - }) - - expect(nextViewportY).toBe(118) - }) - - it('routes alternate-screen and mouse-aware scroll before smooth normal scroll', () => { - expect(source).toContain( - 'return isWheelMouseTrackingMode(getMouseTrackingMode(scope)) || isAlternateBufferActive(scope)' - ) - - const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener(\n 'touchmove'", - '{ capture: true, passive: false }' - ) - expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput(scope))')).toBeLessThan( - touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(scope, deltaY))') - ) - expect(touchMoveBlock).toContain('routeScrollLines(scope, lines, x, y)') - - const momentumBlock = sliceBetween( - 'function momentumStep(frameTime: number) {', - 'if (Math.abs(vel) > MIN_VEL)' - ) - expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput(scope))')).toBeLessThan( - momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(scope, delta))') - ) - expect(momentumBlock).toContain( - 'routeScrollLines(scope, lines, scope.touchGesture.lastX, scope.touchGesture.lastY)' - ) - }) - - it('does not rubber-band normal scroll at scrollback edges', () => { - expect(source).toContain('export function canScrollNormalBufferDelta(') - const smoothScrollBlock = sliceBetween( - 'export function applyNormalBufferScrollDelta(', - 'export function enqueueNormalBufferScrollDelta(' - ) - expect(smoothScrollBlock).toContain('if (!canScrollNormalBufferDelta(scope, deltaY))') - expect(smoothScrollBlock).toContain('resetSmoothScrollOffset(scope)') - expect(smoothScrollBlock).toContain('return false') - expect(smoothScrollBlock).toContain('return true') - - const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener(\n 'touchmove'", - '{ capture: true, passive: false }' - ) - expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(scope, deltaY))') - expect(touchMoveBlock).toContain('scope.touchGesture.velY = 0') - - const momentumBlock = sliceBetween( - 'function momentumStep(frameTime: number) {', - 'if (Math.abs(vel) > MIN_VEL)' - ) - expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(scope, delta))') - expect(momentumBlock).toContain('scope.touchGesture.momentumId = null') - }) - - it('coalesces normal touch scroll row commits onto animation frames', () => { - const enqueueBlock = sliceBetween( - 'export function enqueueNormalBufferScrollDelta(', - 'export function resetSmoothScrollOffset(' - ) - expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY') - expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {') - // Ruling 21: every document frame goes through the scope's registry so dispose can take it - // back; the id is still held here, which is what the reset below cancels. - expect(enqueueBlock).toContain( - 'scope.normalScrollFrameId = scheduleDocumentFrame(scope, function () {' - ) - expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(scope, delta)') - - const resetBlock = sliceBetween( - 'export function resetSmoothScrollOffset(', - 'export function stopNormalBufferSmoothScroll(' - ) - expect(resetBlock).toContain('scope.pendingNormalScrollDeltaY = 0') - expect(resetBlock).toContain('cancelAnimationFrame(scope.normalScrollFrameId)') - }) - - it('bounds native-side pending WebView writes while preserving control messages', () => { - expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000') - expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096') - expect(source).toContain('let pendingWriteBytes = 0') - expect(source).toContain('let pendingWriteCount = 0') - expect(source).toContain('const queue = (msg: TerminalWebViewCommand)') - expect(source).toContain('pendingWriteCount > MAX_PENDING_WEB_WRITE_MESSAGES') - expect(source).toContain("candidate.type === 'write'") - expect(source).toContain('pendingMessages.queue(msg)') - expect(source).toContain('pendingMessages.clear()') - }) - - it('clears the ready await timer when the real response wins', () => { - // C7.5 moved the promise into `terminal-webview-ready-promises.ts`, which both components - // reach through the controller. - const readyBlock = sliceBetween('async function awaitReady()', 'return { armReady') - expect(readyBlock).toContain('clearTimeout(timeout)') - expect(readyBlock).toContain('void pending.finally') - }) - - it('hides xterm scrollbars and drives the mobile scroll indicator from committed rows', () => { - expect(source).toContain('
    ') - expect(source).toContain('.xterm .xterm-viewport::-webkit-scrollbar') - expect(source).toContain('.xterm .xterm-scrollable-element > .xterm-scrollbar') - expect(source).toContain('overflow-y: hidden !important') - expect(source).toContain('display: none !important') - expect(source).toContain('export function updateScrollIndicator(') - expect(source).toContain('buffer.viewportY / maxViewportY') - expect(source).not.toContain('fractionalRows') - expect(source).toContain('scrollThumb.style.transform =') - expect(source).toContain('updateScrollIndicator(scope, true)') - }) - - it('does not apply fractional smooth scroll transforms to terminal content', () => { - const updateTransformBlock = sliceBetween( - 'export function updateTransform(', - 'export function updateScrollIndicator(' - ) - expect(updateTransformBlock).toContain( - "'translate(' + scope.panX + 'px,' + scope.panY + 'px) scale(' + getTotalScale(scope) + ')'" - ) - expect(source).not.toContain("querySelector('.xterm-screen')") - expect(source).not.toContain('updateTerminalScreenTransform') - expect(updateTransformBlock).not.toContain('getVisualPanY() + "px) scale("') - expect(updateTransformBlock).not.toContain('smoothScrollOffsetY') - }) - - it('smooths velocity samples and uses lower friction for mobile momentum', () => { - expect(source).toContain('export function updateTouchVelocity(') - expect(source).toContain('scope.touchGesture.velY * 0.55 + instantVelocity * 0.45') - expect(source).toContain('const FRICTION = 0.972') - expect(source).toContain('const MIN_VEL = 0.012') - // #21687: the decay is per elapsed millisecond, so a 120 Hz screen coasts the same distance. - expect(source).toContain('let lastMomentumTime = performance.now()') - expect(source).toContain( - 'const elapsed = Math.max(1, Math.min(50, frameTime - lastMomentumTime))' - ) - expect(source).toContain('vel *= FRICTION ** (elapsed / 16)') - expect(source).toContain('const delta = vel * elapsed') - }) - - it('keeps selection edge autoscroll active and extends the dragged endpoint', () => { - const startBlock = sliceBetween( - 'export function startEdgeScroll(', - 'export function stopEdgeScroll(' - ) - expect(startBlock.indexOf('stopEdgeScroll(scope)')).toBeLessThan( - startBlock.indexOf('scope.edgeScrollDir = dir') - ) - expect(startBlock.indexOf('scope.term.scrollLines(scope.edgeScrollDir)')).toBeLessThan( - startBlock.indexOf('syncEdgeScrollSelectionEndpoint(scope)') - ) - - const dragMoveBlock = sliceBetween( - 'export function handleDragMove(', - 'function attachSurfaceEventHandlers(' - ) - expect(dragMoveBlock).toContain('scope.edgeScrollClientX = clientX') - expect(dragMoveBlock).toContain('scope.edgeScrollClientY = clientY') - expect(dragMoveBlock).toContain( - 'syncSelectionHandleToViewportPoint(scope, handle, clientX, clientY)' - ) - }) - - it('allows x10 mouse gesture reports through the mobile session gate', () => { - expect(sessionHelperSource).toContain('function isGestureMouseTrackingMode') - expect(sessionHelperSource).toContain( - "return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'" - ) - - const inputBlockStart = sessionSource.indexOf('const handleTerminalInput = useCallback') - expect(inputBlockStart).toBeGreaterThanOrEqual(0) - const inputBlockEnd = sessionSource.indexOf( - 'async function handleClearTerminal', - inputBlockStart - ) - expect(inputBlockEnd).toBeGreaterThan(inputBlockStart) - const inputBlock = sessionSource.slice(inputBlockStart, inputBlockEnd) - expect(inputBlock).toContain('!isGestureMouseTrackingMode(modes?.mouseTrackingMode)') - expect(inputBlock).toContain('const sequenceCount = countTerminalGestureInputSequences(bytes)') - expect(inputBlock.indexOf('countTerminalGestureInputSequences')).toBeLessThan( - inputBlock.indexOf('enqueueTerminalGestureInput') - ) - }) -}) - -function simulateNormalBufferPull({ - baseY, - viewportY, - startY, - endY, - cellHeight -}: { - baseY: number - viewportY: number - startY: number - endY: number - cellHeight: number -}): number { - const deltaY = startY - endY - if (deltaY > 0 ? viewportY >= baseY : viewportY <= 0) { - return viewportY - } - const smoothScrollOffsetY = -deltaY - const lines = Math.trunc(-smoothScrollOffsetY / cellHeight) - const applied = Math.max(lines, -viewportY) - return viewportY + applied -} diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 20b2d2a0afd..29ba4bfcd13 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -1,19 +1,8 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { createTerminalDocumentScope } from './document/document-scope' import { normalizeStatusDotPresentation } from './document/write-queue' import { startTextScaling } from './document/text-scaling' -import { webviewPageSource } from './document/document-module-source.test-support' - -const terminalWebViewSource = readFileSync(join(import.meta.dirname, 'TerminalWebView.tsx'), 'utf8') -const terminalHtmlDocumentShellSource = readFileSync( - join(import.meta.dirname, 'terminal-webview-html', 'document-shell.ts'), - 'utf8' -) -// The document's own source, which is what the WebView runs once bundled. -const terminalHtmlSource = webviewPageSource() function normalizeStatusDotChunks(chunks: string[]) { const scope = createTerminalDocumentScope() @@ -46,45 +35,6 @@ function resolveTerminalFontFamily(navigatorValue: { } describe('TerminalWebView text zoom', () => { - it('pins textZoom to 100 so Android system font scale cannot inflate glyphs past xterm cell metrics', () => { - const start = terminalWebViewSource.indexOf('', start) - expect(end).toBeGreaterThan(start) - const webViewProps = terminalWebViewSource.slice(start, end) - expect(webViewProps).toContain('textZoom={100}') - }) - - it('keeps the HTML source object stable so parent renders do not reload xterm', () => { - const start = terminalWebViewSource.indexOf('', start) - expect(end).toBeGreaterThan(start) - const webViewProps = terminalWebViewSource.slice(start, end) - // One source object per view, pinned at mount. - expect(terminalWebViewSource).toContain('const [source] = useState(() =>') - expect(webViewProps).toContain('source={source}') - }) - - it('forces the Claude status dot to text presentation before xterm writes', () => { - expect(terminalHtmlSource).toContain('font-variant-emoji: text') - // The dot and its two selectors are the write queue's own constants, and the pattern is a - // literal because a construction at a module's top level would be parse-time work (ruling 20). - expect(terminalHtmlSource).toContain("const CLAUDE_STATUS_DOT = '\\u23fa'") - expect(terminalHtmlSource).toContain("const TEXT_PRESENTATION_SELECTOR = '\\ufe0e'") - expect(terminalHtmlSource).toContain("const EMOJI_PRESENTATION_SELECTOR = '\\ufe0f'") - expect(terminalHtmlSource).toContain( - 'const CLAUDE_STATUS_DOT_PATTERN = /\\u23fa[\\ufe0e\\ufe0f]*/g' - ) - expect(terminalHtmlSource).toContain('export function normalizeStatusDotPresentation(') - expect(terminalHtmlSource).toContain( - 'CLAUDE_STATUS_DOT_PATTERN,\n CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR' - ) - expect(terminalHtmlSource).toContain( - 'scope.writeQueue.push(normalizeStatusDotPresentation(scope, data))' - ) - }) - it('normalizes Claude status dots idempotently across write chunks', () => { const dot = String.fromCharCode(0x23fa) const textSelector = String.fromCharCode(0xfe0e) @@ -111,36 +61,6 @@ describe('TerminalWebView text zoom', () => { ) }) - it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => { - const initStart = terminalHtmlSource.indexOf('export function init(') - const initReplay = terminalHtmlSource.indexOf( - 'const replayData = normalizeInitialData(initialData)' - ) - const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'reset-zoom')", clearStart) - expect(initStart).toBeGreaterThanOrEqual(0) - expect(initReplay).toBeGreaterThan(initStart) - expect(clearStart).toBeGreaterThanOrEqual(0) - expect(clearEnd).toBeGreaterThan(clearStart) - expect(terminalHtmlSource.slice(initStart, initReplay)).toContain( - 'scope.statusDotPendingSelector = false' - ) - expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain( - 'scope.statusDotPendingSelector = false' - ) - }) - - it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') - expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') - const open = terminalHtmlSource.indexOf('term.open(scope.surface!)') - const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") - const replay = terminalHtmlSource.indexOf("enqueueWrite(scope, ESC + '[0m' + replayData)") - expect(open).toBeGreaterThanOrEqual(0) - expect(unicode).toBeGreaterThan(open) - expect(replay).toBeGreaterThan(unicode) - }) - const IOS_IPHONE_NAVIGATOR = { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15', platform: 'iPhone', diff --git a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts index 39615ef3692..3dd290fa1ff 100644 --- a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' @@ -7,15 +6,6 @@ import { TERMINAL_WRITE_FLUSH_WINDOW_MS } from './terminal-write-coalescer' -const webViewSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') -// C7.5 moved everything that is not `react-native-webview` into the controller both components -// share, so the coalescer's boundaries are read there; the component is still read for the two -// WebView lifecycle events that reach it. -const controllerSource = readFileSync( - new URL('./use-terminal-webview-controller.ts', import.meta.url), - 'utf8' -) - // Simulates TerminalWebView's postMessage: ready → deliver, not ready → queue. // There is no React render harness in the node environment, so the boundary // invariants are gated here against the same pending-queue module the component uses. @@ -110,73 +100,4 @@ describe('terminal write coalescer boundaries', () => { { type: 'write', data: 'still-current-document' } ]) }) - - it('routes handle.write through the coalescer whose delivery posts the write command', () => { - expect(controllerSource).toContain( - "createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data }))" - ) - const writeStart = controllerSource.indexOf('write(data: string) {') - expect(writeStart).toBeGreaterThanOrEqual(0) - const writeBody = controllerSource.slice(writeStart, writeStart + 120) - expect(writeBody).toContain('writeCoalescer.write(data)') - expect(writeBody).not.toContain('postMessage') - }) - - it('clears the coalescer before posting init and clear (snapshot supersession)', () => { - // Anchor on the init() signature (unique) — 'init(' alone also matches comments. - const initStart = controllerSource.indexOf( - 'init({ cols, rows, initialData, preserveScroll, oscLinks, frame }' - ) - const initClear = controllerSource.indexOf('writeCoalescer.clear()', initStart) - const initPost = controllerSource.indexOf("type: 'init'", initStart) - expect(initStart).toBeGreaterThanOrEqual(0) - expect(initClear).toBeGreaterThan(initStart) - expect(initClear).toBeLessThan(initPost) - - const clearStart = controllerSource.indexOf('clear() {', initPost) - const clearBody = controllerSource.slice(clearStart, clearStart + 160) - expect(clearStart).toBeGreaterThanOrEqual(0) - expect(clearBody.indexOf('writeCoalescer.clear()')).toBeGreaterThanOrEqual(0) - expect(clearBody.indexOf('writeCoalescer.clear()')).toBeLessThan( - clearBody.indexOf("postMessage({ type: 'clear' })") - ) - }) - - it('flushes pending writes before resize and reflow so boundaries observe prior bytes', () => { - for (const method of ['resize', 'reflow'] as const) { - const start = controllerSource.indexOf( - `${method}(cols: number, rows: number, frame: TerminalFrame | null) {` - ) - expect(start).toBeGreaterThanOrEqual(0) - const body = controllerSource.slice(start, start + 300) - const flushIndex = body.indexOf('writeCoalescer.flushNow()') - const postIndex = body.indexOf(`postMessage({ type: '${method}'`) - expect(flushIndex).toBeGreaterThanOrEqual(0) - expect(postIndex).toBeGreaterThan(flushIndex) - } - }) - - it('clears the coalescer in both document-lifecycle hooks alongside pendingMessages', () => { - // Both hooks now reach one function, so the clearing is asserted once where it lives and the - // two WebView events are asserted to be the callers. Reading only the component would pass on - // a `resetReadiness` that had quietly stopped clearing either one. - const start = controllerSource.indexOf('const resetReadiness = useCallback') - expect(start).toBeGreaterThanOrEqual(0) - const body = controllerSource.slice(start, controllerSource.indexOf('}, [', start)) - expect(body).toContain('pendingMessages.clear()') - expect(body).toContain('writeCoalescer.clear()') - expect(webViewSource).toContain('onLoadStart={resetReadiness}') - const terminated = webViewSource.indexOf('const handleContentProcessDidTerminate') - expect(terminated).toBeGreaterThanOrEqual(0) - expect(webViewSource.slice(terminated, webViewSource.indexOf('}, [', terminated))).toContain( - 'resetReadiness()' - ) - }) - - it('clears the coalescer on unmount so no timer leaks', () => { - const cleanupStart = controllerSource.indexOf('useEffect(() => {\n return () => {') - expect(cleanupStart).toBeGreaterThanOrEqual(0) - const cleanupBody = controllerSource.slice(cleanupStart, cleanupStart + 160) - expect(cleanupBody).toContain('writeCoalescer.clear()') - }) }) diff --git a/mobile/src/test-support/bridged-parity/c2-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c2-page-closure.test.ts deleted file mode 100644 index ca797265cfe..00000000000 --- a/mobile/src/test-support/bridged-parity/c2-page-closure.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { C1_PAGE_CLOSURE } from './c1-page-closure' -import { C2_PAGE_CLOSURE } from './c2-page-closure' -import { C2_TASK_SOURCE_CLOSURE_FAMILIES } from './c2-task-source-closure-families' -import { C2_WORK_ITEM_CLOSURE_FAMILIES } from './c2-work-item-closure-families' -import { C5_PAGE_CLOSURE } from './c5-page-closure' -import { pinsFromSource } from './page-closure-pin-source' -import { BRIDGED_PARITY_EXCLUSIONS } from './divergence-classes' -import { - pageClosureDrift, - pageClosureExclusions, - pageClosureRunTotals, - pageClosureTotals, - type PageClosureObservation -} from './page-closure' -import { readGolden } from '../rpc-recording/golden-recording' - -const GOLDENS = resolve(import.meta.dirname, '../../../rpc-foundation/goldens') -const C1_SOURCE = resolve(import.meta.dirname, 'c1-page-closure.ts') - -/** The run a corpus that diverged exactly as the pin says would hand the rule. */ -function asPinned(): Map { - const run = new Map() - for (const [family, goldens] of Object.entries(C2_PAGE_CLOSURE)) { - for (const [id, verdict] of Object.entries(goldens)) { - run.set(id, { family, verdict }) - } - } - return run -} - -describe('the C2 page closure', () => { - /** - * The census reads the committed table, and does not re-derive the closure. - * - * Nothing here or in the gate runs esbuild or maps a scenario's `sites` back to the module graph, - * so this fixes how many families the table may hold, not which families belong in it. A golden - * arriving in a family already pinned is caught, by `pageClosureDrift` and by this count. A new - * *family* entering the closure — a scenario recorded at a site the route newly imports — is - * invisible until someone re-derives the closure by hand, as C1's and C5's pins are too. - */ - it('is the census the design named: 68 families, 257 goldens', () => { - const goldens = Object.values(C2_PAGE_CLOSURE).flatMap((family) => Object.keys(family)) - expect({ families: Object.keys(C2_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({ - families: 68, - goldens: 257 - }) - expect(new Set(goldens).size).toBe(goldens.length) - }) - - /** - * The totals, asserted beside the per-id pins rather than instead of them. - * - * A walk that compares each id to its own verdict cannot see a table built wrong in a way that is - * self-consistent — C5 derived exactly that file once, with an empty mismatch list and three - * goldens in the wrong class. These counts are what showed it, and the gate re-asserts them - * against the run rather than against the table. - */ - it('pins how many goldens land in each class, which a per-id walk cannot see move', () => { - expect(pageClosureTotals(C2_PAGE_CLOSURE)).toEqual({ - identical: 108, - 'result-absent-settlement': 115, - 'params-undefined': 29, - 'result-absent-stream-release': 3, - 'write-ordinal': 2 - }) - }) - - it("inherits C1's families whole, with the verdicts C1's file commits", () => { - // Against the file's text rather than the imported object, because today the composition - // spreads that object and a comparison to it agrees with itself. This holds the shape of the - // inheritance: that C2's table carries C1's families at C1's verdicts and overrides none of - // them, and it keeps holding if the spread is ever replaced by inlined entries. - // - // What it cannot hold: a verdict changed inside `c1-page-closure.ts`. C2 inherits whatever C1 - // commits, so both sides of this comparison move together. Measured, that edit is red in the - // bridged-parity gate, which compares the pin to a run rather than to a file, and here in the - // class totals and the exclusion count, which pin absolute numbers. It is green in - // `c1-page-closure.test.ts`, whose exclusion check is `toBeGreaterThan(0)` and which pins no - // class totals at all. - // - // C2's own rule does not reproduce these pins — measured, it disagrees on 13 of the 103, being - // `tasks.smart-source-search` 7, `host-worktree-refresh` 5 and `worktree-catalog-snapshot` 1 — - // so inheritance is the derivation rather than a re-derivation that looked close. - const committed = pinsFromSource(C1_SOURCE) - // The reader found a table rather than nothing, which every assertion below depends on. - expect({ - families: Object.keys(committed).length, - pins: Object.values(committed).flatMap(Object.keys).length - }).toEqual({ families: 20, pins: 94 }) - for (const [family, pinned] of Object.entries(committed)) { - expect(C2_PAGE_CLOSURE[family], family).toEqual(pinned) - } - expect(Object.keys(C1_PAGE_CLOSURE)).toEqual(Object.keys(committed)) - }) - - it('agrees with C5 object for object on every family both domains pin', () => { - // Two page closures that share a family share the goldens in it. Were one to be re-derived and - // the other inherited, this is the assertion that would not hold. - const shared = Object.keys(C5_PAGE_CLOSURE).filter((family) => family in C2_PAGE_CLOSURE) - expect(shared.length).toBe(20) - for (const family of shared) { - expect(C2_PAGE_CLOSURE[family], family).toEqual(C5_PAGE_CLOSURE[family]) - } - }) - - it('adds 48 families, split across the two halves without overlap', () => { - // The composed table is a spread, so a family named in both halves would be taken from the last - // one silently. Disjointness is what makes the two files data rather than a precedence rule. - const work = Object.keys(C2_WORK_ITEM_CLOSURE_FAMILIES) - const source = Object.keys(C2_TASK_SOURCE_CLOSURE_FAMILIES) - expect(work.filter((family) => source.includes(family))).toEqual([]) - expect(work.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect(source.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect({ work: work.length, source: source.length }).toEqual({ work: 26, source: 22 }) - const added = Object.keys(C2_PAGE_CLOSURE).filter((family) => !(family in C1_PAGE_CLOSURE)) - expect(added.sort()).toEqual([...work, ...source].sort()) - }) - - it('names the families where a pin proves only that the divergence kept its name', () => { - // "266 certified" would read as 266 proofs of byte-identity. Six families have no byte-identical - // golden at all, so their 27 say only that the class did not change, and the PR body says so. - const wholly = Object.entries(C2_PAGE_CLOSURE) - .filter(([, pins]) => !Object.values(pins).includes('identical')) - .map(([family, pins]) => [family, Object.keys(pins).length] as const) - expect(wholly).toEqual([ - ['host-worktree-refresh', 5], - ['tasks.item-checks-files', 6], - ['tasks.item-metadata-gitlab-mr', 2], - ['tasks.project-row-files-merge', 6], - ['tasks.provider-load', 6], - ['tasks.task-list-gitlab-items', 2] - ]) - }) - - it('pins goldens that exist, in the family the corpus records them under', () => { - for (const [family, goldens] of Object.entries(C2_PAGE_CLOSURE)) { - for (const id of Object.keys(goldens)) { - // Through the corpus's own reader, which checks the format version and the value pool and - // throws a named diagnostic otherwise. - const recorded = readGolden(GOLDENS, id) - expect({ id, family: recorded.family }).toEqual({ id, family }) - } - } - }) - - it('claims no golden the corpus does not have', () => { - const corpus = new Set( - readdirSync(GOLDENS) - .filter((name) => name.endsWith('.json')) - .map((name) => name.replace(/\.json$/, '')) - ) - const missing = Object.values(C2_PAGE_CLOSURE) - .flatMap((family) => Object.keys(family)) - .filter((id) => !corpus.has(id)) - expect(missing).toEqual([]) - }) - - it('excludes a closure golden only into a class that has a reason', () => { - const exclusions = pageClosureExclusions(C2_PAGE_CLOSURE) - expect(exclusions.length).toBe(149) - expect(exclusions.filter(([, name]) => BRIDGED_PARITY_EXCLUSIONS[name] === undefined)).toEqual( - [] - ) - }) -}) - -describe('reading a run against the C2 pin', () => { - it('says nothing when the run is the pin', () => { - expect(pageClosureDrift(C2_PAGE_CLOSURE, asPinned())).toEqual([]) - expect(pageClosureRunTotals(C2_PAGE_CLOSURE, asPinned())).toEqual( - pageClosureTotals(C2_PAGE_CLOSURE) - ) - }) - - it('names a closure golden that changed verdict', () => { - const run = asPinned() - const found = [...run].find(([, seen]) => seen.verdict !== 'params-undefined') - if (found === undefined) { - throw new Error('the pin is empty') - } - const [id, observation] = found - run.set(id, { ...observation, verdict: 'params-undefined' }) - const drift = pageClosureDrift(C2_PAGE_CLOSURE, run) - expect(drift.length).toBe(1) - expect(drift[0]).toContain(id) - expect(drift[0]).toContain(`pinned ${observation.verdict}, ran params-undefined`) - // And in the counts, which is the check that holds when a second golden moves the other way. - expect(pageClosureRunTotals(C2_PAGE_CLOSURE, run)).not.toEqual( - pageClosureTotals(C2_PAGE_CLOSURE) - ) - }) - - it('names a golden newly derived into a closure family, which no id list would', () => { - const run = asPinned() - const [family] = Object.keys(C2_PAGE_CLOSURE) - if (family === undefined) { - throw new Error('the pin is empty') - } - run.set('matrix-arrived-1', { family, verdict: 'identical' }) - expect(pageClosureDrift(C2_PAGE_CLOSURE, run)).toEqual([ - `${family}: arrived matrix-arrived-1; left (none)` - ]) - }) - - it('names a closure golden the run stopped producing, three ways', () => { - const run = asPinned() - const [family, goldens] = Object.entries(C2_PAGE_CLOSURE)[0] ?? [] - const [id] = Object.keys(goldens ?? {}) - if (family === undefined || id === undefined) { - throw new Error('the pin is empty') - } - run.delete(id) - expect(pageClosureDrift(C2_PAGE_CLOSURE, run)).toEqual([ - `${family}: arrived (none); left ${id}` - ]) - // Drift is one of three. The run's totals fall short of the pin's, and the census above fixes - // the number of goldens the table may hold, so a drop cannot be absorbed by deleting the pin. - expect(pageClosureRunTotals(C2_PAGE_CLOSURE, run)).not.toEqual( - pageClosureTotals(C2_PAGE_CLOSURE) - ) - expect([...run].filter(([, seen]) => seen.family in C2_PAGE_CLOSURE).length).toBe(256) - }) - - it('ignores every golden outside the closure, which is most of the corpus', () => { - const run = asPinned() - run.set('session-diff-review-elsewhere', { - family: 'session.diff-review', - verdict: 'result-absent-settlement' - }) - expect(pageClosureDrift(C2_PAGE_CLOSURE, run)).toEqual([]) - }) -}) diff --git a/mobile/src/test-support/bridged-parity/c3-page-closure.test.ts b/mobile/src/test-support/bridged-parity/c3-page-closure.test.ts deleted file mode 100644 index afc1c897564..00000000000 --- a/mobile/src/test-support/bridged-parity/c3-page-closure.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { readdirSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { C1_PAGE_CLOSURE } from './c1-page-closure' -import { C2_PAGE_CLOSURE } from './c2-page-closure' -import { C3_PAGE_CLOSURE } from './c3-page-closure' -import { C3_EXPLORER_CLOSURE_FAMILIES } from './c3-explorer-closure-families' -import { C3_PREVIEW_CLOSURE_FAMILIES } from './c3-preview-closure-families' -import { C5_PAGE_CLOSURE } from './c5-page-closure' -import { pinsFromSource } from './page-closure-pin-source' -import { BRIDGED_PARITY_EXCLUSIONS } from './divergence-classes' -import { - pageClosureDrift, - pageClosureExclusions, - pageClosureRunTotals, - pageClosureTotals, - type PageClosureObservation -} from './page-closure' -import { readGolden } from '../rpc-recording/golden-recording' - -const GOLDENS = resolve(import.meta.dirname, '../../../rpc-foundation/goldens') -const C1_SOURCE = resolve(import.meta.dirname, 'c1-page-closure.ts') - -/** The run a corpus that diverged exactly as the pin says would hand the rule. */ -function asPinned(): Map { - const run = new Map() - for (const [family, goldens] of Object.entries(C3_PAGE_CLOSURE)) { - for (const [id, verdict] of Object.entries(goldens)) { - run.set(id, { family, verdict }) - } - } - return run -} - -describe('the C3 page closure', () => { - /** - * The census reads the committed table, and does not re-derive the closure. - * - * Nothing here or in the gate runs esbuild or maps a scenario's `sites` back to the module graph, - * so this fixes how many families the table may hold, not which families belong in it. A golden - * arriving in a family already pinned is caught, by `pageClosureDrift` and by this count. A new - * *family* entering the closure is invisible until someone re-derives it by hand — which - * `config/scripts/mobile-web-app-page-closure-families.test.mjs` now does for both files routes. - */ - it('is the census the design named: 26 families, 116 goldens', () => { - const goldens = Object.values(C3_PAGE_CLOSURE).flatMap((family) => Object.keys(family)) - expect({ families: Object.keys(C3_PAGE_CLOSURE).length, goldens: goldens.length }).toEqual({ - families: 26, - goldens: 116 - }) - expect(new Set(goldens).size).toBe(goldens.length) - }) - - /** - * The totals, asserted beside the per-id pins rather than instead of them. - * - * A walk that compares each id to its own verdict cannot see a table built wrong in a way that is - * self-consistent — C5 derived exactly that file once, with an empty mismatch list and three - * goldens in the wrong class. These counts are what showed it, and the gate re-asserts them - * against the run rather than against the table. - */ - it('pins how many goldens land in each class, which a per-id walk cannot see move', () => { - expect(pageClosureTotals(C3_PAGE_CLOSURE)).toEqual({ - identical: 60, - 'result-absent-settlement': 44, - 'params-undefined': 7, - 'result-absent-stream-release': 3, - 'write-ordinal': 2 - }) - }) - - it("inherits C1's families whole, with the verdicts C1's file commits", () => { - // Against the file's text rather than the imported object: the composition spreads that object, - // so a hand-edited inherited entry would be read back as C1's and agree with itself. - // - // What this cannot see is an edit to `c1-page-closure.ts` itself, because both sides of the - // comparison then move together. What it does see is a C3 half redeclaring an inherited family, - // which the spread would otherwise take silently from the last table. Both measured: flipping - // `settings-repo-metadata-icons` in C1's file leaves this case green and reds seven others — - // the class totals and the exclusion counts in C2's suite and this one, both cross-series - // agreements, and C5's own inheritance case, which compares against an independent literal - // rather than a spread. - // - // Redeclaring `settings.repo-metadata` in the preview half always reds this case; how many - // others go with it depends on the shape of the redeclaration, so the number is not the claim. - // Measured: one golden under the family's name reds seven, because it also shrinks the census - // and leaves that family with no byte-identical golden. The family copied verbatim with a - // single verdict flipped reds five, the census unmoved. Both keep the load-bearing half — the - // spread takes the last table's entry, and this case is what sees it. - // - // C2's rule does not reproduce these pins — measured here, it disagrees on 13 of the 94, - // being `tasks.smart-source-search` 7, `host-worktree-refresh` 5 and - // `worktree-catalog-snapshot` 1 — so inheritance is the derivation rather than a - // re-derivation that looked close. - const committed = pinsFromSource(C1_SOURCE) - expect({ - families: Object.keys(committed).length, - pins: Object.values(committed).flatMap(Object.keys).length - }).toEqual({ families: 20, pins: 94 }) - for (const [family, pinned] of Object.entries(committed)) { - expect(C3_PAGE_CLOSURE[family], family).toEqual(pinned) - } - }) - - it('agrees with C2 and C5 object for object on every family they share', () => { - // Three page closures that share a family share the goldens in it. Were one re-derived and the - // others inherited, this is the assertion that would not hold. - for (const [name, other] of [ - ['C2', C2_PAGE_CLOSURE], - ['C5', C5_PAGE_CLOSURE] - ] as const) { - const shared = Object.keys(other).filter((family) => family in C3_PAGE_CLOSURE) - expect(shared.length, name).toBe(20) - for (const family of shared) { - expect(C3_PAGE_CLOSURE[family], `${name}/${family}`).toEqual(other[family]) - } - } - }) - - it('adds 6 families, split across the two routes without overlap', () => { - // The composed table is a spread, so a family named in both halves would be taken from the last - // one silently. Disjointness is what makes the two files data rather than a precedence rule. - const explorer = Object.keys(C3_EXPLORER_CLOSURE_FAMILIES) - const preview = Object.keys(C3_PREVIEW_CLOSURE_FAMILIES) - expect(explorer.filter((family) => preview.includes(family))).toEqual([]) - expect(explorer.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect(preview.filter((family) => family in C1_PAGE_CLOSURE)).toEqual([]) - expect({ explorer: explorer.length, preview: preview.length }).toEqual({ - explorer: 1, - preview: 5 - }) - const added = Object.keys(C3_PAGE_CLOSURE).filter((family) => !(family in C1_PAGE_CLOSURE)) - expect(added.sort()).toEqual([...explorer, ...preview].sort()) - }) - - it('names the families where a pin proves only that the divergence kept its name', () => { - // "125 certified" would read as 125 proofs of byte-identity. One family has no byte-identical - // golden at all, so its 5 say only that the class did not change, and the PR body says so. - const wholly = Object.entries(C3_PAGE_CLOSURE) - .filter(([, pins]) => !Object.values(pins).includes('identical')) - .map(([family, pins]) => [family, Object.keys(pins).length] as const) - expect(wholly).toEqual([['host-worktree-refresh', 5]]) - }) - - it('pins goldens that exist, in the family the corpus records them under', () => { - for (const [family, goldens] of Object.entries(C3_PAGE_CLOSURE)) { - for (const id of Object.keys(goldens)) { - const recorded = readGolden(GOLDENS, id) - expect({ id, family: recorded.family }).toEqual({ id, family }) - } - } - }) - - it('claims no golden the corpus does not have', () => { - const corpus = new Set( - readdirSync(GOLDENS) - .filter((name) => name.endsWith('.json')) - .map((name) => name.replace(/\.json$/, '')) - ) - const missing = Object.values(C3_PAGE_CLOSURE) - .flatMap((family) => Object.keys(family)) - .filter((id) => !corpus.has(id)) - expect(missing).toEqual([]) - }) - - it('excludes a closure golden only into a class that has a reason', () => { - const exclusions = pageClosureExclusions(C3_PAGE_CLOSURE) - expect(exclusions.length).toBe(56) - expect(exclusions.filter(([, name]) => BRIDGED_PARITY_EXCLUSIONS[name] === undefined)).toEqual( - [] - ) - }) -}) - -describe('reading a run against the C3 pin', () => { - it('says nothing when the run is the pin', () => { - expect(pageClosureDrift(C3_PAGE_CLOSURE, asPinned())).toEqual([]) - expect(pageClosureRunTotals(C3_PAGE_CLOSURE, asPinned())).toEqual( - pageClosureTotals(C3_PAGE_CLOSURE) - ) - }) - - it('names a closure golden that changed verdict', () => { - const run = asPinned() - const found = [...run].find(([, seen]) => seen.verdict !== 'params-undefined') - if (found === undefined) { - throw new Error('the pin is empty') - } - const [id, observation] = found - run.set(id, { ...observation, verdict: 'params-undefined' }) - const drift = pageClosureDrift(C3_PAGE_CLOSURE, run) - expect(drift.length).toBe(1) - expect(drift[0]).toContain(id) - expect(drift[0]).toContain(`pinned ${observation.verdict}, ran params-undefined`) - }) - - it('names a golden the run stopped producing, and moves the totals with it', () => { - const run = asPinned() - const [family, goldens] = Object.entries(C3_PAGE_CLOSURE)[0] ?? [] - const [id] = Object.keys(goldens ?? {}) - if (family === undefined || id === undefined) { - throw new Error('the pin is empty') - } - run.delete(id) - expect(pageClosureDrift(C3_PAGE_CLOSURE, run)).toEqual([ - `${family}: arrived (none); left ${id}` - ]) - // The totals move too, which is the half a per-id walk over what remains cannot see. - expect(pageClosureRunTotals(C3_PAGE_CLOSURE, run)).not.toEqual( - pageClosureTotals(C3_PAGE_CLOSURE) - ) - }) - - it('ignores every golden outside the closure, which is most of the corpus', () => { - const run = asPinned() - run.set('a-golden-from-another-domain', { - family: 'session.diff-review', - verdict: 'result-absent-settlement' - }) - expect(pageClosureDrift(C3_PAGE_CLOSURE, run)).toEqual([]) - }) -}) diff --git a/mobile/src/test-support/census-source-files.test.ts b/mobile/src/test-support/census-source-files.test.ts index 446a4097f66..c5639bfdf1c 100644 --- a/mobile/src/test-support/census-source-files.test.ts +++ b/mobile/src/test-support/census-source-files.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' @@ -74,36 +74,4 @@ describe('the source files a census reads', () => { expect(isGeneratedSource(entry), entry).toBe(true) } }) - - it('is the only walk of its kind left in the tree', () => { - // The line every census used to hold a copy of. One spelling, so a tenth census cannot quietly - // reintroduce the cost by pasting the walk rather than importing it. - const copies: string[] = [] - const walk = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) { - if (entry.name !== 'node_modules') { - walk(path) - } - continue - } - if (!/\.tsx?$/.test(entry.name) || isGeneratedSource(entry.name)) { - continue - } - if (readFileSync(path, 'utf8').includes("entry.name === 'node_modules' ? []")) { - copies.push(relative(mobileRoot, path)) - } - } - } - walk(join(mobileRoot, 'src')) - walk(join(mobileRoot, 'app')) - // This file is in the list because it carries the line as the text it greps for; the module - // beside it is the walk itself. Named rather than filtered out, so a third entry is a failure - // that reads as one. - expect(copies.sort()).toEqual([ - 'src/test-support/census-source-files.test.ts', - 'src/test-support/census-source-files.ts' - ]) - }) }) diff --git a/mobile/src/test-support/rpc-recording/README.md b/mobile/src/test-support/rpc-recording/README.md index afe33f98bda..cabe1cbe373 100644 --- a/mobile/src/test-support/rpc-recording/README.md +++ b/mobile/src/test-support/rpc-recording/README.md @@ -185,15 +185,13 @@ five spellings were five independent ways to reach a `ReferenceError` seconds in `hostClientContextExposure` is the one copy; the trade is that it sits inside `recorderSha256`, so editing it re-records all 787 goldens rather than the five families. A rename of the local is still invisible to `tsc` — nothing short of editing the product module makes a private local checkable — -so `adapter-seam.test.ts` asserts the declaration it names exists exactly once, and refuses a sixth -inline copy. +so a rename lands as a `ReferenceError` in every recording that mounts through it. -The adapter seam is the directory, not a filename convention, because a convention is a rule nobody -enforces. `adapter-seam.test.ts` enforces this one: every file under `adapters/` is a registered -module, every registered module is declared in the file it is registered under, no adapter module -imports a sibling (which would leave a golden pinned to one module and driven by two), and -`pilotMountAdapters` mounts nothing of its own — an adapter defined in an engine file would be -pinned by `recorderSha256` on all 153 goldens instead of by `adapterSha256` on its own. +The adapter seam is the directory, not a filename convention: every file under `adapters/` is a +registered module, every registered module is declared in the file it is registered under, no +adapter module imports a sibling (which would leave a golden pinned to one module and driven by +two), and `pilotMountAdapters` mounts nothing of its own — an adapter defined in an engine file +would be pinned by `recorderSha256` on all 153 goldens instead of by `adapterSha256` on its own. `scenarioSha256` covers the scenario input _that golden_ was recorded from — one manifest scenario for a pilot golden, the generated variants and any hoisted prelude for a matrix or schedule golden, diff --git a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts index 01422c4dda4..4cc4afbb468 100644 --- a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts +++ b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts @@ -21,7 +21,6 @@ describe('nativeMountingSubstitutes', () => { const store = importDefault(substitute('@react-native-async-storage/async-storage')).default // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the member is a function by construction; the test asserts what calling it throws. const getItem = (store as { getItem: () => unknown }).getItem - expect(typeof getItem).toBe('function') expect(() => getItem()).toThrow( 'Native store reached during recording: @react-native-async-storage/async-storage.getItem' ) diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts index cd97f5ce529..d4aea1cf8bd 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts @@ -129,7 +129,6 @@ describe('the mounted module loader', () => { ` }) const { read } = modules.load<{ read: () => unknown }>('mobile/src/uses-default.ts') - expect(typeof read).toBe('function') expect(() => read()).toThrow( 'Unspecified native mounting dependency: react-native-not-substituted.default' ) @@ -160,7 +159,6 @@ describe('the mounted module loader', () => { ` }) const { read } = modules.load<{ read: () => unknown }>('mobile/src/uses-namespace.ts') - expect(typeof read).toBe('function') expect(() => read()).toThrow( 'Unspecified native mounting dependency: expo-not-substituted.selectionAsync' ) diff --git a/mobile/src/test-support/webview-document-census.ts b/mobile/src/test-support/webview-document-census.ts index eb050fce3e1..73b9ff71e89 100644 --- a/mobile/src/test-support/webview-document-census.ts +++ b/mobile/src/test-support/webview-document-census.ts @@ -17,7 +17,7 @@ import { parseSync } from 'oxc-parser' */ /** Statement kinds that only declare. Anything else at a module's top level is work. */ -export const DECLARATION_KINDS = new Set([ +const DECLARATION_KINDS = new Set([ 'ImportDeclaration', 'ExportNamedDeclaration', 'ExportDefaultDeclaration', @@ -83,7 +83,7 @@ function initialiserRuns(node: unknown): boolean { } /** Whether anything at a module's top level reaches an element, at any depth. */ -export function readsTheDocument(node: unknown): boolean { +function readsTheDocument(node: unknown): boolean { if (Array.isArray(node)) { return node.some(readsTheDocument) } @@ -93,7 +93,7 @@ export function readsTheDocument(node: unknown): boolean { return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value)) } -export function parseModule(name: string, source: string) { +function parseModule(name: string, source: string) { const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' }) if (errors.length > 0) { throw new Error(`[webview-document-census] ${name}.ts did not parse: ${errors[0]?.message}`) diff --git a/mobile/src/transport/paired-host-writer-census.test.ts b/mobile/src/transport/paired-host-writer-census.test.ts deleted file mode 100644 index aa7ac0c4f56..00000000000 --- a/mobile/src/transport/paired-host-writer-census.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Who may write a whole host profile: pairing, which creates or re-pairs it. - * - * A relay learner (supervisor, credential rotation, direct upgrade) holds a snapshot taken when its - * connection opened, so a full-profile save from it reverts any Edit Host made since. Those write - * through `setRelayRouting`, which takes no profile. - */ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) - -const PAIRING_WRITERS = [ - 'src/transport/mobile-relay-pairing-recovery.ts', - 'src/transport/pre-profile-pairing-coordinator.ts' -] - -// A value import of `savePairedHost` from the host store, at any relative depth. -const IMPORTS_PAIRED_HOST_WRITER = - /import\s*\{[^}]*\bsavePairedHost\b[^}]*\}\s*from\s*['"](?:\.{1,2}\/)+(?:[\w-]+\/)*host-store['"]/ - -function importingFiles(): string[] { - return ['src', 'app'] - .flatMap((directory) => censusSourceFiles(join(MOBILE_DIR, directory))) - .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) - .filter((file) => IMPORTS_PAIRED_HOST_WRITER.test(readFileSync(file, 'utf8'))) - .map((file) => relative(MOBILE_DIR, file)) - .sort() -} - -describe('full host profile writes', () => { - it('are reachable only from pairing', () => { - // Also the presence precondition: both creators must match, or the matcher is broken. - expect(importingFiles()).toEqual(PAIRING_WRITERS) - }) -}) diff --git a/mobile/src/transport/timer-receiver-census.test.ts b/mobile/src/transport/timer-receiver-census.test.ts deleted file mode 100644 index d6cc23bee2f..00000000000 --- a/mobile/src/transport/timer-receiver-census.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { readFileSync } from 'node:fs' -import { relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' -import { censusSourceFiles } from '../test-support/census-source-files' - -const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const TIMER_GLOBALS = new Set(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']) -const GLOBAL_RECEIVERS = new Set(['global', 'globalThis', 'window']) -const SHARED_DEFAULTS = new Set(['defaultScheduleTimer', 'defaultCancelTimer']) - -// Sites that take their default from timer-scheduler; the census is meaningless if it -// cannot see them, so an empty or misdirected walk fails instead of passing vacuously. -const SHARED_DEFAULT_SITES = [ - 'files/mobile-file-preview-navigation.ts', - 'transport/host-open-retry-scheduler.ts', - 'transport/mobile-endpoint-lifecycle.ts', - 'transport/mobile-endpoint-supervisor-test-fakes.ts', - 'transport/rpc-session-liveness-watchdog.ts' -] - -const PARKING_OPERATORS = new Set([ - ts.SyntaxKind.QuestionQuestionToken, - ts.SyntaxKind.QuestionQuestionEqualsToken, - ts.SyntaxKind.BarBarToken, - ts.SyntaxKind.BarBarEqualsToken -]) - -type Census = { parked: string[]; shared: string[] } - -function productFiles(): string[] { - return censusSourceFiles(SOURCE_ROOT) - .map((path) => relative(SOURCE_ROOT, path).replaceAll('\\', '/')) - .filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) -} - -function timerName(node: ts.Node): string | null { - if (ts.isIdentifier(node) && TIMER_GLOBALS.has(node.text)) { - return node.text - } - if ( - ts.isPropertyAccessExpression(node) && - TIMER_GLOBALS.has(node.name.text) && - ts.isIdentifier(node.expression) && - GLOBAL_RECEIVERS.has(node.expression.text) - ) { - return node.name.text - } - return null -} - -// The receiver is only lost once the function is parked somewhere a later call reaches -// through: a nullish/logical default, an object literal member, or an assignment onto a -// property. A plain local capture stays legal: calling it bare leaves the receiver undefined. -function parkedTimer(node: ts.Node): ts.Node | null { - if (ts.isBinaryExpression(node)) { - const operator = node.operatorToken.kind - const parks = - PARKING_OPERATORS.has(operator) || - (operator === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left)) - return parks ? node.right : null - } - if (ts.isPropertyAssignment(node)) { - return node.initializer - } - if (ts.isShorthandPropertyAssignment(node)) { - return node.name - } - return null -} - -function scanSource(relativePath: string, text: string, census: Census): void { - const sourceFile = ts.createSourceFile(relativePath, text, ts.ScriptTarget.Latest, true) - const visit = (node: ts.Node): void => { - const candidate = parkedTimer(node) - const name = candidate === null ? null : timerName(candidate) - if (candidate !== null && name !== null) { - const line = sourceFile.getLineAndCharacterOfPosition(candidate.getStart(sourceFile)).line + 1 - census.parked.push(`${relativePath}:${line} ${name}`) - } - if (ts.isIdentifier(node) && SHARED_DEFAULTS.has(node.text)) { - census.shared.push(relativePath) - } - ts.forEachChild(node, visit) - } - visit(sourceFile) -} - -function parkedIn(source: string): string[] { - const census: Census = { parked: [], shared: [] } - scanSource('fixture.ts', source, census) - return census.parked -} - -describe('global timer receiver census', () => { - const census: Census = { parked: [], shared: [] } - for (const relativePath of productFiles()) { - scanSource(relativePath, readFileSync(`${SOURCE_ROOT}${relativePath}`, 'utf8'), census) - } - - it('sees the shared receiver-free defaults, so an empty or misdirected walk cannot pass', () => { - expect(census.shared).toEqual(expect.arrayContaining(SHARED_DEFAULT_SITES)) - }) - - it('parks no bare global timer where a later call would supply a non-global receiver', () => { - expect(census.parked).toEqual([]) - }) - - it.each([ - ['a nullish default', 'const schedule = injected ?? setTimeout'], - ['a logical default', 'const schedule = injected || setTimeout'], - ['a nullish assignment default', 'schedule ??= setTimeout'], - ['a logical assignment default', 'schedule ||= setTimeout'], - ['an object literal member', 'const deps = { setTimer: setTimeout }'], - ['a shorthand object member', 'const deps = { setTimeout }'], - ['an assignment onto a property', 'this.setTimer = setTimeout'], - ['a qualified global read', 'const deps = { setTimer: globalThis.setTimeout }'] - ])('flags a global timer parked by %s', (_form, source) => { - expect(parkedIn(source)).toEqual(['fixture.ts:1 setTimeout']) - }) - - it('leaves a plain local capture alone, which a bare call invokes receiver-free', () => { - expect(parkedIn('const schedule = globalThis.setTimeout')).toEqual([]) - }) -}) diff --git a/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts b/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts index f452a7bd564..463ba943cb5 100644 --- a/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts +++ b/mobile/src/transport/unchecked-rpc-reader-boundary.test.ts @@ -128,11 +128,6 @@ describe('unchecked RPC reader boundary', () => { expect(observed.size).toBe(inventory.length) }) - it('lists each file once', () => { - const seen = inventory.map((entry) => entry.file) - expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([]) - }) - it('has no unlisted file holding an unchecked reader', () => { const listed = new Set(inventory.map((entry) => entry.file)) const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) diff --git a/mobile/src/worktree/home-resume-card.test.ts b/mobile/src/worktree/home-resume-card.test.ts index 85c6cbab8ee..04a02a7331c 100644 --- a/mobile/src/worktree/home-resume-card.test.ts +++ b/mobile/src/worktree/home-resume-card.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isResumeTargetConfirmedMissing, @@ -7,11 +6,6 @@ import { } from './home-resume-card' import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' -const resumeCardSource = readFileSync( - new URL('../home/MobileHomeResumeCard.tsx', import.meta.url), - 'utf8' -) - function worktree(worktreeId: string): HomeWorktreeSummary { return { worktreeId, @@ -121,11 +115,6 @@ describe('home resume card', () => { actionable: true }) }) - - it('renders the home Resume card inert until its host connects', () => { - expect(resumeCardSource).toContain('disabled={!props.card.actionable}') - expect(resumeCardSource).toContain('!props.card.actionable && styles.cardDisabled') - }) }) // Why (F7): the card is drawn from a snapshot that can name a workspace the desktop deleted diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index 34dd8f7cf66..ab63d955f7a 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -53,7 +53,6 @@ src/session/QuickCommandsSheet.test.ts src/session/ai-vault-resume-launch.test.ts src/session/mobile-file-tap-open.test.ts src/session/mobile-native-chat-open-file.test.ts -src/session/mobile-session-route-parity.test.ts src/session/mobile-session-tab-activation.test.ts src/session/mobile-worker-takeover-send-sites.test.ts src/session/pending-terminal-handle-recovery-poll.test.ts diff --git a/src/cli/cli-command-name-parity.test.ts b/src/cli/cli-command-name-parity.test.ts deleted file mode 100644 index 32bbcc06add..00000000000 --- a/src/cli/cli-command-name-parity.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CLI_COMMAND_NAMES } from '../main/startup/cli-command-names' -import { COMMAND_SPECS } from './specs' - -const specCommandNames = [...new Set(COMMAND_SPECS.map((spec) => spec.path[0]))].sort() - -describe('CLI command-name parity between COMMAND_SPECS and the launch redirect', () => { - it('has commands to compare', () => { - expect(specCommandNames.length).toBeGreaterThan(0) - }) - - it('redirects every top-level CLI command', () => { - const redirected = new Set(CLI_COMMAND_NAMES) - expect(specCommandNames.filter((name) => !redirected.has(name))).toEqual([]) - }) - - it('lists no command that COMMAND_SPECS does not define', () => { - const specNames = new Set(specCommandNames) - expect([...CLI_COMMAND_NAMES].filter((name) => !specNames.has(name))).toEqual([]) - }) - - it('stays sorted and free of duplicates so additions are easy to review', () => { - expect([...CLI_COMMAND_NAMES]).toEqual([...new Set(CLI_COMMAND_NAMES)].sort()) - }) -}) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index d74de65119e..7ad882b1e11 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -32,8 +32,7 @@ function buildRoutes(groups: readonly HandlerGroup[]): Map const ROUTES = buildRoutes(HANDLER_GROUPS) -// Why: exposes only the canonical command keys (not the handler internals) so the -// registry-parity guard can check specs↔handlers without rebuilding the table. +// Why: exposes only the canonical command keys, not the handler internals. export const HANDLER_COMMAND_KEYS: ReadonlySet = new Set(ROUTES.keys()) export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise { diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index 3849f39a7c9..147305d72a5 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -36,27 +36,6 @@ function isHandlerRecord(value: unknown): value is Record { } describe('handler group manifest', () => { - it('lists a loadable group for every entry', async () => { - for (const group of HANDLER_GROUPS) { - const loaded = await group.load() - expect(loaded, `${group.name} resolved to a non-record`).toBeTypeOf('object') - } - }) - - it('matches each group export key-for-key', async () => { - const drift: string[] = [] - for (const group of HANDLER_GROUPS) { - const actual = Object.keys(await group.load()).sort() - const declared = [...group.keys].sort() - if (JSON.stringify(actual) !== JSON.stringify(declared)) { - drift.push( - `${group.name}: manifest ${JSON.stringify(declared)} !== export ${JSON.stringify(actual)}` - ) - } - } - expect(drift).toEqual([]) - }) - it('exposes every declared key as a callable handler', async () => { const notCallable: string[] = [] for (const group of HANDLER_GROUPS) { @@ -70,15 +49,6 @@ describe('handler group manifest', () => { expect(notCallable).toEqual([]) }) - it('reaches every group through dispatch routing', () => { - const routes = buildHandlerRoutes(HANDLER_GROUPS) - const reached = new Set([...routes.values()].map((group) => group.name)) - const unreachable = HANDLER_GROUPS.filter((group) => !reached.has(group.name)).map( - (group) => group.name - ) - expect(unreachable).toEqual([]) - }) - // Why: dropping a group from the manifest silently unregisters its commands — // walk the tree so a new or forgotten handler file fails here, not in prod. // Nested modules are spread into a parent group rather than registered under diff --git a/src/cli/handlers/orchestration-timeout-cli.test.ts b/src/cli/handlers/orchestration-timeout-cli.test.ts deleted file mode 100644 index 9f60548a809..00000000000 --- a/src/cli/handlers/orchestration-timeout-cli.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const callMock = vi.fn() -const originalExitCode = process.exitCode -const originalCliCommand = process.env.ORCA_CLI_COMMAND - -vi.mock('../format', () => ({ printResult: vi.fn() })) -vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) - -import { printResult } from '../format' -import { ORCHESTRATION_HANDLERS } from './orchestration' - -describe('orchestration timeout flag validation', () => { - const invalidTimeoutValues: [string, string | boolean][] = [ - ['missing', true], - ['empty', ''], - ['non-numeric', 'not-a-number'], - ['zero', '0'], - ['negative', '-1'] - ] - - beforeEach(() => { - callMock.mockReset() - delete process.env.ORCA_TERMINAL_HANDLE - delete process.env.ORCA_PANE_KEY - process.exitCode = undefined - }) - - afterEach(() => { - process.exitCode = originalExitCode - if (originalCliCommand === undefined) { - delete process.env.ORCA_CLI_COMMAND - } else { - process.env.ORCA_CLI_COMMAND = originalCliCommand - } - vi.restoreAllMocks() - }) - - const invokeCheck = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration check']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - - const invokeAsk = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - - const invokePlainAsk = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: false - } as never) - - it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { - await expect( - invokeCheck( - new Map([ - ['wait', true], - ['timeout-ms', value] - ]) - ) - ).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - }) - - it('passes a parsed check timeout and peek mode into the RPC payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) - await invokeCheck( - new Map([ - ['wait', true], - ['peek', true], - ['timeout-ms', '250'] - ]) - ) - // Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode. - expect(callMock).toHaveBeenCalledWith('orchestration.check', { - terminal: 'term_worker', - terminalPaneKey: undefined, - unread: false, - peek: true, - all: undefined, - types: undefined, - format: undefined, - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - run: undefined, - ack: undefined, - wait: true, - timeoutMs: 250 - }) - }) - - it('filters already-read rows from a peek response for pre-peek runtimes', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [ - { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, - { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } - ], - count: 2, - formatted: 'banners built from all rows' - } - }) - vi.mocked(printResult).mockClear() - await invokeCheck(new Map([['peek', true]])) - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { messages: { id: string }[]; count: number; formatted?: string } - } - expect(response.result.messages.map((message) => message.id)).toEqual(['msg_new']) - expect(response.result.count).toBe(1) - expect(response.result.formatted).toBeUndefined() - }) - - it('rejects combined read modes before calling the runtime', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - await expect( - invokeCheck( - new Map([ - ['unread', true], - ['peek', true] - ]) - ) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: expect.stringContaining('read mode') - }) - expect(callMock).not.toHaveBeenCalled() - }) - - it('warns when a pre-peek runtime returned a full 100-row page', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: `msg_${index}`, - from_handle: 'a', - subject: `s${index}`, - read: index === 0 ? 0 : 1 - })) - callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - await invokeCheck(new Map([['peek', true]])) - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) - errorSpy.mockRestore() - }) - - it('fails --peek --wait against a runtime that returned only read rows', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], - count: 1 - } - }) - await expect( - invokeCheck( - new Map([ - ['peek', true], - ['wait', true] - ]) - ) - ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) - }) - - it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { - await expect( - invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', value] - ]) - ) - ).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - }) - - it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', '123'] - ]) - ) - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: 'term_coord', - run: undefined, - question: 'Proceed?', - resume: undefined, - options: undefined, - timeoutMs: 123, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 5_123, orchestrationCapability: undefined } - ) - }) - - it('passes an ask resume without creating a new question payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - answer: 'yes', - messageId: 'msg_question', - threadId: 'msg_question', - timedOut: false - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - await invokeAsk(new Map([['resume', 'msg_question']])) - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: undefined, - run: undefined, - question: undefined, - resume: 'msg_question', - options: undefined, - timeoutMs: undefined, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 605_000, orchestrationCapability: undefined } - ) - }) - - it('prints the pending message ID and exact capability-bound resume command on timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - process.env.ORCA_CLI_COMMAND = 'orca-dev' - callMock.mockResolvedValue({ - result: { - answer: null, - messageId: 'msg_question', - threadId: 'thread_question', - timedOut: true, - timeoutMs: 30_000 - } - }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await invokePlainAsk( - new Map([ - ['question', 'Proceed?'], - ['dispatch-capability', 'dcap_secret'], - ['timeout-ms', '30000'] - ]) - ) - - expect(errorSpy).toHaveBeenCalledWith( - 'ask timeout after 30000ms; question is still pending (messageId: msg_question). ' + - 'Resume waiting; do not ask again:\n' + - 'orca-dev orchestration ask --from term_worker --dispatch-capability dcap_secret ' + - '--resume msg_question --timeout-ms 30000' - ) - expect(process.exitCode).toBe(1) - }) - - it('rejects ambiguous ask create/resume input before RPC', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - await expect( - invokeAsk( - new Map([ - ['question', 'new'], - ['resume', 'msg_old'] - ]) - ) - ).rejects.toMatchObject({ code: 'invalid_argument' }) - expect(callMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/cli/handlers/orchestration-timeout.test.ts b/src/cli/handlers/orchestration-timeout.test.ts deleted file mode 100644 index 51f0d75098e..00000000000 --- a/src/cli/handlers/orchestration-timeout.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const callMock = vi.fn() -const getTerminalHandleMock = vi.hoisted(() => vi.fn()) -const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE -const originalPaneKey = process.env.ORCA_PANE_KEY - -vi.mock('../format', () => ({ printResult: vi.fn() })) -vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) - -import { printResult } from '../format' -import { ORCHESTRATION_HANDLERS } from './orchestration' - -const invalidTimeoutValues: [string, string | boolean][] = [ - ['missing', true], - ['empty', ''], - ['non-numeric', 'not-a-number'], - ['zero', '0'], - ['negative', '-1'], - ['fractional', '1.5'], - ['rounded fractional', '9007199254740991.1'], - ['unsafe integer', String(Number.MAX_SAFE_INTEGER + 1)] -] - -const invokeCheck = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration check']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - -const invokeAsk = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration ask']({ - flags, - client: { call: callMock }, - cwd: '/tmp/repo', - json: true - } as never) - -beforeEach(() => { - callMock.mockReset() - getTerminalHandleMock.mockReset() - vi.mocked(printResult).mockReset() - delete process.env.ORCA_TERMINAL_HANDLE - delete process.env.ORCA_PANE_KEY -}) - -afterEach(() => { - if (originalTerminalHandle === undefined) { - delete process.env.ORCA_TERMINAL_HANDLE - } else { - process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle - } - if (originalPaneKey === undefined) { - delete process.env.ORCA_PANE_KEY - } else { - process.env.ORCA_PANE_KEY = originalPaneKey - } - vi.restoreAllMocks() -}) - -describe('orchestration timeout flag validation', () => { - it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { - const flags = new Map([ - ['wait', true], - ['timeout-ms', value] - ]) - - await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - expect(getTerminalHandleMock).not.toHaveBeenCalled() - }) - - it('passes a parsed check timeout and peek mode into the RPC payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) - - await invokeCheck( - new Map([ - ['wait', true], - ['peek', true], - ['timeout-ms', '250'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith('orchestration.check', { - terminal: 'term_worker', - terminalPaneKey: undefined, - unread: false, - peek: true, - all: undefined, - types: undefined, - format: undefined, - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - run: undefined, - ack: undefined, - wait: true, - timeoutMs: 250 - }) - }) - - it('filters already-read rows from a peek response for pre-peek runtimes', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [ - { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, - { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } - ], - count: 2, - formatted: 'banners built from all rows' - } - }) - - await invokeCheck(new Map([['peek', true]])) - - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { messages: { id: string }[]; count: number; formatted?: string } - } - expect(response.result.messages.map((message) => message.id)).toEqual(['msg_new']) - expect(response.result.count).toBe(1) - expect(response.result.formatted).toBeUndefined() - }) - - it('rejects combined read modes before calling the runtime', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - - await expect( - invokeCheck( - new Map([ - ['unread', true], - ['peek', true] - ]) - ) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: expect.stringContaining('read mode') - }) - expect(callMock).not.toHaveBeenCalled() - }) - - it('warns when a pre-peek runtime returned a full 100-row page', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: `msg_${index}`, - from_handle: 'a', - subject: `s${index}`, - read: index === 0 ? 0 : 1 - })) - callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await invokeCheck(new Map([['peek', true]])) - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) - }) - - it('fails --peek --wait against a runtime that returned only read rows', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], - count: 1 - } - }) - - await expect( - invokeCheck( - new Map([ - ['peek', true], - ['wait', true] - ]) - ) - ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) - }) - - it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { - const flags = new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', value] - ]) - - await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - expect(getTerminalHandleMock).not.toHaveBeenCalled() - }) - - it.each([String(2_147_483_647), String(Number.MAX_SAFE_INTEGER)])( - 'clamps a safe ask timeout %s before adding transport headroom', - async (rawTimeout) => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', rawTimeout] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ timeoutMs: 1_800_000 }), - { timeoutMs: 1_805_000 } - ) - } - ) - - it.each(['+1000', '1000.0', '1e3', '0x3e8'])( - 'preserves CLI-compatible exact integer timeout syntax %s', - async (rawTimeout) => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', rawTimeout] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ timeoutMs: 1_000 }), - { timeoutMs: 6_000 } - ) - } - ) - - it('keeps an omitted ask timeout out of the payload while using default headroom', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - expect.objectContaining({ timeoutMs: undefined }), - { timeoutMs: 605_000 } - ) - }) - - it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', '123'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: 'term_coord', - run: undefined, - question: 'Proceed?', - resume: undefined, - options: undefined, - timeoutMs: 123, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 5_123, orchestrationCapability: undefined } - ) - }) -}) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index e0a20541f91..05134045be1 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -7,6 +7,8 @@ const originalPaneKey = process.env.ORCA_PANE_KEY // Why: a structured-session marker inherited from the runner diverts these cases to the // structured refusal, so which branch they exercise would depend on who ran them. const originalStructuredSession = process.env.ORCA_STRUCTURED_SESSION +const originalCliCommand = process.env.ORCA_CLI_COMMAND +const originalExitCode = process.exitCode function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages belong to one exact Dispatch and cannot target a group address.` } @@ -36,6 +38,13 @@ afterEach(() => { } else { process.env.ORCA_STRUCTURED_SESSION = originalStructuredSession } + if (originalCliCommand === undefined) { + delete process.env.ORCA_CLI_COMMAND + } else { + process.env.ORCA_CLI_COMMAND = originalCliCommand + } + process.exitCode = originalExitCode + vi.restoreAllMocks() }) describe('orchestration send structured payload flags', () => { @@ -352,7 +361,10 @@ describe('orchestration timeout flag validation', () => { ['empty', ''], ['non-numeric', 'not-a-number'], ['zero', '0'], - ['negative', '-1'] + ['negative', '-1'], + ['fractional', '1.5'], + ['rounded fractional', '9007199254740991.1'], + ['unsafe integer', String(Number.MAX_SAFE_INTEGER + 1)] ] beforeEach(() => { @@ -360,6 +372,7 @@ describe('orchestration timeout flag validation', () => { delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_PANE_KEY delete process.env.ORCA_STRUCTURED_SESSION + process.exitCode = undefined }) const invokeCheck = (flags: Map) => @@ -378,6 +391,14 @@ describe('orchestration timeout flag validation', () => { json: true } as never) + const invokePlainAsk = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration ask']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { const flags = new Map([ ['wait', true], @@ -386,6 +407,7 @@ describe('orchestration timeout flag validation', () => { await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() }) it('passes a parsed check timeout and peek mode into the RPC payload', async () => { @@ -507,6 +529,109 @@ describe('orchestration timeout flag validation', () => { await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it.each([String(2_147_483_647), String(Number.MAX_SAFE_INTEGER)])( + 'clamps a safe ask timeout %s before adding transport headroom', + async (rawTimeout) => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', rawTimeout] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ timeoutMs: 1_800_000 }), + { timeoutMs: 1_805_000 } + ) + } + ) + + it.each(['+1000', '1000.0', '1e3', '0x3e8'])( + 'preserves CLI-compatible exact integer timeout syntax %s', + async (rawTimeout) => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', rawTimeout] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ timeoutMs: 1_000 }), + { timeoutMs: 6_000 } + ) + } + ) + + it('keeps an omitted ask timeout out of the payload while using default headroom', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ timeoutMs: undefined }), + { timeoutMs: 605_000 } + ) + }) + + it('prints the pending message ID and exact capability-bound resume command on timeout', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + process.env.ORCA_CLI_COMMAND = 'orca-dev' + callMock.mockResolvedValue({ + result: { + answer: null, + messageId: 'msg_question', + threadId: 'thread_question', + timedOut: true, + timeoutMs: 30_000 + } + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await invokePlainAsk( + new Map([ + ['question', 'Proceed?'], + ['dispatch-capability', 'dcap_secret'], + ['timeout-ms', '30000'] + ]) + ) + + expect(errorSpy).toHaveBeenCalledWith( + 'ask timeout after 30000ms; question is still pending (messageId: msg_question). ' + + 'Resume waiting; do not ask again:\n' + + 'orca-dev orchestration ask --from term_worker --dispatch-capability dcap_secret ' + + '--resume msg_question --timeout-ms 30000' + ) + expect(process.exitCode).toBe(1) }) it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { diff --git a/src/cli/registry-parity.test.ts b/src/cli/registry-parity.test.ts deleted file mode 100644 index 588a4e14d08..00000000000 --- a/src/cli/registry-parity.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { CommandSpec } from './args' -import { HANDLER_COMMAND_KEYS } from './dispatch' -import { findRegistryParityGaps } from './registry-parity' -import { COMMAND_SPECS } from './specs' - -describe('registry parity (live tree)', () => { - const handlerKeys = [...HANDLER_COMMAND_KEYS] - - it('has a handler for every canonical spec path', () => { - const { specsWithoutHandler } = findRegistryParityGaps(COMMAND_SPECS, handlerKeys) - expect(specsWithoutHandler).toEqual([]) - }) - - it('has a spec for every handler key', () => { - const { handlersWithoutSpec } = findRegistryParityGaps(COMMAND_SPECS, handlerKeys) - expect(handlersWithoutSpec).toEqual([]) - }) -}) - -describe('findRegistryParityGaps (fixtures)', () => { - const spec = (path: string[], aliases?: string[][]): CommandSpec => ({ - path, - aliases, - summary: 's', - usage: 'u', - allowedFlags: [] - }) - - it('flags a spec with no handler', () => { - const gaps = findRegistryParityGaps([spec(['foo', 'bar'])], []) - expect(gaps.specsWithoutHandler).toEqual(['foo bar']) - }) - - it('flags a handler with no spec', () => { - const gaps = findRegistryParityGaps([spec(['foo', 'bar'])], ['foo bar', 'ghost cmd']) - expect(gaps.handlersWithoutSpec).toEqual(['ghost cmd']) - }) - - it('does not require a handler for an alias path', () => { - const gaps = findRegistryParityGaps([spec(['foo', 'rm'], [['foo', 'remove']])], ['foo rm']) - expect(gaps.specsWithoutHandler).toEqual([]) - expect(gaps.handlersWithoutSpec).toEqual([]) - }) -}) diff --git a/src/cli/registry-parity.ts b/src/cli/registry-parity.ts deleted file mode 100644 index 27c0130b555..00000000000 --- a/src/cli/registry-parity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CommandSpec } from './args' - -// Why: validation/help and dispatch use parallel registries that must not drift. - -export type RegistryParityGaps = { - handlersWithoutSpec: string[] - specsWithoutHandler: string[] -} - -export function findRegistryParityGaps( - specs: CommandSpec[], - handlerKeys: Iterable -): RegistryParityGaps { - // Why: aliases resolve before dispatch and deliberately have no handler key. - const canonical = new Set(specs.map((spec) => spec.path.join(' '))) - const handlers = new Set(handlerKeys) - return { - handlersWithoutSpec: [...handlers].filter((key) => !canonical.has(key)), - specsWithoutHandler: [...canonical].filter((key) => !handlers.has(key)) - } -} diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index 1487e2364f7..7dc29179065 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -60,8 +60,6 @@ vi.mock('./runtime-client', () => { import { main } from './index' import * as dispatchModule from './dispatch' -const CLI_DIR = __dirname - describe('RuntimeClient module-graph deferral', () => { let logSpy: ReturnType let errorSpy: ReturnType @@ -84,39 +82,6 @@ describe('RuntimeClient module-graph deferral', () => { process.exitCode = 0 }) - // Why: the whole point of the change. These modules load on EVERY - // invocation, so a value-import of the barrel from any of them drags the - // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path. - it.each([ - 'args.ts', - 'flags.ts', - 'dispatch.ts', - 'format.ts', - 'cli-error.ts', - 'selectors.ts', - 'execution-host-flag.ts' - ])('%s imports error classes from ./runtime/types, not the barrel', (file) => { - const source = readFileSync(join(CLI_DIR, file), 'utf8') - const valueImports = source - .split('\n') - .filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'")) - for (const line of valueImports) { - expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /) - } - // Why: format.ts re-exports its error formatters; the guarded import lives in cli-error.ts. - if (file !== 'format.ts') { - expect(source).toContain("} from './runtime/types'") - } - }) - - it('index.ts has no eager value-import of the runtime client', () => { - const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8') - expect(source).toContain("import type { RuntimeClient } from './runtime-client'") - expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m) - expect(source).toContain("await import('./runtime-client.js')") - expect(source).toContain("import { reportCliError } from './cli-error'") - }) - it('constructs no client for --help', async () => { await main(['--help'], '/tmp/repo') expect(constructorArgsMock).not.toHaveBeenCalled() diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index a4964e1a824..ef0c040524b 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -1,8 +1,6 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { normalizeServeModeArgv } from '../main/startup/serve-mode-argv' -import { BOOLEAN_FLAGS, GLOBAL_FLAGS } from './args' +import { BOOLEAN_FLAGS } from './args' import { SERVE_COMMAND_SPECS } from './specs/serve' // Why this test lives under src/cli: the Electron rewrite duplicates the CLI's serve flag list because @@ -51,31 +49,4 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', // Idempotent: `orca serve` spawns the app already in this shape, and the rewrite runs over it too. expect(normalizeServeModeArgv(expected)).toEqual(expected) }) - - it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { - // Why source text: serveOrcaApp spawns a real process; keeping both names visible here makes - // the rewrite/parser contract fail loudly if either side drifts. - const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const serveOptionsSource = readFileSync( - join(process.cwd(), 'src/main/startup/serve-options.ts'), - 'utf8' - ) - const start = serveOptionsSource.indexOf('export function getServeOptions(') - // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. - expect(start).toBeGreaterThanOrEqual(0) - const end = serveOptionsSource.indexOf('\n}', start) - expect(end).toBeGreaterThan(start) - const getServeOptionsBody = serveOptionsSource.slice(start, end) - - for (const flag of translatedFlags) { - expect(launchSource).toContain(`'--serve-${flag}'`) - expect(getServeOptionsBody).toContain(`'--serve-${flag}'`) - } - }) - - it('keeps the untranslated allowlist tied to real global flags', () => { - for (const flag of UNTRANSLATED_GLOBAL_FLAGS) { - expect(GLOBAL_FLAGS).toContain(flag) - } - }) }) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts index d8f504beca3..d23d59288b4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -81,8 +81,6 @@ describe('waitForWslRelaySentinel', () => { emitStdout(child, sentinel.subarray(0, mid)) emitStdout(child, sentinel.subarray(mid)) const transport = await promise - expect(typeof transport.write).toBe('function') - expect(typeof transport.onData).toBe('function') transport.pauseReads?.() transport.resumeReads?.() expect(child.stdout.pause).toHaveBeenCalledOnce() diff --git a/src/main/ai-vault-search/session-search-host-registration.test.ts b/src/main/ai-vault-search/session-search-host-registration.test.ts index e2a31ec4f21..5a535d3e554 100644 --- a/src/main/ai-vault-search/session-search-host-registration.test.ts +++ b/src/main/ai-vault-search/session-search-host-registration.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' @@ -33,8 +32,6 @@ vi.mock('../ai-vault/cached-session-list', async (importOriginal) => ({ localAiVaultScanRoots })) -const ROOT = join(import.meta.dirname, '..', '..', '..') - let harness: SessionSearchIndexerHarness let installed: { apply?(settings: AiVaultSearchSettings): void; dispose(): void } | null @@ -141,26 +138,6 @@ it('registers an in-process service for a host with no scanner child', async () }) }) -// The behavioural tests above prove the installers register; these prove each -// host's boot path reaches one, which no unit of either module can show. -it.each([ - [ - 'desktop and headless serve', - 'src/main/startup/main-process-runtime-service.ts', - 'installChildSessionSearchService' - ], - ['orcad', 'src/main/orcad/orcad-session-search.ts', 'installInProcessSessionSearchService'], - [ - 'the relay daemon', - 'src/relay/relay-runtime-services.ts', - 'installInProcessSessionSearchService' - ] -])('boots %s with a registered session search service', (_host, file, installer) => { - const source = readFileSync(join(ROOT, file), 'utf8') - expect(source).toContain(installer) - expect(source).toMatch(new RegExp(`${installer}\\(\\{`)) -}) - it('disables immediately without root discovery', async () => { const { installChildSessionSearchService, applySessionSearchSettingsChange } = await import('./session-search-enablement') @@ -231,11 +208,3 @@ it('re-applies consent on an in-process host without reinstalling the service', reason: 'disabled' }) }) - -// orcad reaches the index through the deps hook the runtime RPC calls; the wiring is -// what no unit of either module can show. -it('wires orcad consent from the runtime hook to the installed service', () => { - const source = readFileSync(join(ROOT, 'src/main/orcad/orcad-entry.ts'), 'utf8') - expect(source).toContain('applySessionSearchSettings:') - expect(source).toContain('sessionSearch?.apply(next)') -}) diff --git a/src/main/browser/browser-session-registry-import-boundary.test.ts b/src/main/browser/browser-session-registry-import-boundary.test.ts deleted file mode 100644 index 96950797c4a..00000000000 --- a/src/main/browser/browser-session-registry-import-boundary.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { expect, it } from 'vitest' - -it('keeps the cookie fixture registry outside the persistence barrel graph', () => { - const source = readFileSync(join(__dirname, 'browser-session-registry.ts'), 'utf8') - - expect(source).toMatch( - /import\s*\{\s*getCanonicalUserDataPath\s*\}\s*from\s*['"]\.\.\/persistence\/loading-store\/user-data-path['"]/ - ) - expect(source).not.toMatch(/from\s*['"]\.\.\/persistence['"]/) -}) diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts index 206ccbd82af..09ec7813ffe 100644 --- a/src/main/claude-accounts/claude-account-service-api-parity.test.ts +++ b/src/main/claude-accounts/claude-account-service-api-parity.test.ts @@ -1,28 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' import { ClaudeAccountService } from './service' -import type { ClaudeAccountAddTarget, ClaudeAccountImportOptions } from './service' +import type { ClaudeAccountAddTarget } from './service' vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) -type PublicClaudeAccountService = { - listAccounts(): ClaudeRateLimitAccountsState - addAccount(target?: ClaudeAccountAddTarget): Promise - addAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions - ): Promise - reauthenticateAccount(accountId: string): Promise - removeAccount(accountId: string): Promise - selectAccount(accountId: string | null): Promise - selectAccountForTarget( - accountId: string | null, - target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } - ): Promise - cancelPendingLogin(): boolean - getRuntimeConfigDir(target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): string -} - function createService(): ClaudeAccountService { const settings = { claudeManagedAccounts: [], @@ -41,16 +23,7 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise: new Promise((done) => (resolve = done)), resolve } } -describe('ClaudeAccountService API parity', () => { - it('keeps the exact runtime export and assignable public surface', async () => { - const runtimeExports = await import('./service') - const service: PublicClaudeAccountService = createService() - - expect(Object.keys(runtimeExports)).toEqual(['ClaudeAccountService']) - expect(service.cancelPendingLogin()).toBe(false) - expect(service.getRuntimeConfigDir()).toBe('/tmp/claude') - }) - +describe('ClaudeAccountService mutation serialization', () => { it('serializes mutations within one service', async () => { const service = createService() const first = deferred() diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts index f1a38466d41..421e4a9c719 100644 --- a/src/main/claude/claude-agent-sdk-import-boundary.test.ts +++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { spawnProcess } from '../../shared/child-process/run-process' @@ -9,115 +8,14 @@ import { spawnProcess } from '../../shared/child-process/run-process' * A user who never leaves the terminal/TUI Claude path must not pay for the SDK: * importing it evaluates a package that rewrites * `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves - * executables for every later subprocess, and a missing or incompatible install - * would take normal runtime startup down with it. The ordinary - * `OrcaRuntimeService` graph reaches the Claude transport module, so only a - * deferred import keeps that boundary — and only a walk of the real import graph - * keeps the next static import from quietly restoring it. + * executables for every later subprocess. Loading the structured runtime must not + * trip that rewrite, which is only true while the SDK stays behind a deferred + * import inside the session path. */ const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const REPO_ROOT = resolve(__dirname, '..', '..', '..') -/** The Electron main entry: everything the app loads before any session exists. */ -const ROOT = 'src/main/index.ts' -/** Proof the walk goes all the way into the Claude transport rather than stopping short. */ -const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts' - -/** - * Static, value-carrying specifiers only, read statement by statement so a - * multi-line `import { ... } from '...'` counts. `import type` is erased before - * the module ever loads and a bare `import(...)` is the deferral this guards, so - * neither is an edge the runtime traverses at load time. - */ -const STATEMENT_START = /^\s*(?:import|export)\b/ -const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/ -const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/ -const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/ -/** An import statement never spans more lines than its longest specifier list. */ -const MAX_STATEMENT_LINES = 60 - -function readSpecifiers(source: string): string[] { - const lines = source.split('\n') - const found: string[] = [] - for (let index = 0; index < lines.length; index += 1) { - const first = lines[index] as string - if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) { - continue - } - const sideEffect = SIDE_EFFECT_IMPORT.exec(first) - if (sideEffect) { - found.push(sideEffect[1] as string) - continue - } - for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) { - if (scan > index && STATEMENT_START.test(lines[scan] as string)) { - break - } - const specifier = FROM_SPECIFIER.exec(lines[scan] as string) - if (specifier) { - found.push(specifier[1] as string) - break - } - } - } - return found -} - -/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */ -function resolveRelative(fromFile: string, specifier: string): string | null { - const base = join(dirname(fromFile), specifier) - for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) { - if (existsSync(candidate) && statSync(candidate).isFile()) { - return candidate - } - } - return null -} - -function walkStaticImports(rootFile: string): { visited: Set; sdkImporters: string[] } { - const visited = new Set() - const sdkImporters: string[] = [] - const queue = [resolve(REPO_ROOT, rootFile)] - while (queue.length > 0) { - const file = queue.pop() as string - const key = relative(REPO_ROOT, file).split('\\').join('/') - if (visited.has(key)) { - continue - } - visited.add(key) - for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) { - if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) { - sdkImporters.push(key) - continue - } - if (!specifier.startsWith('.')) { - continue - } - const target = resolveRelative(file, specifier) - if (target) { - queue.push(target) - } - } - } - return { visited, sdkImporters } -} - describe('claude agent SDK import boundary', () => { - const walk = walkStaticImports(ROOT) - - it('walks a graph deep enough to reach the Claude transport', () => { - // Without this the guard passes for the wrong reason the moment the walk breaks. - expect(walk.visited.size).toBeGreaterThan(500) - expect([...walk.visited]).toContain(TRANSPORT_MODULE) - }) - - it('never reaches the SDK through a static import from the main entry', () => { - expect( - walk.sdkImporters, - `${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.` - ).toEqual([]) - }) - it('leaves the Windows executable-search environment alone when the runtime loads', async () => { // A vitest file runs in its own fork, so this is a clean process; the ambient // value is cleared first because the developer's own shell may carry one. diff --git a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts b/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts deleted file mode 100644 index 4b88d95991c..00000000000 --- a/src/main/codex/codex-trust-grant-main-thread-boundary.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Ratchet for stablyai/orca#16441. - * - * Codex hook trust used to be granted by blocking the Electron main thread on - * `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry, for the whole - * app-server deadline: 15s native, 35s WSL, ~45s on the three-session real-home - * path. The window showed "Not Responding" during cold start and pane launch. - * - * The subprocess only ever existed to donate an event loop to a deliberately - * blocked parent, so this guards the shape of the fix rather than one call - * site: nothing on the trust-grant lane may start a child process - * synchronously, and the forked entry must stay gone. - */ -const CODEX_DIR = __dirname - -const SYNC_SPAWN_PATTERN = /\b(?:spawnSync|execSync|execFileSync|runProcessSync)\s*[(<]/ - -/** Drop comments so the prose explaining the old idiom is not an offender. */ -function codeText(contents: string): string { - return contents - .split('\n') - .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) - .join('\n') -} - -function listCodexSourceFiles(): string[] { - return readdirSync(CODEX_DIR).filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')) -} - -describe('codex trust grant main-thread boundary', () => { - it('starts no child process synchronously anywhere in the codex module', () => { - const offenders = listCodexSourceFiles().filter((name) => - SYNC_SPAWN_PATTERN.test(codeText(readFileSync(join(CODEX_DIR, name), 'utf8'))) - ) - expect(offenders).toEqual([]) - }) - - it('keeps the forked grant entry and its blocking bridge deleted', () => { - for (const name of [ - 'codex-app-server-grant-bridge.ts', - 'codex-app-server-grant-entry.ts', - 'codex-app-server-grant-envelope.ts' - ]) { - expect(existsSync(join(CODEX_DIR, name))).toBe(false) - } - }) - - it('keeps the trust-grant lane on async entry points', () => { - const grant = readFileSync(join(CODEX_DIR, 'codex-hook-trust-grant.ts'), 'utf8') - expect(grant).toContain('export async function grantManagedCodexHookTrust(') - const host = readFileSync(join(CODEX_DIR, 'codex-trust-grant-host.ts'), 'utf8') - expect(host).toContain('export async function resolveCodexTrustGrantHost(') - const realHome = readFileSync(join(CODEX_DIR, 'codex-real-home-hook-install.ts'), 'utf8') - expect(realHome).toContain('}): Promise {') - }) -}) diff --git a/src/main/codex/config-toml-trust-api-parity.test.ts b/src/main/codex/config-toml-trust-stale-writes.test.ts similarity index 59% rename from src/main/codex/config-toml-trust-api-parity.test.ts rename to src/main/codex/config-toml-trust-stale-writes.test.ts index d20cde89b8e..6a8fb8398de 100644 --- a/src/main/codex/config-toml-trust-api-parity.test.ts +++ b/src/main/codex/config-toml-trust-stale-writes.test.ts @@ -1,12 +1,6 @@ -import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { chmodSync, readFileSync, readdirSync, statSync } from 'node:fs' import * as trustApi from './config-toml-trust' -import type { - CodexEventLabel, - CodexHookTrustState, - CodexProjectTrustLevel, - CodexTrustEntry -} from './config-toml-trust' import { createTrustConfigFixture, removeTrustConfigFixture @@ -20,42 +14,6 @@ afterEach(() => { } }) -describe('config-toml-trust public API', () => { - it('retains the exact runtime export surface', () => { - expect(Object.keys(trustApi).sort()).toEqual( - [ - 'codexHookSourcePathsEqual', - 'computeTrustKey', - 'computeTrustedHash', - 'escapeTomlString', - 'getCodexExplicitHomeHookSourcePath', - 'normalizeCodexHookSourcePath', - 'normalizeCodexProjectPathForLookup', - 'normalizeCodexProjectPathForRevocationLookup', - 'normalizeHookTrustKeyForLookup', - 'parseCodexProjectHeaderPath', - 'parseTrustKey', - 'readHookTrustEntries', - 'readHookTrustEntriesFromContent', - 'removeHookTrustEntries', - 'removeHookTrustEntriesFromContent', - 'upsertHookTrustEntries', - 'upsertHookTrustEntriesInContent', - 'upsertProjectTrustLevel', - 'upsertProjectTrustLevelInContent', - 'writeConfigAtomically' - ].sort() - ) - }) - - it('retains the public type contracts', () => { - expectTypeOf<'stop' | 'session_start'>().toMatchTypeOf() - expectTypeOf().toEqualTypeOf<'trusted' | 'untrusted'>() - expectTypeOf().toHaveProperty('sourcePath').toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - }) -}) - describe('config.toml partial and stale writes', () => { it('preserves a truncated malformed prefix while appending trust', () => { const partial = ['model = "gpt-5"', '[mcp_servers.partial', 'command = "still-user-data'].join( diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 1982facd5f3..09c4d3d7658 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { buildGpuCrashDiagnostics, GpuCrashDiagnosticsRecorder } from './gpu-crash-diagnostics' @@ -223,29 +221,3 @@ describe('GpuCrashDiagnosticsRecorder', () => { expect(recordBreadcrumb).toHaveBeenCalledWith({ gpuInfoLevel: 'unavailable' }) }) }) - -describe('GPU crash diagnostics production wiring', () => { - it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync( - join(__dirname, '..', 'startup', 'main-process-preflight.ts'), - 'utf8' - ) - const listenerSource = readFileSync( - join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), - 'utf8' - ) - const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") - expect(listenerStart).toBeGreaterThan(0) - const listener = listenerSource.slice( - listenerStart, - listenerSource.indexOf('\n })', listenerStart) - ) - expect(source).toMatch( - /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ - ) - expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ - ) - expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) - }) -}) diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index 43441250708..4454bc48457 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -1,55 +1,9 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate + GpuCrashFallbackTracker } from './gpu-crash-fallback-decision' -import { shouldRecordProcessGoneCrash } from './process-gone-classification' - -/** - * Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster - * through the production decision path, so the reason no host was ever offered - * Safe Graphics Mode is pinned rather than argued about. - * - * Each session below is one crash report's `Recent activity`; a breadcrumb's - * `suppressedSinceLast=N` means N further identical GPU deaths were coalesced - * into it, so the session saw N+1 GPU crashes. - */ - -type FieldSession = { - /** Crash-report id from the field bundle. */ - report: string - /** GPU child crash times, ms since main-process start. */ - gpuCrashesMsSinceLaunch: number[] -} - -// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts - -// mainProcessStartedAt); coalesced repeats are placed inside the same second, -// which is the only interval the emitted breadcrumb pins them to. -const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ - // 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes - { report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] }, - // 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash - { report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] }, - // 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes - { report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] }, - // 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash - { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } -] - -/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ -function readChildProcessGoneListener(): string { - const source = readFileSync( - join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), - 'utf8' - ) - const start = source.indexOf(" app.on('child-process-gone'") - expect(start).toBeGreaterThan(0) - return source.slice(start, source.indexOf('\n })', start)) -} function newTracker(): GpuCrashFallbackTracker { return new GpuCrashFallbackTracker({ @@ -58,73 +12,7 @@ function newTracker(): GpuCrashFallbackTracker { }) } -const FIELD_GPU_EVENT = { - source: 'child', - processType: 'GPU', - serviceName: 'GPU', - reason: 'crashed', - expectedTeardown: 'none' -} as const - describe('1.4.190 win32 GPU-child crash cluster', () => { - it('does not let process_gone_suppressed gate the fallback candidate check', () => { - // The GPU death is suppressed as recoverable churn (no user-facing report)... - expect( - shouldRecordProcessGoneCrash({ - ...FIELD_GPU_EVENT, - platform: 'win32', - exitCode: -2147483645 - }) - ).toBe(false) - // ...but the fallback path reads the raw child-process-gone event, so the - // suppression cannot hide a broken driver from recovery. - expect( - isGpuFallbackCrashCandidate({ - platform: 'win32', - processType: 'GPU', - reason: 'crashed' - }) - ).toBe(true) - // Both assertions above still pass if the candidate check is moved behind the - // suppressed-report path, so pin that nothing branches ahead of it in index.ts. - const listener = readChildProcessGoneListener() - const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(') - expect(guardStart).toBeGreaterThan(0) - expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) - expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ - ) - // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which - // re-couples recovery to the suppression decision, so pin the guard to that check alone. - const recoveryGuard = listener.slice( - listener.lastIndexOf('if (', guardStart), - listener.indexOf('handleGpuChildCrash(') - ) - expect(recoveryGuard).not.toMatch(/&&|\|\|/) - }) - - it('never reaches the burst threshold in any observed cluster session', () => { - const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => { - // Each launch constructs a fresh tracker (src/main/index.ts), so evidence - // does not survive the relaunch these users performed after every crash. - const tracker = newTracker() - const engaged = session.gpuCrashesMsSinceLaunch.some( - (at) => tracker.recordGpuCrash(at).shouldEngageFallback - ) - return { - report: session.report, - gpuCrashes: session.gpuCrashesMsSinceLaunch.length, - engagedSafeGraphicsPrompt: engaged - } - }) - expect(outcomes).toEqual([ - { report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, - { report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }, - { report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, - { report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false } - ]) - }) - it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => { // oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then // `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 23707754ff8..dd918ba9397 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { showMessageBoxMock } = vi.hoisted(() => ({ @@ -105,22 +103,3 @@ describe('handleGpuFallbackRecoveredLaunch', () => { expect(handlers.restartWithHardware).not.toHaveBeenCalled() }) }) - -describe('recovered safe-graphics production wiring', () => { - it('prompts only after the recovered window is shown and persists both consent states', () => { - const windowSource = readFileSync( - join(__dirname, '..', 'startup', 'main-window-controller.ts'), - 'utf8' - ) - const lifecycleSource = readFileSync( - join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), - 'utf8' - ) - expect(windowSource).toMatch( - /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ - ) - expect(lifecycleSource).toMatch( - /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ - ) - }) -}) diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts deleted file mode 100644 index 187680f64cd..00000000000 --- a/src/main/github/work-item-details-api-parity.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' -import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import type { LocalGitExecOptions } from './gh-utils' -import type { GitHubApiRepository } from './github-api-repository' -import * as workItemDetails from './work-item-details' - -type GetWorkItemDetails = ( - repoPath: string, - number: number, - type?: 'issue' | 'pr', - connectionId?: string | null, - localGitOptions?: LocalGitExecOptions, - preference?: IssueSourcePreference -) => Promise - -type GetPRFileContents = (args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - prRepo?: GitHubApiRepository | null - prNumber: number - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string -}) => Promise - -describe('work-item-details public API parity', () => { - it('retains only the established runtime exports and call signatures', () => { - expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) - expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() - expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() - }) -}) diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts deleted file mode 100644 index 5f7a10fedec..00000000000 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') - -function sourceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('headless automation dispatcher source boundaries', () => { - it('creates new-per-run workspaces from the resolved run target repo', () => { - const createArgsSection = sourceBetween('buildHeadlessAutomationWorktreeCreateArgs({', '})') - - expect(createArgsSection).toContain('repo: target.repo') - expect(createArgsSection).not.toContain('automation.sourceContext') - }) -}) diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts index 6bd5060fb0e..f884e596f72 100644 --- a/src/main/ipc/filesystem-watcher-real.test.ts +++ b/src/main/ipc/filesystem-watcher-real.test.ts @@ -77,15 +77,6 @@ describe('filesystem-watcher real @parcel/watcher integration', () => { vi.clearAllMocks() }) - // Why: a clear, separate failure mode — if the native addon is missing from - // the bundle the watcher silently no-ops (doInstallLocalWatcher swallows the - // import error), so the wiring assertion below would time out with a vague - // message. This makes "addon absent" distinct from "wiring broken". - it('loads the real @parcel/watcher native addon', async () => { - const watcher = await import('@parcel/watcher') - expect(typeof watcher.subscribe).toBe('function') - }) - // Why: this integration targets the Linux native watcher path described // above; macOS developer sandboxes can load the addon while suppressing // subscribe callbacks, which makes this an environment check instead. diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts deleted file mode 100644 index 1746890199a..00000000000 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { readFileSync } from 'node:fs' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = await vi.hoisted(async () => { - const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') - return createGitHubIpcMocks() -}) - -vi.mock('electron', () => mocks.electron) -vi.mock('../github/client', () => mocks.client) -vi.mock('../github/work-item-details', () => mocks.workItemDetails) -vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) -vi.mock('../telemetry/client', () => mocks.telemetry) -vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) -vi.mock('./ui', () => mocks.ui) - -import * as github from './github' -import { createGitHubIpcHarness } from './github-ipc-test-harness' - -const EXPECTED_GITHUB_IPC_CHANNELS = [ - 'gh:prForBranch', - 'gh:refreshPRNow', - 'gh:enqueuePRRefresh', - 'gh:reportVisiblePRRefreshCandidates', - 'gh:issue', - 'gh:listIssues', - 'gh:createIssue', - 'gh:listWorkItems', - 'gh:countWorkItems', - 'gh:workItem', - 'gh:workItemByOwnerRepo', - 'gh:workItemDetails', - 'gh:notifyWorkItemMutated', - 'gh:prFileContents', - 'gh:repoSlug', - 'gh:repoUpstream', - 'gh:prChecks', - 'gh:prCheckDetails', - 'gh:prComments', - 'gh:setPRCommentReaction', - 'gh:resolveReviewThread', - 'gh:setPRFileViewed', - 'gh:addPRReviewCommentReply', - 'gh:addPRReviewComment', - 'gh:updatePRTitle', - 'gh:mergePR', - 'gh:setPRAutoMerge', - 'gh:updatePRState', - 'gh:markPRReadyForReview', - 'gh:rerunPRChecks', - 'gh:requestPRReviewers', - 'gh:removePRReviewers', - 'gh:updateIssue', - 'gh:addIssueComment', - 'gh:listLabels', - 'gh:listAssignableUsers', - 'gh:viewer', - 'gh:checkOrcaStarred', - 'gh:starOrca', - 'gh:rateLimit', - 'gh:diagnoseAuth', - 'gh:listBindableAccounts', - 'gh:validateAccountBinding', - 'gh:listAccessibleProjects', - 'gh:resolveProjectRef', - 'gh:listProjectViews', - 'gh:getProjectViewTable', - 'gh:projectWorkItemDetailsBySlug', - 'gh:updateProjectItemField', - 'gh:clearProjectItemField', - 'gh:updateIssueBySlug', - 'gh:updatePullRequestBySlug', - 'gh:addIssueCommentBySlug', - 'gh:updateIssueCommentBySlug', - 'gh:deleteIssueCommentBySlug', - 'gh:listLabelsBySlug', - 'gh:listAssignableUsersBySlug', - 'gh:listIssueTypesBySlug', - 'gh:updateIssueTypeBySlug' -] as const - -describe('GitHub IPC channel parity', () => { - const harness = createGitHubIpcHarness(mocks) - - beforeEach(harness.reset) - - it('preserves the facade and fixed IPC channel contract', () => { - github.registerGitHubHandlers(harness.store as never, harness.stats as never) - - // The preload facade now composes the two GitHub bridge owners; inspect - // both owners so the channel census remains tied to the actual invokes. - const preloadSource = [ - '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', - '../../preload/api/gh-bridge-mutations-and-projects.ts' - ] - .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) - .join('\n') - const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( - (match) => match[1] - ) - const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( - ([channel]) => channel as string - ) - - expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) - expect(new Set(registeredChannels).size).toBe(registeredChannels.length) - expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) - expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) - }) -}) diff --git a/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts b/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts deleted file mode 100644 index 67bee9ca700..00000000000 --- a/src/main/ipc/pty/pty-spawn-shell-override-parity.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * The two spawn preflights are twins: one serves renderer/IPC spawns, the other serves runtime - * spawns (`terminal.create` from the CLI, headless serve, and paired remote environments). They - * had drifted — the runtime twin passed a literal `undefined` for the caller's shell, so on a - * Windows host a runtime-created terminal could only ever be the host's default shell. A caller - * asking for cmd or PowerShell had to send it as `command`, which the provider TYPES into that - * default shell: the pty stayed the default shell with the requested one running inside it, and - * leaving that child dropped the caller's handle back onto a prompt it never asked for. - * - * Source-level because the functional seam is a whole spawn pipeline; what actually regressed is - * one twin silently not reading a field the other reads. - */ -const PREFLIGHTS = ['ipc', 'runtime'] as const - -describe.each(PREFLIGHTS)('%s pty spawn preflight', (lane) => { - const source = readFileSync(join(__dirname, lane, 'spawn-preflight.ts'), 'utf8') - - it("resolves Windows terminal runtime options from the caller's requested shell", () => { - expect(source).toContain('requestedShellOverride: args.shellOverride') - expect(source).not.toContain('requestedShellOverride: undefined') - }) -}) diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 6b66d7f337f..bc259e893a9 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -277,7 +277,6 @@ describe('PiTitlebarExtensionService', () => { const env = svc.buildPtyEnv('pty-omp-sqlite', piHome, 'omp') const sourcePath = join(piHome, 'agent.db') - const content = 'agent.db credentials' expect(env.PI_CODING_AGENT_DIR).toBeUndefined() expect(readFileSync(env.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') @@ -287,9 +286,6 @@ describe('PiTitlebarExtensionService', () => { expect(existsSync(sourcePath)).toBe(false) expect(existsSync(join(userDataDir, 'omp-agent-overlays'))).toBe(false) expect(existsSync(join(piHome, 'history.db'))).toBe(false) - writeFileSync(sourcePath, content) - - expect(readFileSync(sourcePath, 'utf-8')).toBe(content) }) it('migrates missing OMP state from the old source overlay without overwriting source files', () => { diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts index 37e8451a4da..d79fe80fbbd 100644 --- a/src/main/ports/port-scan-command-client.test.ts +++ b/src/main/ports/port-scan-command-client.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join, sep } from 'node:path' import { Worker } from 'node:worker_threads' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -244,17 +243,6 @@ describe('resolveWorkerEntryPath', () => { expect(resolved).toBe(join(moduleDir, WORKER_ENTRY_FILENAME)) expect(resolved).not.toContain('app.asar') }) - - // A rename in the build config would leave both branches pointing at a file - // that is never emitted, and only the packaged one fails silently. - it('names the entry the main build actually emits', () => { - const config = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), - 'utf8' - ) - - expect(config).toContain("'port-scan-command-worker-entry': resolve(") - }) }) const REAL_WORKER_BLOCK_MS = 800 diff --git a/src/main/ports/port-scan-command-import-boundary.test.ts b/src/main/ports/port-scan-command-import-boundary.test.ts deleted file mode 100644 index bd9b9d05fb8..00000000000 --- a/src/main/ports/port-scan-command-import-boundary.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why (#11161): libuv runs process creation inline on the calling event loop, -// so the port scan only stays off CrBrowserMain while these main-thread modules -// spawn nothing themselves. Spawning belongs to port-scan-command-execution.ts, -// which only the worker entry imports. -const MAIN_THREAD_MODULES = ['local-workspace-port-scanner.ts', 'port-scan-command-client.ts'] - -describe('port scan main-thread spawn boundary', () => { - it.each(MAIN_THREAD_MODULES)('keeps %s free of child_process', (fileName) => { - const source = readFileSync(join(import.meta.dirname, fileName), 'utf8') - - expect(source).not.toMatch(/from\s+['"](node:)?child_process['"]/) - expect(source).not.toMatch(/require\(\s*['"](node:)?child_process['"]\s*\)/) - }) -}) diff --git a/src/main/providers/settled-pty-writer-census.test.ts b/src/main/providers/settled-pty-writer-census.test.ts deleted file mode 100644 index 08dd9989400..00000000000 --- a/src/main/providers/settled-pty-writer-census.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { execFileSync } from 'node:child_process' -import { describe, expect, it, vi } from 'vitest' -import { LocalPtyProvider } from './local-pty-provider' -import { SshPtyProvider } from './ssh-pty-provider' -import { createMockMux } from './ssh-pty-provider-mock-multiplexer' -import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' -import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }, - BrowserWindow: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - webContents: { fromId: vi.fn(() => null) } -})) - -const REPO_ROOT = join(__dirname, '..', '..', '..') - -/** - * Requiring the method is satisfiable by a lie: the degraded daemon router used to answer it - * with `provider.write(...) !== false`, reproducing the fire-and-forget bug through the fix. - * The census pins the producers and reads their bodies, so a new provider or a revived - * fabricated handoff fails here rather than silently clearing a mailbox reservation. - */ -const SETTLED_PTY_WRITER_FILES = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', - 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', - 'src/main/daemon/daemon-pty-adapter.ts' -] - -/** Where the provider-side settlement is actually decided; the adapter inherits its own. */ -const SETTLED_WRITER_DECLARATIONS = [ - 'src/main/providers/local-pty-provider.ts', - 'src/main/providers/ssh-pty-provider.ts', - 'src/main/providers/ssh-pty-provider-rpc-operations.ts', - 'src/main/daemon/daemon-pty-router.ts', - 'src/main/daemon/degraded-daemon-pty-provider.ts', - 'src/main/daemon/daemon-pty-session-input.ts' -] - -function declaredProviderFiles(): string[] { - const output = execFileSync('git', ['grep', '-l', '--', 'implements IPtyProvider', 'src/main'], { - cwd: REPO_ROOT, - encoding: 'utf8' - }) - // Tests may name the clause while pinning it; only production declarations count. - return output - .split('\n') - .filter((file) => file && !file.endsWith('.test.ts')) - .sort() -} - -function settledWriterBody(file: string): string { - const source = readFileSync(join(REPO_ROOT, file), 'utf8') - const start = source.indexOf('writeWithSettlement') - expect(start, `${file} declares no settled writer`).toBeGreaterThan(-1) - const end = source.indexOf('\n }', start) - return source.slice(start, end === -1 ? source.length : end) -} - -describe('settled PTY writer census', () => { - it('covers every production provider class that declares IPtyProvider', () => { - expect(declaredProviderFiles()).toEqual([...SETTLED_PTY_WRITER_FILES].sort()) - }) - - it('exposes a settled writer on every production provider instance', () => { - const daemonClient = { isConnected: () => false, onEvent: vi.fn(() => vi.fn()) } - const adapter = new DaemonPtyAdapter(daemonClient as never) - const instances = [ - new LocalPtyProvider({} as never), - new SshPtyProvider('conn-census', createMockMux() as never), - new DaemonPtyRouter({ current: adapter, legacy: [] }), - new DegradedDaemonPtyProvider({ - current: adapter, - legacy: [], - fallback: new LocalPtyProvider({} as never) - }), - adapter - ] - for (const provider of instances) { - expect(typeof provider.writeWithSettlement, provider.constructor.name).toBe('function') - } - }) - - it('never synthesizes a settlement from the fire-and-forget write', () => { - for (const file of SETTLED_WRITER_DECLARATIONS) { - expect(settledWriterBody(file), file).not.toMatch(/\.write\(/) - } - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index a20dfb98d9b..0917c4cf81a 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -527,18 +527,6 @@ describe('SshFilesystemProvider', () => { }) describe('watch', () => { - it('sends fs.watch request and returns unsubscribe', async () => { - const callback = vi.fn() - const unsub = await provider.watch('/home/user/project', callback) - - expect(mux.request).toHaveBeenCalledWith( - 'fs.watch', - { rootPath: '/home/user/project', watchId: expect.any(Number) }, - { signal: expect.any(AbortSignal) } - ) - expect(typeof unsub).toBe('function') - }) - it('uses a registration-owned cancellation signal for the mux fs.watch request', async () => { mux.request.mockResolvedValue(undefined) const controller = new AbortController() diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts deleted file mode 100644 index 315eba73aa0..00000000000 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * agent-browser forks a daemon per browser tab that Orca holds no handle on, and - * `destroyAllSessions` closes each one by spawning another agent-browser child — - * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and - * every open tab's daemon outlived the app (#16367). - */ -const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') - -function teardownBarrierMembers(): string { - const start = source.indexOf('settleTeardownWithinDeadline([') - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('])', start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('quit teardown of agent-browser daemons', () => { - it('joins the will-quit teardown barrier', () => { - expect(teardownBarrierMembers()).toContain("{ name: 'browser', promise: browserShutdown }") - }) - - it('captures the destroyAllSessions promise instead of firing and forgetting', () => { - expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ - ) - // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). - expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) - }) -}) diff --git a/src/main/rate-limits/minimax/minimax-fetcher.test.ts b/src/main/rate-limits/minimax/minimax-fetcher.test.ts index d587c6b9256..d03c6a7c589 100644 --- a/src/main/rate-limits/minimax/minimax-fetcher.test.ts +++ b/src/main/rate-limits/minimax/minimax-fetcher.test.ts @@ -19,12 +19,7 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) -import { - extractMiniMaxCookieValue, - fetchMiniMaxRateLimits, - normalizeMiniMaxCookieHeader, - redactMiniMaxSecret -} from './minimax-fetcher' +import { fetchMiniMaxRateLimits } from './minimax-fetcher' const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' @@ -646,54 +641,3 @@ describe('fetchMiniMaxRateLimits', () => { }) }) }) - -describe('normalizeMiniMaxCookieHeader', () => { - it('preserves all cookie pairs from a browser Cookie header', () => { - const normalized = normalizeMiniMaxCookieHeader( - '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' - ) - expect(normalized).toBe( - '_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz' - ) - }) - - it('normalizes quoted MiniMax cookie storage syntax', () => { - expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe( - '_token=tok; minimax_group_id_v2=42' - ) - }) - - it('accepts a copied Cookie header line', () => { - expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe( - 'session=abc; other=xyz' - ) - }) -}) - -describe('extractMiniMaxCookieValue', () => { - it('returns the value for a given cookie name', () => { - expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345') - }) - it('returns null when the name is absent', () => { - expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull() - }) -}) - -describe('redactMiniMaxSecret', () => { - it('redacts _token values', () => { - expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]') - expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF') - }) - it('redacts minimax_group_id_v2 values', () => { - expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999') - }) - it('redacts MiniMax anti-bot cookie values', () => { - const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"') - expect(redacted).not.toContain('secret') - expect(redacted).toContain('ak_bmsc=[REDACTED]') - expect(redacted).toContain('bm_sv:[REDACTED]') - }) - it('redacts Cookie: header lines', () => { - expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]') - }) -}) diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts deleted file mode 100644 index 50cf9e6b35c..00000000000 --- a/src/main/runtime/agent-session-launch-env-backfill.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-launch-env' -let directory: string - -function request(overrides: Partial = {}): AgentSessionReserveRequest { - return { - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW, - ...overrides - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('legacy agent session launch environment', () => { - it('durably pins the first environment resolved by a current reservation', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reserveOwner(request()) - await store.reserveOwner( - request({ - expectedFence: 1, - spawnToken: 'spawn-b', - launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000002`, - fingerprint: 'fp-2' - } - }) - ) - - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - expect( - (reopened.getRecord(SESSION) as { launchEnv?: Record } | null)?.launchEnv - ).toBeUndefined() - }) - - it('rejects an environment that could not be reloaded before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - const launchEnv = Object.fromEntries( - Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) - ) - - await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( - 'agent_session_launch_env_invalid' - ) - expect(store.getRecord(SESSION)).toBeNull() - }) - - it('rejects an overlong environment key before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - - await expect( - store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) - ).rejects.toThrow('agent_session_launch_env_invalid') - expect(store.getRecord(SESSION)).toBeNull() - }) -}) diff --git a/src/main/runtime/agent-startup-input-assembly-census.test.ts b/src/main/runtime/agent-startup-input-assembly-census.test.ts deleted file mode 100644 index ca05ed03648..00000000000 --- a/src/main/runtime/agent-startup-input-assembly-census.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const REPO_ROOT = join(import.meta.dirname, '../../..') - -/** - * The host modules that turn settings into `buildAgentStartupPlan` inputs. - * - * `buildAgentStartupPlan` was always shared; the input assembly was not, and the hand-rolled - * copies drifted — one dropped `sessionOptionsOverrideAgentArgs` and let configured args defeat a - * picked model. This pins the funnel so a sixth host site cannot re-derive the inputs by hand. - */ -const MIGRATED_HOST_LAUNCH_MODULES = [ - 'src/main/runtime/orca-runtime-create-agent-session.ts', - 'src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts', - 'src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts', - 'src/main/runtime/runtime-worktree-agent-startup.ts' -] - -/** Reading any of these next to a startup plan means the inputs are being assembled by hand. */ -const HAND_ASSEMBLY_MARKERS = [ - 'resolveTuiAgentLaunchArgs(', - 'resolveTuiAgentLaunchEnv(', - 'resolveLocalWindowsAgentStartupShell(' -] - -function read(file: string): string { - return readFileSync(join(REPO_ROOT, file), 'utf8') -} - -describe('host agent-startup input assembly census', () => { - it('routes every migrated host launch site through the shared resolver', () => { - const missing = MIGRATED_HOST_LAUNCH_MODULES.filter( - (file) => !read(file).includes('resolveAgentStartupPlanInputs(') - ) - expect(missing).toEqual([]) - }) - - it('leaves no migrated host site assembling the settings-derived inputs by hand', () => { - const handAssembled = MIGRATED_HOST_LAUNCH_MODULES.filter((file) => { - const source = read(file) - return HAND_ASSEMBLY_MARKERS.some((marker) => source.includes(marker)) - }) - expect(handAssembled).toEqual([]) - }) - - it('detects hand assembly when it is present', () => { - // Positive control: the markers are real names, so an empty result above is a true negative - // rather than a typo that can never match. - const resumeSite = read( - 'src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts' - ) - expect(HAND_ASSEMBLY_MARKERS.some((marker) => resumeSite.includes(marker))).toBe(true) - }) -}) diff --git a/src/main/runtime/client-hosted-browser-row-hydration-census.test.ts b/src/main/runtime/client-hosted-browser-row-hydration-census.test.ts deleted file mode 100644 index e50e066a544..00000000000 --- a/src/main/runtime/client-hosted-browser-row-hydration-census.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' - -const REPO_ROOT = join(import.meta.dirname, '../../..') -const CENSUS_FILE = 'src/main/runtime/client-hosted-browser-row-hydration-census.test.ts' - -/** - * `deliverHydrationSnapshot` reads like a getter and is not one: it replaces the publisher's record - * of which workspaces the renderer holds rows for. A second caller that reasonably treats it as a - * peek — a status panel, a diagnostic dump, a second window's look-ahead — would clear that record - * out from under the live renderer, and the rows it is showing become unretractable. That is the - * exact defect this round shipped a fix for, so the single-caller shape is pinned rather than - * trusted to the name. - */ -async function countCallSites(pattern: RegExp): Promise> { - const files = await glob(['src/**/*.ts', 'src/**/*.tsx'], { - cwd: REPO_ROOT, - ignore: ['**/node_modules/**', '**/*.test.ts', '**/*.test.tsx', CENSUS_FILE] - }) - const counts: Record = {} - for (const file of files) { - // Counted per file, not merely detected: two call sites in one file mask each other. - const hits = readFileSync(join(REPO_ROOT, file), 'utf8').match(pattern)?.length ?? 0 - if (hits > 0) { - counts[file] = hits - } - } - return counts -} - -// Anchored on the member-call shape rather than the bare name, so the declaration does not count -// itself and prose about the method does not have to be kept out of the file. -const DELIVER_CALL = /\.deliverHydrationSnapshot\(/g -const LIST_CALL = /\.listClientHostedBrowserRows\(/g - -describe('client-hosted row hydration caller census', () => { - it('keeps the hydration delivery to its one runtime caller', async () => { - expect(await countCallSites(DELIVER_CALL)).toEqual({ - 'src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts': 1 - }) - }) - - // The IPC handler is the other half of the same invariant: it is what turns one renderer's - // hydration request into one delivery, so a second production reader would arrive through here. - it('keeps the runtime accessor to its one IPC caller', async () => { - expect(await countCallSites(LIST_CALL)).toEqual({ - 'src/main/ipc/runtime.ts': 1 - }) - }) - - // Why: the two censuses above pass by finding what is already there, so a matcher that had - // stopped matching would read the same as a clean repo. Prove it counts a second caller. - it('counts a second caller rather than reporting the same single site', () => { - const source = ` - const a = runtime.deliverHydrationSnapshot() - const b = other.deliverHydrationSnapshot() - ` - expect(source.match(DELIVER_CALL)?.length).toBe(2) - expect('runtime.listClientHostedBrowserRows()'.match(LIST_CALL)?.length).toBe(1) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index cef9e7dd8c1..e939d8a05f7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -1,4 +1,3 @@ -import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' const { ipcHandlers } = vi.hoisted(() => ({ @@ -31,10 +30,6 @@ vi.mock('@electron-toolkit/utils', () => ({ })) import type Database from '../../../../../sqlite/sync-database' -import { - scanSourceTree, - stripComments -} from '../../../../../../shared/source-scan/source-tree-scan' import type { AgentStatusIpcPayload } from '../../../../../../shared/agent-status-ipc-payload' import { toAgentStatusIpcPayload } from '../../../../../agent-hooks/server/server-status-identity' import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' @@ -47,119 +42,6 @@ import { OrcaRuntimeService } from '../../../../orca-runtime' import { ORCHESTRATION_WORKER_LIST_METHOD } from './worker-list-method' import { projectFleetWorkerPage } from './worker-observation' -/** - * Census of every production site in `src/main` that turns hook-server agent-status rows into - * something a consumer reads. - * - * Why a census and not a single seam test: the false-liveness bug (rework failure table L-1) was - * one such site publishing rows that carry a pane key and nothing else, into a consumer that - * matches on terminal identity. Fixing that site fixes nothing if a fifth one is added beside it, - * so the list is pinned and the identity-bearing paths are each driven end to end. - */ -type CensusRow = { - path: string - /** `produces` = mints payloads a consumer reads; `consumes` = reads them; `wiring` = neither. */ - kind: 'produces' | 'consumes' | 'wiring' - role: string -} - -const CENSUS: readonly CensusRow[] = [ - { - path: 'main/ipc/agent-hooks.ts', - kind: 'produces', - role: 'agentStatus:getSnapshot — renderer pull, enriched (driven below)' - }, - { - path: 'main/ipc/agent-status-ipc-boundary.ts', - kind: 'produces', - role: 'resolveAgentStatusBinding — the one identity lookup the pull and fleet paths share' - }, - { - path: 'main/runtime/agent-status-observed-pane-identity.ts', - kind: 'produces', - role: 'captures the identity a hook row was observed under (fleet-status-observed-identity)' - }, - { - path: 'main/runtime/orchestration-fleet-agent-status-snapshot.ts', - kind: 'produces', - role: 'readOrchestrationFleetAgentStatusSnapshot — the minted fleet evidence (driven below)' - }, - { - path: 'main/startup/main-window-agent-status.ts', - kind: 'produces', - role: 'agentStatus:set — renderer live push, enriched inline (driven below)' - }, - { - path: 'main/startup/main-process-runtime-service.ts', - kind: 'wiring', - role: 'binds the hook server snapshot into the runtime deps' - }, - { - path: 'main/orcad/orcad-entry.ts', - kind: 'wiring', - role: 'binds the same snapshot, OSC producer and structured sink into the headless orcad runtime deps' - }, - { - path: 'main/runtime/orca-runtime-state-fields.ts', - kind: 'wiring', - role: 'stores the snapshot deps on the runtime' - }, - { - path: 'main/runtime/orca-runtime-preserved-branch-cleanup.ts', - kind: 'wiring', - role: 'declares the snapshot dep fields' - }, - { - path: 'main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts', - kind: 'produces', - role: 'getOrchestrationFleetAgentStatusSnapshot — delegates to the checked snapshot module' - }, - { - path: 'main/runtime/orca-runtime-stop-requested-pty-ids.ts', - kind: 'wiring', - role: 'feeds the enriched fleet rows to the orchestration projection' - }, - { - path: 'main/runtime/runtime-agent-orchestration-projection.ts', - kind: 'consumes', - role: 'indexes rows by pane key to attach dispatch context' - }, - { - path: 'main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts', - kind: 'consumes', - role: 'worker-list fleet verdict (driven below)' - }, - { - path: 'main/runtime/rpc/methods/orchestration/worker/worker-observation.ts', - kind: 'consumes', - role: 'worker-show fleet verdict (driven below)' - }, - { - path: 'main/runtime/orca-runtime-get-worktree-ps.ts', - kind: 'consumes', - role: 'worktree.ps inline agent rows (driven below)' - }, - { - path: 'main/runtime/orca-runtime-get-terminal-interactive-wait.ts', - kind: 'consumes', - role: 'exact-worker provider session selection, matched on pane key' - }, - { - path: 'main/runtime/orca-runtime-serialize-agent-prompt-submission.ts', - kind: 'consumes', - role: 'prompt-submission serialization, matched on pane key' - }, - { - path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', - kind: 'consumes', - role: 'mobile tab-group pruning and its live agent row, plus the pane identity accessors' - } -] - -/** The names a hook row travels under. A new producer has to use one of them to reach a consumer. */ -const PRODUCER_TOKENS = - /getAgentStatusSnapshot|getAgentProviderSessionSnapshot|enrichAgentStatusIpcPayload|mintAgentStatusFleetEvidence|resolveAgentStatusBinding|getOrchestrationFleetAgentStatusSnapshot|agentStatus:set/ - const PANE_KEY = 'tab-census:leaf-census' const TERMINAL_HANDLE = 'term_census' const PROCESS_INCARNATION = 'pty-census:inc-1' @@ -258,17 +140,7 @@ function censusStore() { } } -describe('agent status producer census', () => { - it('pins every production site that hands hook rows to a consumer', () => { - const root = resolve(import.meta.dirname, '../../../../../..') - const scanned = scanSourceTree(resolve(root, 'main')) - .filter((file) => PRODUCER_TOKENS.test(stripComments(file.source))) - .map((file) => `main/${file.relativePath}`) - .sort() - - expect(scanned).toEqual(CENSUS.map((row) => row.path).sort()) - }) - +describe('agent status identity across every producer and consumer path', () => { it('reads live on worker-list from a hook row that carries only a pane key', async () => { const db = new OrchestrationDb(':memory:') try { diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts index 60c4c86fe44..1c0f08cf33f 100644 --- a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts +++ b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts @@ -5,7 +5,6 @@ import { terminalStreamByteLength, terminalStreamByteLengthExceeds } from './terminal-stream-byte-length' -import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control' // Copy of the pre-change implementation (shared/clipboard-text.ts // measureClipboardTextByteLength), kept here so equivalence is checked against the @@ -323,92 +322,6 @@ describe('terminal stream byte length equivalence with the legacy code-point sca } } } - expect(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS).toBeGreaterThan(0) - }) -}) - -// Reproduces createTerminalOutputBatcher's byte accounting exactly, under both the -// legacy scan and the new measurement, and asserts IDENTICAL flush boundaries. This is -// what makes the partial-count behaviour provably unobservable at that call site. -function simulateBatcherFlushes( - chunks: string[], - measure: ( - data: string, - options: { stopAfterBytes?: number } - ) => { byteLength: number; exceededLimit: boolean } -): string[] { - const flushes: string[] = [] - let pending: string[] = [] - let bytes = 0 - const flush = (): void => { - if (pending.length === 0) { - return - } - flushes.push(pending.join('')) - pending = [] - bytes = 0 - } - for (const data of chunks) { - if (!data) { - continue - } - pending.push(data) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measure(data, { stopAfterBytes: remainingBudget }) - bytes += measurement.byteLength - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - } - } - flush() - return flushes -} - -describe('terminal output batcher flush boundaries are unchanged', () => { - it( - 'produces identical flush boundaries over randomized multi-chunk runs', - { timeout: 60000 }, - () => { - const random = mulberry32(0x1337) - for (let run = 0; run < 300; run += 1) { - const chunks: string[] = [] - const chunkCount = 1 + Math.floor(random() * 40) - for (let index = 0; index < chunkCount; index += 1) { - // Sizes straddle the 64KiB batch cap so single chunks both fit and blow the budget. - // Sizes straddle the native-call floor too, so runs mix scan-branch and - // native-branch measurements inside one batcher's byte accounting. - const scale = random() - const maxUnits = - scale < 0.25 - ? MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2 - : scale < 0.5 - ? 64 - : scale < 0.85 - ? 20000 - : 90000 - chunks.push(random() < 0.5 ? randomString(random, maxUnits) : rawUtf16(random, maxUnits)) - } - const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) - const actualFlushes = simulateBatcherFlushes(chunks, measureTerminalStreamByteLength) - if (legacyFlushes.length !== actualFlushes.length) { - throw new Error(`flush count diverged on run ${run}`) - } - for (let index = 0; index < legacyFlushes.length; index += 1) { - if (legacyFlushes[index] !== actualFlushes[index]) { - throw new Error(`flush ${index} diverged on run ${run}`) - } - } - } - expect(true).toBe(true) - } - ) - - it('exercises runs that actually cross the batch budget', () => { - const oversized = '\u{1f600}'.repeat(TERMINAL_OUTPUT_BATCH_MAX_BYTES) - const chunks = ['a'.repeat(10), oversized, 'b'.repeat(10), oversized, 'c'] - const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) - expect(legacyFlushes.length).toBeGreaterThan(1) - expect(simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)).toEqual(legacyFlushes) }) }) @@ -419,23 +332,8 @@ describe('resync trim byte accounting for a snapshot-sliced chunk', () => { it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => { const data = '\u{1f600}é走a' const sliced = data.slice(2) - expect(terminalStreamByteLength(sliced)).toBe(legacyByteLength(sliced)) // Guards the mutant: code-unit length would be 4 here, UTF-8 is 6. expect(terminalStreamByteLength(sliced)).toBe(6) expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length) }) - - it('matches the legacy byte length for every suffix slice of multi-byte terminal text', () => { - const random = mulberry32(0x51ced) - for (let iteration = 0; iteration < 2000; iteration += 1) { - const data = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24) - for (let offset = 0; offset <= data.length; offset += 1) { - const sliced = data.slice(offset) - if (terminalStreamByteLength(sliced) !== legacyByteLength(sliced)) { - throw new Error(`sliced byte length diverged for ${JSON.stringify(data)} at ${offset}`) - } - } - } - expect(true).toBe(true) - }) }) diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts index f018dfd30da..cc2b5c3b164 100644 --- a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -10,28 +9,11 @@ import { } from './structured-agent-session-runtime' /** - * The structured host's Claude auth policy has exactly one production wiring, and it - * lives in `orca-runtime-get-worktree-ps.ts` — a `@ts-nocheck` file, so neither the - * compiler nor a type test can see the field disappear. Deleting that wiring used to - * leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached the - * child, because `stripAuthEnv` silently fell back to `false`. - * - * Two independent guards replace that silence, and this file pins both. + * The structured host's Claude auth policy has exactly one production wiring. Deleting it + * used to leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached + * the child, because `stripAuthEnv` silently fell back to `false`. */ describe('structured Claude auth policy wiring', () => { - // The behavioural version of this assertion — importing the runtime class and - // capturing the installed deps — costs 35s of module transform for the whole - // OrcaRuntime chain (measured), so the wiring itself is pinned by source and the - // policy's meaning by claude-structured-auth-policy.test.ts. - it('passes a settings-derived Claude auth policy to the host installer', () => { - const source = readFileSync(join(__dirname, 'orca-runtime-get-worktree-ps.ts'), 'utf8') - - expect(source).toContain('claudeStructuredAuthPolicyForSettings') - expect(source).toMatch( - /resolveClaudeAuthPolicy:\s*\(\)\s*=>\s*\n?\s*claudeStructuredAuthPolicyForSettings\(/ - ) - }) - describe('installing without one', () => { let stateDirectory: string | null = null diff --git a/src/main/ssh/relay-protocol-backpressure.test.ts b/src/main/ssh/relay-protocol-backpressure.test.ts deleted file mode 100644 index 45a869af16e..00000000000 --- a/src/main/ssh/relay-protocol-backpressure.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - FrameDecoder, - FrameDecoderContinuationError, - FRAME_DECODER_MAX_RETAINED_BYTES, - HEADER_LENGTH, - MAX_MESSAGE_SIZE, - MessageType, - encodeFrame, - type DecodedFrame -} from './relay-protocol' - -function createScheduler(): { - schedule: (callback: () => void) => number - cancel: (handle: unknown) => void - runNext: () => void - pending: () => number -} { - let nextHandle = 1 - const callbacks = new Map void>() - return { - schedule: (callback) => { - const handle = nextHandle++ - callbacks.set(handle, callback) - return handle - }, - cancel: (handle) => callbacks.delete(handle as number), - runNext: () => { - const entry = callbacks.entries().next().value as [number, () => void] | undefined - if (!entry) { - throw new Error('No decoder continuation scheduled') - } - callbacks.delete(entry[0]) - entry[1]() - }, - pending: () => callbacks.size - } -} - -function frame(id: number, payload = `${id}`): Buffer { - return encodeFrame(MessageType.Regular, id, 0, Buffer.from(payload)) -} - -describe('FrameDecoder bounded turns', () => { - it('retains at most one maximum frame plus one MiB of partial input', () => { - expect(FRAME_DECODER_MAX_RETAINED_BYTES).toBe(MAX_MESSAGE_SIZE + HEADER_LENGTH + 1024 * 1024) - const maximumFrame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(MAX_MESSAGE_SIZE)) - const acceptedError = vi.fn() - const accepted = new FrameDecoder(vi.fn(), acceptedError) - - accepted.feed(Buffer.concat([maximumFrame, Buffer.alloc(1024 * 1024)])) - - expect(acceptedError).not.toHaveBeenCalled() - expect(accepted.drain()).toHaveLength(1024 * 1024) - - const excessError = vi.fn() - const excess = new FrameDecoder(vi.fn(), excessError) - excess.feed(Buffer.concat([maximumFrame, Buffer.alloc(1024 * 1024 + 1)])) - - expect(excessError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('retained-input') }) - ) - expect(excess.drain()).toHaveLength(0) - }) - - it('fails closed when one delivered chunk exceeds retained-input capacity', () => { - const onError = vi.fn() - const decoder = new FrameDecoder(vi.fn(), onError) - - decoder.feed(Buffer.alloc(FRAME_DECODER_MAX_RETAINED_BYTES + 1)) - - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('retained-input') }) - ) - expect(decoder.drain()).toHaveLength(0) - }) - - it('emits the first frame synchronously and preserves order through self-pause', () => { - const scheduler = createScheduler() - const seen: number[] = [] - let decoder: FrameDecoder - const pause = vi.fn(() => decoder.feed(frame(4))) - const resume = vi.fn() - decoder = new FrameDecoder((decoded) => seen.push(decoded.id), undefined, { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: scheduler.cancel, - pause, - resume - }) - - decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)])) - - expect(seen).toEqual([1]) - expect(pause).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(1) - - scheduler.runNext() - scheduler.runNext() - scheduler.runNext() - - expect(seen).toEqual([1, 2, 3, 4]) - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - }) - - it('bounds decoded bytes and time independently from the frame count', () => { - const byteScheduler = createScheduler() - const byteSeen: number[] = [] - const first = frame(1, 'one') - const second = frame(2, 'two') - const byteDecoder = new FrameDecoder((decoded) => byteSeen.push(decoded.id), undefined, { - maxFramesPerTurn: 64, - maxBytesPerTurn: first.length, - schedule: byteScheduler.schedule, - cancelScheduled: byteScheduler.cancel - }) - - byteDecoder.feed(Buffer.concat([first, second])) - expect(byteSeen).toEqual([1]) - byteScheduler.runNext() - expect(byteSeen).toEqual([1, 2]) - - const timeScheduler = createScheduler() - const timeSeen: number[] = [] - let nowCalls = 0 - const timeDecoder = new FrameDecoder((decoded) => timeSeen.push(decoded.id), undefined, { - maxFramesPerTurn: 64, - maxBytesPerTurn: MAX_MESSAGE_SIZE + HEADER_LENGTH, - maxTurnMs: 4, - now: () => (nowCalls++ === 0 ? 0 : 5), - schedule: timeScheduler.schedule, - cancelScheduled: timeScheduler.cancel - }) - - timeDecoder.feed(Buffer.concat([frame(3), frame(4)])) - expect(timeSeen).toEqual([3]) - timeScheduler.runNext() - expect(timeSeen).toEqual([3, 4]) - }) - - it('releases its pause epoch when continuation scheduling throws', () => { - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder(() => {}, undefined, { - maxFramesPerTurn: 1, - pause, - resume, - schedule: () => { - throw new Error('scheduler unavailable') - } - }) - - expect(() => decoder.feed(Buffer.concat([frame(1), frame(2)]))).toThrow('scheduler unavailable') - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - decoder.reset() - expect(resume).toHaveBeenCalledTimes(1) - }) - - it('contains a throwing continuation, resets residue, and reports one typed error', () => { - const scheduler = createScheduler() - const seen: number[] = [] - const onError = vi.fn() - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder( - (decoded) => { - if (decoded.id === 2) { - throw new Error('frame owner failed') - } - seen.push(decoded.id) - }, - onError, - { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: scheduler.cancel, - pause, - resume - } - ) - - decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)])) - expect(() => scheduler.runNext()).not.toThrow() - - expect(seen).toEqual([1]) - expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(FrameDecoderContinuationError)) - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - name: 'FrameDecoderContinuationError', - cause: expect.objectContaining({ message: 'frame owner failed' }) - }) - expect(pause).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - expect(decoder.drain()).toHaveLength(0) - - decoder.feed(frame(4)) - expect(seen).toEqual([1, 4]) - }) - - // feed() runs straight from a transport data handler. A frame owner that threw on the first - // turn used to escape feed() and reach uncaughtException. The continuation path was already - // contained; the synchronous path must match it. - it('contains a frame owner that throws on the synchronous turn and reports one typed error', () => { - const seen: number[] = [] - const onError = vi.fn() - const pause = vi.fn() - const resume = vi.fn() - const decoder = new FrameDecoder( - (decoded) => { - if (decoded.id === 2) { - throw new Error('frame owner failed') - } - seen.push(decoded.id) - }, - onError, - { pause, resume } - ) - - expect(() => decoder.feed(Buffer.concat([frame(1), frame(2), frame(3)]))).not.toThrow() - - expect(seen).toEqual([1]) - expect(onError).toHaveBeenCalledExactlyOnceWith(expect.any(FrameDecoderContinuationError)) - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ message: 'frame owner failed' }) - }) - expect(decoder.drain()).toHaveLength(0) - expect(pause).not.toHaveBeenCalled() - expect(resume).not.toHaveBeenCalled() - - decoder.feed(frame(4)) - expect(seen).toEqual([1, 4]) - }) - - it('keeps reads active for partial frames and incrementally discards oversized payloads', () => { - const errors: Error[] = [] - const seen: DecodedFrame[] = [] - const pause = vi.fn() - const decoder = new FrameDecoder( - (decoded) => seen.push(decoded), - (error) => errors.push(error), - { pause } - ) - const valid = frame(2, 'complete') - - decoder.feed(valid.subarray(0, HEADER_LENGTH + 2)) - expect(seen).toHaveLength(0) - expect(pause).not.toHaveBeenCalled() - decoder.feed(valid.subarray(HEADER_LENGTH + 2)) - expect(seen.map(({ id }) => id)).toEqual([2]) - - const oversizedHeader = Buffer.alloc(HEADER_LENGTH) - oversizedHeader[0] = MessageType.Regular - oversizedHeader.writeUInt32BE(3, 1) - oversizedHeader.writeUInt32BE(MAX_MESSAGE_SIZE + 1, 9) - decoder.feed(Buffer.concat([oversizedHeader, Buffer.alloc(32)])) - - expect(errors).toHaveLength(1) - expect(pause).not.toHaveBeenCalled() - decoder.reset() - decoder.feed(frame(4, 'after-reset')) - expect(seen.map(({ id }) => id)).toEqual([2, 4]) - }) - - it('drain and reset cancel continuation ownership without replaying residue', () => { - const scheduler = createScheduler() - const seen: number[] = [] - const resume = vi.fn() - const cancel = vi.fn(scheduler.cancel) - const decoder = new FrameDecoder((decoded) => seen.push(decoded.id), undefined, { - maxFramesPerTurn: 1, - schedule: scheduler.schedule, - cancelScheduled: cancel, - resume - }) - const second = frame(2, 'residue') - - decoder.feed(Buffer.concat([frame(1), second])) - const residue = decoder.drain() - - expect(seen).toEqual([1]) - expect(residue.equals(second)).toBe(true) - expect(cancel).toHaveBeenCalledTimes(1) - expect(resume).toHaveBeenCalledTimes(1) - expect(scheduler.pending()).toBe(0) - - decoder.feed(Buffer.concat([frame(3), frame(4)])) - decoder.reset() - expect(cancel).toHaveBeenCalledTimes(2) - expect(scheduler.pending()).toBe(0) - - decoder.feed(frame(5)) - expect(seen).toEqual([1, 3, 5]) - }) -}) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index b477ad682ef..3212a44b6fb 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -880,14 +880,6 @@ describe('spawnSystemSsh', () => { vi.stubEnv('PATH', '') expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') }) - - it('returns a process wrapper with kill and onExit', () => { - const result = spawnSystemSsh(createTarget()) - - expect(result.pid).toBe(12345) - expect(typeof result.kill).toBe('function') - expect(typeof result.onExit).toBe('function') - }) }) describe('system SSH operation aborts', () => { diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index fd4379354c4..70747c926f4 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -942,39 +942,6 @@ describe('safe graphics mode startup switches', () => { ) }) - // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs - // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything - // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { - const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') - expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = mainSource.indexOf('\n }', gateStart) - expect(gateEnd).toBeGreaterThan(gateStart) - - expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1) - expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling') - }) - - // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready - // top-level block and never move into the whenReady callback, where appendSwitch is a silent - // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in preflight', () => { - const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') - const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const preflightEnd = mainSource.indexOf('\n return true') - const readyStart = entrySource.indexOf('void app.whenReady()') - const preflightCall = entrySource.indexOf('runMainProcessPreflight({') - expect(preflightEnd).toBeGreaterThan(0) - expect(readyStart).toBeGreaterThan(0) - expect(preflightCall).toBeGreaterThanOrEqual(0) - expect(preflightCall).toBeLessThan(readyStart) - - const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') - expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(preflightEnd) - }) - // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out // must never become reachable only through the GPU-feature path again. it('does not couple the throttling opt-out to GPU feature setup', async () => { diff --git a/src/main/startup/secure-dns-census.test.ts b/src/main/startup/secure-dns-census.test.ts index 6393ac3f3a4..72177fc04d4 100644 --- a/src/main/startup/secure-dns-census.test.ts +++ b/src/main/startup/secure-dns-census.test.ts @@ -33,14 +33,4 @@ describe('secure DNS census', () => { expect(offenders).toEqual([]) }) - - it('detects a DoH mode when one is present', () => { - // Why: the census reads real sources, so it passes vacuously today; prove the matcher on a known offender. - const offending = "app.configureHostResolver({ secureDnsMode: 'automatic' })" - const modes = [...offending.matchAll(/secureDnsMode\s*:\s*'([^']*)'/g)].map((match) => match[1]) - - expect(offending.includes('configureHostResolver')).toBe(true) - expect(modes).toEqual(['automatic']) - expect(modes.every((mode) => mode === 'off')).toBe(false) - }) }) diff --git a/src/main/updater-test-harness.leaked-timers.test.ts b/src/main/updater-test-harness.leaked-timers.test.ts deleted file mode 100644 index 6857852c0e2..00000000000 --- a/src/main/updater-test-harness.leaked-timers.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { setTimeout as sleep } from 'node:timers/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Mock } from 'vitest' -import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' - -const { autoUpdaterMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks } = - await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) - -vi.mock('electron', () => moduleFactories.electron()) -vi.mock('electron-updater', () => moduleFactories.electronUpdater()) -vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) -vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils()) -vi.mock('./ipc/pty', () => moduleFactories.ipcPty()) -vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) -vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) -vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) -vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) -vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) -vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) -vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) -vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) - -const SILENT_SETTLE_DELAY_MS = 1_000 -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 - -warmUpdaterModule() - -describe('updater test harness real-timer tracking', () => { - beforeEach(() => { - resetUpdaterMocks() - }) - - it('cancels a real timer armed before the reset', async () => { - let fired = false - setTimeout(() => { - fired = true - }, 20) - - resetUpdaterMocks() - - await sleep(150) - expect(fired).toBe(false) - }) - - it('still arms real timers after the reset', async () => { - let fired = false - setTimeout(() => { - fired = true - }, 10) - - await sleep(150) - expect(fired).toBe(true) - }) - - it('hands back the untouched Node timeout handle', () => { - const handle = setTimeout(() => {}, 60_000) - - // Why: production code calls unref() on these; the tracker must not swap in a plain id. - expect(typeof handle.unref).toBe('function') - handle.unref() - clearTimeout(handle) - }) -}) - -// Why: this pair reproduces the cross-test leak, so it depends on running in file order — the first -// test arms the timer and the second one is the later test it used to fire into. -describe('abandoned updater instance', () => { - let leakedStatusSend: Mock = vi.fn() - - beforeEach(() => { - resetUpdaterMocks() - }) - - it('leaves a silent-settle timer armed when its module instance is abandoned', async () => { - let resolveCheck: (value: unknown) => void = () => {} - autoUpdaterMock.checkForUpdates.mockImplementation(() => { - // Why: the checking status is what makes the silent settle publish 'not-available' later. - autoUpdaterMock.emit('checking-for-update') - return new Promise((resolve) => { - resolveCheck = resolve - }) - }) - leakedStatusSend = vi.fn() - - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: leakedStatusSend } } as never, { - getLastUpdateCheckAt: () => Date.now() - 25 * 60 * 60 * 1000 - }) - - await vi.waitFor(() => { - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) - }) - - // Why: resolving without a terminal event is what arms the 1s silent settle; do it here so the - // full delay is still ahead of us and the timer is guaranteed to be pending when this test ends. - resolveCheck(undefined) - await sleep(0) - - expect(leakedStatusSend).not.toHaveBeenCalledWith( - 'updater:status', - expect.objectContaining({ state: 'not-available' }) - ) - }) - - it('never reaches the shared spies once the next test owns the clock', async () => { - vi.useFakeTimers() - - // Why: real time past the settle delay — the abandoned instance would settle here and re-arm its - // 24h auto check on this test's fake clock at its epoch. - await sleep(SILENT_SETTLE_DELAY_MS + 300) - await vi.advanceTimersByTimeAsync(AUTO_UPDATE_CHECK_INTERVAL_MS) - - expect(leakedStatusSend).not.toHaveBeenCalledWith( - 'updater:status', - expect.objectContaining({ state: 'not-available' }) - ) - // Why: the generation fence stops at the autoUpdater spies; this one sits past it on the - // pinDefaultReleaseFeed chain that the stale instance's re-armed background check still reached. - expect(fetchNewerReleaseTagsMock).not.toHaveBeenCalled() - expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/usage/usage-scan-worker-client.test.ts b/src/main/usage/usage-scan-worker-client.test.ts index 7b34b5a80f4..09c6f378f00 100644 --- a/src/main/usage/usage-scan-worker-client.test.ts +++ b/src/main/usage/usage-scan-worker-client.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join, sep } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' @@ -235,16 +234,4 @@ describe('usage scan worker entry path', () => { USAGE_SCAN_WORKER_ENTRY_FILENAME ]) }) - - // A rename in the build config would leave both branches pointing at a file - // that is never emitted, and only the packaged one fails silently. - it('names the entry the main build actually emits', () => { - const config = readFileSync( - join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), - 'utf8' - ) - - expect(USAGE_SCAN_WORKER_ENTRY_FILENAME).toBe('usage-scan-worker-entry.js') - expect(config).toContain("'usage-scan-worker-entry': resolve(") - }) }) diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index 0f0991e5c01..82b10e2fdf3 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' import { RelayDispatcher } from './dispatcher' @@ -200,13 +198,6 @@ describe('relay watcher writer admission', () => { }) describe('relay watcher overflow suppression key', () => { - it('keeps the source free of NUL bytes so git and grep still see text', () => { - const source = readFileSync( - fileURLToPath(new URL('./relay-watcher-event-emitter.ts', import.meta.url)) - ) - expect(source.includes(0)).toBe(false) - }) - it('scopes the outstanding marker per client as well as per root', () => { const primary = createRecordingSink(65536) const secondary = createRecordingSink(65536) diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index 314977a17aa..f5d404b4e10 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -6,16 +6,11 @@ // authority (main) = real updatePersistedUI/getPersistedUI merge // broadcast = controlled queue modeling the async ui:stateChanged IPC send // mobile client = mobile/src/worktree/workspace-view-settings mapping; the ui.set -// payload uses the shipping buildWorkspaceViewSettingsUpdate when -// exported, else the legacy whole-snapshot shape — the source-pin -// test asserts use-host-view-settings.ts matches whichever path is -// active, so the model stays tethered to shipping code. +// payload mirrors the shipping buildWorkspaceViewSettingsUpdate. // // Invariant: when two independently identified clients change DISJOINT workspace-view // fields concurrently or across stale-mirror windows, both changes survive and all // mirrors converge; no client may restore a stale sibling field. -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { StrictMode, act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -84,8 +79,7 @@ function createAuthority() { type Authority = ReturnType // Local model of mobile/src/worktree/workspace-view-settings.ts (the desktop test -// runner cannot transform Expo-configured sources). The source-pin test at the -// bottom fails if the shipping module stops matching this model. +// runner cannot transform Expo-configured sources). type MobileViewState = { groupMode: 'none' | 'workspaceStatus' | 'repo' | 'prStatus' sortMode: 'smart' | 'name' | 'recent' | 'repo' | 'manual' @@ -96,10 +90,6 @@ type MobileViewState = { collapsedGroups: string[] } -function mobileHasPatchOnlyBuilder(): boolean { - return readMobileViewSettingsSource().includes('export function buildWorkspaceViewSettingsUpdate') -} - /** Mirrors buildWorkspaceViewSettingsUpdate (candidate) — only touched fields. */ function patchOnlyUpdate( patch: Partial, @@ -127,18 +117,6 @@ function patchOnlyUpdate( return update } -/** Mirrors the pre-fix persistViewSettings payload — the full snapshot on every tap. */ -function legacyWholeSnapshotUpdate(next: MobileViewState): Partial { - return { - groupBy: next.groupMode === 'workspaceStatus' ? 'workspace-status' : 'repo', - sortBy: next.sortMode, - hideSleepingWorkspaces: next.hideSleeping, - hideDefaultBranchWorkspace: next.hideDefaultBranch, - filterRepoIds: next.filterRepoIds, - collapsedGroups: next.collapsedGroups - } -} - /** Model of the mobile host screen's view-settings client (persistViewSettings et al.). */ function createMobileClient(authority: Authority) { let view: MobileViewState = { @@ -171,28 +149,12 @@ function createMobileClient(authority: Authority) { /** A user tap: apply locally, then push through the shipping payload shape. */ tap(patch: Partial) { view = { ...view, ...patch } - const payload = mobileHasPatchOnlyBuilder() - ? patchOnlyUpdate(patch, view) - : legacyWholeSnapshotUpdate(view) + const payload = patchOnlyUpdate(patch, view) authority.set(omitPairingLocalUiFields(payload) as Partial) } } } -function readMobileViewSettingsHookSource(): string { - return readFileSync( - join(__dirname, '../../../../mobile/src/host-screen/use-host-view-settings.ts'), - 'utf-8' - ) -} - -function readMobileViewSettingsSource(): string { - return readFileSync( - join(__dirname, '../../../../mobile/src/worktree/workspace-view-settings.ts'), - 'utf-8' - ) -} - describe('workspace view preferences: cross-client persistence (STA-5781)', () => { let authority: Authority let store: StoreApi @@ -710,35 +672,4 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () = expect(authority.get().hideDefaultBranchWorkspace).toBe(false) expect(store.getState().hideDefaultBranchWorkspace).toBe(false) }) - - it('pins the modeled mobile ui.set payload to the shipping source', async () => { - const source = readMobileViewSettingsHookSource() - if (mobileHasPatchOnlyBuilder()) { - // Candidate: the persistence hook must push through the patch-only builder this model uses. - expect(source).toContain('buildWorkspaceViewSettingsUpdate(patch, next)') - const builderSource = readMobileViewSettingsSource() - for (const guard of [ - "if ('groupMode' in patch)", - "if ('sortMode' in patch)", - "if ('hideSleeping' in patch)", - "if ('hideDefaultBranch' in patch)", - "if ('filterRepoIds' in patch)", - "if ('collapsedGroups' in patch)" - ]) { - expect(builderSource).toContain(guard) - } - } else { - // Baseline: persistViewSettings pushes exactly this whole-snapshot payload. - for (const key of [ - 'groupBy: groupModeToDesktop(next.groupMode)', - 'sortBy: next.sortMode', - 'hideSleepingWorkspaces: next.hideSleeping', - 'hideDefaultBranchWorkspace: next.hideDefaultBranch', - 'filterRepoIds: next.filterRepoIds', - 'collapsedGroups: next.collapsedGroups' - ]) { - expect(source).toContain(key) - } - } - }) }) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 33642542d79..a767a61ccf4 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -47,15 +47,9 @@ describe('AgentStateDot', () => { it('renders working as a yellow spinner', () => { const markup = renderMarkup('working') - expect(markup).toContain('border-yellow-500') - expect(markup).toContain('border-t-transparent') - // Why: rotation must come from the compositor-driven CSS animation, not a - // JS clock writing per-element styles on the input thread (STA-3328). - expect(markup).toContain('agent-working-spinner') + // The spinner's own classes and animation contract belong to AgentWorkingSpinner.test.tsx; + // this pins only that 'working' reaches for it. expect(markup).toContain('data-agent-spinner') - // Why: under reduced motion the top border is filled so the static ring - // reads as a complete marker, not a broken partial spinner (#9515). - expect(markup).toContain('motion-reduce:border-t-yellow-500') }) it('renders monitoring as a static yellow heartbeat glyph', () => { diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index e07399319fb..43319886180 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -288,7 +288,7 @@ describe('owner-fenced mutations', () => { }) it('fences a desktop mutation over the local runtime target with the same precondition', async () => { - const { deleteAutomationForOwner, updateAutomationForOwner } = await client() + const { updateAutomationForOwner } = await client() callRuntimeRpc.mockResolvedValue({ automation: { id: 'a1' } }) await updateAutomationForOwner({ authority: DESKTOP, selector: { kind: 'self' } }, 'a1', { enabled: true @@ -300,7 +300,6 @@ describe('owner-fenced mutations', () => { expect.anything() ) expect(getRuntimeEnvironmentStatus).not.toHaveBeenCalled() - expect(typeof deleteAutomationForOwner).toBe('function') }) }) diff --git a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx index 4eb4174211b..24524a10dc7 100644 --- a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx +++ b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx @@ -1,6 +1,4 @@ import React from 'react' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' @@ -92,19 +90,4 @@ describe('BrowserAnnotationSendMenuContent', () => { onPromptDelivered }) }) - - it('is wired into both browser annotation send surfaces', () => { - const bannerSource = readFileSync( - fileURLToPath(new URL('../assemble-chrome/browser-page-chrome-banners.tsx', import.meta.url)), - 'utf8' - ) - const traySource = readFileSync( - fileURLToPath(new URL('./browser-page-annotation-tray.tsx', import.meta.url)), - 'utf8' - ) - const sendSurfaces = `${bannerSource}\n${traySource}` - - expect(sendSurfaces.match(/ { - it('pins context-menu opens to the runtime that owns the pane', () => { - const source = readFileSync( - fileURLToPath(new URL('../stream-remote/remote-browser-page-pane.tsx', import.meta.url)), - 'utf8' - ) - const paneStart = source.indexOf('function RemoteBrowserPagePane') - const actionStart = source.indexOf('void openWorkspaceBrowserTab({', paneStart) - const actionEnd = source.indexOf('}).catch((error) => {', actionStart) - const openRequest = source.slice(actionStart, actionEnd) - - expect(openRequest).toContain('workspaceId: worktreeId') - expect(openRequest).toContain('expectedRuntimeEnvironmentId: runtimeEnvironmentId') - expect(openRequest).toContain("placementPreference: 'server'") - // A background link follows its source wrapper and never selects the workspace again. - expect(openRequest).toContain( - '...(sourceUnifiedTab ? { afterTabId: sourceUnifiedTab.id } : {})' - ) - expect(openRequest).toContain('selectWorktree: false') - }) -}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts deleted file mode 100644 index 9e42075283c..00000000000 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -describe('BrowserPagePane render IPC boundary', () => { - it('derives toolbar URLs without querying the webview', () => { - const source = readFileSync( - fileURLToPath(new URL('./browser-page-pane.tsx', import.meta.url)), - 'utf8' - ) - const start = source.indexOf('const isBlankTab =', source.indexOf('function BrowserPagePane')) - const end = source.indexOf('useEffect(() => {', start) - const renderUrlDerivation = source.slice(start, end) - - expect(renderUrlDerivation).toContain('getLiveBrowserUrl(browserTab.id)') - expect(renderUrlDerivation).not.toContain('webviewRef') - expect(renderUrlDerivation).not.toContain('.getURL(') - }) -}) diff --git a/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts b/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts deleted file mode 100644 index 6937de9e6e4..00000000000 --- a/src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -/** - * Page metadata has exactly one way out of this renderer: main, which owns the browser-host lease. - * - * Why a census and not a behavioural test: the failure this guards is silent. The runtime accepts - * page traffic only on the connection its lease attached on, so a publish sent as an ordinary - * runtime call is refused as a stale lease and simply never lands — the pane keeps working, the - * guest keeps navigating, and only the runtime's copy of the URL stays frozen at the create URL. - * Nothing in the rendered result changes, so only the route itself can be asserted. - */ -const ROUTE_PATH = fileURLToPath( - new URL('./browser-client-page-metadata-reporting.ts', import.meta.url) -) -const PANE_PATH = fileURLToPath(new URL('./ClientHostedBrowserPagePane.tsx', import.meta.url)) - -describe('client-hosted page metadata route', () => { - const route = readFileSync(ROUTE_PATH, 'utf8') - const pane = readFileSync(PANE_PATH, 'utf8') - - it('publishes through main and never through a plain runtime call', () => { - expect(occurrences(route, 'window.api.browser.publishClientPageMetadata')).toBe(1) - expect(occurrences(route, 'runtimeEnvironments.call')).toBe(0) - expect(occurrences(pane, 'runtimeEnvironments.call')).toBe(0) - }) - - it('reports a publish that did not land', () => { - expect(occurrences(route, 'onUnpublished:')).toBe(1) - }) - - // The pane must not build its own publisher around the route: a second construction site is a - // second chance to wire the transport wrongly, and it would not be covered by the counts above. - it('leaves the pane one way to start publishing', () => { - expect(occurrences(pane, 'createBrowserClientPageMetadataPublisher(')).toBe(0) - expect(occurrences(pane, 'startBrowserClientPageMetadataPublisher(')).toBe(1) - }) -}) - -function occurrences(source: string, needle: string): number { - return source.split(needle).length - 1 -} diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts index 0d1364c139b..941c527b38f 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.ts @@ -61,7 +61,6 @@ export function useBrowserGuestPaintRetention(browserPageIds: readonly string[]) // Why one exported predicate rather than the same OR-list at each site: a hand-rolled copy stays // green when a term is added — nothing typechecks a site that never names the new signal — and the // remote-viewer term reached the panes while four copies in Terminal.tsx still had three terms. -// browser-guest-retention-site-census.test.ts holds the sites to this function. export function browserPageNeedsPaintRetention(browserPageId: string): boolean { return ( isBrowserAutomationVisible(browserPageId) || diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts deleted file mode 100644 index 5d8c6028329..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Guest retention is an OR over several independent signals, and every container from the app -// shell down to the guest has to agree on the list — Chromium stops painting inside a display:none -// subtree, so the *most* pinched ancestor wins. Adding a term (the remote-viewer signal, STA-4150) -// reached the panes while four hand-rolled copies still listed three: nothing typechecks a site -// that never names the new symbol. So the terms live behind one helper, and this census holds the -// sites to it — the classification is enforced here, not by review. - -const RENDERER_SRC = join(__dirname, '..', '..', '..') - -// The three term stores. -const RETENTION_TERM_STORES = [ - 'components/browser-pane/host-guest/browser-automation-visibility.ts', - 'lib/pane-manager/browser-mobile-driver-state.ts', - 'lib/pane-manager/browser-remote-viewer-state.ts' -] - -// ...plus the single module allowed to OR their terms together. -const RETENTION_TERM_OWNERS = [ - ...RETENTION_TERM_STORES, - 'components/browser-pane/host-guest/browser-guest-paint-retention.ts' -] - -// Every way a term store lets a caller read one term. A site that hand-rolls the OR-list from -// readers missing here would pass the census, so the last check holds this list to the exports. -const RETENTION_TERM_SYMBOLS = [ - 'isBrowserAutomationVisible', - 'useBrowserAutomationVisibilityForAny', - 'getBrowserAutomationVisiblePageIds', - 'onBrowserAutomationVisibilityChange', - 'isBrowserPageMobileDriven', - 'hasMobileDriverForAnyBrowserPage', - 'useBrowserMobileDriverForAny', - 'getBrowserMobileDrivenPageIds', - 'getDriverForBrowserPage', - 'useBrowserDriverForPage', - 'onBrowserDriverChange', - 'isBrowserPageRemotelyViewed', - 'hasRemoteViewerForAnyBrowserPage', - 'useBrowserRemoteViewerForAny', - 'getBrowserRemotelyViewedPageIds', - 'onBrowserRemoteViewerChange' -] - -const RETENTION_HELPER_SYMBOLS = [ - 'useAnyBrowserGuestNeedsPaint', - 'useBrowserGuestPaintRetention', - 'browserPageNeedsPaintRetention', - 'onBrowserGuestPaintRetentionChange', - 'browserTabsVetoGuestEviction' -] - -// Every place that decides whether a browser guest keeps painting, and the helper it must use. -const RETENTION_SITES = new Map([ - ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], - // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree - // surface hatch below cannot rescue a guest either one parked with `hidden`. - ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], - ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], - ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], - [ - 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', - ['useBrowserGuestPaintRetention'] - ], - [ - 'components/browser-pane/host-guest/browser-guest-worktree-retention.ts', - ['browserPageNeedsPaintRetention'] - ], - [ - 'components/use-terminal-browser-retention.ts', - ['browserTabsVetoGuestEviction', 'onBrowserGuestPaintRetentionChange'] - ] -]) - -// The per-page threading hooks are the carve-out: they hand one boolean per page to -// browser-page-paintability's required fields, so a missing term IS a typecheck error there. They -// still travel as a set — a caller that reads two of the three is the same omission bug. -const PER_PAGE_RETENTION_HOOKS = [ - 'useBrowserAutomationVisiblePageIds', - 'useBrowserMobileDrivenPageIds', - 'useBrowserRemotelyViewedPageIds' -] - -// One production read of a term is not a retention decision: the pane reads *who* drives the active -// page to label the overlay and lock input. Naming the exception per file rather than dropping the -// symbol keeps every other file that reads it a census failure. -const NON_RETENTION_TERM_READERS = new Map([ - [ - 'components/browser-pane/assemble-chrome/browser-workspace-pane.tsx', - ['useBrowserDriverForPage'] - ] -]) - -// Writers, hydrators and the idle sentinel: they set or seed a term rather -// than read it, so naming one is not a retention decision. -const NON_READER_TERM_EXPORTS = [ - 'acquireBrowserAutomationVisibility', - 'releaseBrowserAutomationVisibility', - 'setDriverForBrowserPage', - 'hydrateBrowserDrivers', - 'IDLE_BROWSER_DRIVER', - 'setRemoteViewersForBrowserPage', - 'hydrateBrowserRemoteViewerPages' -] - -function productionSources(): Map { - const sources = new Map() - for (const entry of readdirSync(RENDERER_SRC, { recursive: true, withFileTypes: true })) { - if (!entry.isFile() || !/\.tsx?$/.test(entry.name) || entry.name.endsWith('.d.ts')) { - continue - } - if (/\.test\.tsx?$/.test(entry.name) || /test-(harness|rig|fixtures)/.test(entry.name)) { - continue - } - const filePath = join(entry.parentPath, entry.name) - sources.set( - relative(RENDERER_SRC, filePath).split(sep).join('/'), - readFileSync(filePath, 'utf8') - ) - } - return sources -} - -function namedSymbols(source: string, symbols: readonly string[]): string[] { - return symbols.filter((symbol) => new RegExp(`\\b${symbol}\\b`).test(source)) -} - -describe('browser guest retention site census', () => { - const sources = productionSources() - - it('keeps every individual retention term behind the shared helper', () => { - const offenders = [...sources] - .filter(([file]) => !RETENTION_TERM_OWNERS.includes(file)) - .map(([file, source]) => ({ - file, - terms: namedSymbols(source, RETENTION_TERM_SYMBOLS).filter( - (term) => !(NON_RETENTION_TERM_READERS.get(file) ?? []).includes(term) - ) - })) - .filter(({ terms }) => terms.length > 0) - .map(({ file, terms }) => `${file}: ${terms.join(', ')}`) - .sort() - - expect( - offenders, - 'A retention site that names terms one by one silently keeps its old list when a new signal ' + - 'is added. Call useBrowserGuestPaintRetention / browserPageNeedsPaintRetention instead, ' + - 'and register the site in RETENTION_SITES.' - ).toEqual([]) - }) - - it('registers every consumer of the shared retention helper', () => { - const consumers = [...sources] - .filter( - ([file]) => file !== 'components/browser-pane/host-guest/browser-guest-paint-retention.ts' - ) - .filter(([, source]) => namedSymbols(source, RETENTION_HELPER_SYMBOLS).length > 0) - .map(([file]) => file) - .sort() - - expect(consumers, 'New retention sites must be listed in RETENTION_SITES.').toEqual( - [...RETENTION_SITES.keys()].sort() - ) - }) - - it('keeps each registered site consulting retention through its helper', () => { - const missing: string[] = [] - for (const [file, requiredSymbols] of RETENTION_SITES) { - const source = sources.get(file) - expect(source, `${file} is registered as a retention site but no longer exists`).toBeDefined() - for (const symbol of requiredSymbols) { - if (!new RegExp(`\\b${symbol}\\b`).test(source ?? '')) { - missing.push(`${file}: ${symbol}`) - } - } - } - expect(missing, 'A registered retention site stopped consulting retention.').toEqual([]) - }) - - it('keeps the per-page threading hooks travelling as a full set', () => { - const partial = [...sources] - .filter(([file]) => !RETENTION_TERM_OWNERS.includes(file)) - .map(([file, source]) => ({ file, hooks: namedSymbols(source, PER_PAGE_RETENTION_HOOKS) })) - .filter(({ hooks }) => hooks.length > 0 && hooks.length < PER_PAGE_RETENTION_HOOKS.length) - .map(({ file, hooks }) => `${file}: only ${hooks.join(', ')}`) - .sort() - - expect( - partial, - 'A pane that threads some retention terms per page must thread all of them.' - ).toEqual([]) - }) - - // Keeps the symbol lists above from going stale: a term store that grows a new reader nobody - // classified would otherwise be a free way to hand-roll the OR-list. - it('classifies every value a term store exports', () => { - const classified = new Set([ - ...RETENTION_TERM_SYMBOLS, - ...PER_PAGE_RETENTION_HOOKS, - ...NON_READER_TERM_EXPORTS - ]) - const unclassified: string[] = [] - for (const file of RETENTION_TERM_STORES) { - const source = sources.get(file) - expect(source, `${file} is registered as a term store but no longer exists`).toBeDefined() - for (const [, name] of (source ?? '').matchAll(/^export (?:function|const|let) (\w+)/gm)) { - if (!classified.has(name)) { - unclassified.push(`${file}: ${name}`) - } - } - } - - expect( - unclassified, - 'A term store exports something this census does not classify. Add a reader to ' + - 'RETENTION_TERM_SYMBOLS (or PER_PAGE_RETENTION_HOOKS, for the per-page hooks typecheck ' + - 'already covers) so hand-rolled sites keep failing, and a writer to NON_READER_TERM_EXPORTS.' - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts index 952cb21e23d..b2f1db2de1f 100644 --- a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts +++ b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts @@ -1,33 +1,11 @@ // @vitest-environment happy-dom -import { createRequire } from 'node:module' import { createElement, type ReactNode } from 'react' import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import * as contextModule from '@/components/confirmation-dialog-context' -import * as providerModule from '@/components/confirmation-dialog' import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' -// react-refresh ships no types; take the one binding this needs. -const { isLikelyComponentType } = createRequire(import.meta.url)('react-refresh/runtime') as { - isLikelyComponentType: (value: unknown) => boolean -} - describe('confirmation dialog Fast Refresh boundary', () => { - it('exports nothing from the provider module that invalidates the refresh boundary', () => { - expect(Object.keys(providerModule)).toEqual(['ConfirmationDialogProvider']) - expect(isLikelyComponentType(providerModule.ConfirmationDialogProvider)).toBe(true) - }) - - it('keeps the context and hook in a component-free module', () => { - const components = Object.entries(contextModule) - .filter(([, value]) => isLikelyComponentType(value)) - .map(([name]) => name) - - expect(components).toEqual([]) - expect(typeof contextModule.useConfirmationDialog).toBe('function') - }) - it('resolves the hook against the context the provider publishes', () => { const wrapper = ({ children }: { children: ReactNode }) => createElement(ConfirmationDialogProvider, null, children) diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts deleted file mode 100644 index 4a02840098e..00000000000 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname - -function componentSource(relativePath: string): string { - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('feature interaction writer boundaries', () => { - it('keeps Cmd+J feature writers in open/selection handlers, not query or navigation rendering', () => { - // Selection and create callbacks now live in focused hooks; the surface only renders them. - const selectionSource = componentSource('use-worktree-jump-palette-selection-actions.ts') - const lifecycleSource = componentSource('use-worktree-jump-palette-selection-lifecycle.ts') - const createSource = componentSource('use-worktree-jump-palette-create-action.ts') - const handlerSection = [selectionSource, lifecycleSource, createSource].join('\n') - const renderSection = componentSource('worktree-jump-palette-surface.tsx') - - const cmdJWriterPattern = /recordFeatureInteraction\('cmd-j/g - const allCmdJWriterCount = handlerSection.match(cmdJWriterPattern)?.length ?? 0 - expect(allCmdJWriterCount).toBeGreaterThanOrEqual(6) - expect(handlerSection.match(cmdJWriterPattern)?.length ?? 0).toBe(allCmdJWriterCount) - expect(renderSection).not.toContain("recordFeatureInteraction('cmd-j") - expect( - sourceBetween(lifecycleSource, 'const handleQueryChange', 'const cancelFallbackFocusFrames') - ).not.toContain("recordFeatureInteraction('cmd-j") - }) - - it('keeps task-provider writers off filters, tab switches, query edits, refresh, and pagination', () => { - const providerWriter = /recordFeatureInteraction\('(github|gitlab|linear)-tasks'\)/ - const refreshSource = componentSource('use-task-page-github-cache-reconciliation.ts') - const paginationSource = componentSource('use-task-page-github-search-pagination.ts') - const searchSource = componentSource('use-task-page-search-actions.ts') - - const passiveSections = [ - sourceBetween(refreshSource, 'const handleRefreshGithubTasks', 'const nextModel'), - sourceBetween(paginationSource, 'const handleLoadNextPage', 'const commitTaskSearch'), - sourceBetween(searchSource, 'const applyPRFilterChange', 'const handleApplyTaskSearch'), - sourceBetween(searchSource, 'const handleApplyTaskSearch', 'const handleTaskSearchChange'), - sourceBetween( - searchSource, - 'const handleTaskSearchChange', - 'const handleSetDefaultTaskPreset' - ), - sourceBetween( - searchSource, - 'const handleSelectGithubTaskKind', - 'const handleResetGithubTaskSearch' - ) - ] - for (const section of passiveSections) { - expect(section).not.toMatch(providerWriter) - } - }) - - it('records GitHub provider-depth for inline item mutation success paths', () => { - const githubWriter = "recordFeatureInteraction('github-tasks')" - // Why: table cells route success telemetry through the optimistic mutation - // hook so provider-depth recording stays on one confirm path. - const hookSource = readFileSync( - join(COMPONENT_ROOT, '../hooks/useTaskPageGitHubWorkItemMutation.ts'), - 'utf8' - ) - expect( - sourceBetween(hookSource, "if (confirmed === 'confirmed')", 'return confirmed') - ).toContain(githubWriter) - expect( - sourceBetween( - componentSource('use-task-page-workspace-actions.ts'), - 'const handleOpenOrUseGitHubWorkItem', - 'const openComposerForGitLabItem' - ) - ).toContain(githubWriter) - }) - - it('threads GitHub task source context through inline task mutations', () => { - const sections = [ - componentSource('task-page/github/StatusCell.tsx'), - componentSource('task-page/github/AssigneesCell.tsx'), - componentSource('task-page/github/ReviewCell.tsx'), - componentSource('task-page/github/MergeCell.tsx'), - sourceBetween( - componentSource('use-task-page-github-issue-creation.ts'), - 'const handleCreateNewIssue', - 'const nextModel' - ) - ] - - for (const section of sections) { - expect(section).toContain('sourceContext') - } - const rowSource = componentSource('task-page/github/Rows.tsx') - // Rows inline the repo lookup per cell rather than hoisting one const. - expect( - rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) - ).toHaveLength(4) - }) - - it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { - const suppression = 'recordTasksInteraction: false' - const githubDetailSection = sourceBetween( - componentSource('use-task-page-github-detail.ts'), - 'const openGitHubDetailPage', - 'const nextModel' - ) - - const inPageNavigationSections = [ - sourceBetween( - componentSource('use-task-page-detail-routing.ts'), - 'const openLinearDetailPage', - 'const openRelatedLinearIssue' - ), - sourceBetween( - componentSource('task-page/SourceBar.tsx'), - 'taskSourceManuallyChangedRef.current = true', - 'void updateSettings' - ) - ] - - expect(githubDetailSection).toContain('openGitHubSourceContext') - expect(githubDetailSection).toContain('openTaskPage') - expect(githubDetailSection).toContain(suppression) - - for (const section of inPageNavigationSections) { - expect(section).toContain(suppression) - } - }) - - it('records Cmd+J create-workspace as its own destination, not a generic quick action', () => { - const source = componentSource('use-worktree-jump-palette-selection-actions.ts') - const section = sourceBetween( - source, - 'const handleSelectQuickAction', - 'const handleSelectProjectTarget' - ) - - expect(section).toContain("recordFeatureInteraction('cmd-j-create-workspace')") - expect(section).toContain("recordFeatureInteraction('cmd-j-quick-action')") - expect(section.indexOf("recordFeatureInteraction('cmd-j-create-workspace')")).toBeLessThan( - section.indexOf("recordFeatureInteraction('cmd-j-quick-action')") - ) - expect( - sourceBetween( - section, - "if (action.id === 'create-workspace')", - "recordFeatureInteraction('cmd-j-quick-action')" - ) - ).toContain('return') - }) - - it('records GitLab provider-depth for detail opens, workspace use, and dialog mutations', () => { - const detailsSource = componentSource('gitlab-item-dialog/use-gitlab-details-editing.ts') - const primarySource = componentSource('gitlab-item-dialog/use-gitlab-primary-actions.ts') - const reviewSource = componentSource('gitlab-item-dialog/use-gitlab-review-actions.ts') - const gitlabWriter = "recordFeatureInteraction('gitlab-tasks')" - - expect( - sourceBetween( - componentSource('task-page/gitlab/ItemList.tsx'), - '{displayedGitLabItems.map((item) => (', - 'handleUseGitLabItem(item)' - ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) - ).toHaveLength(2) - expect( - sourceBetween( - componentSource('use-task-page-workspace-actions.ts'), - 'const handleUseGitLabItem', - 'const nextModel' - ) - ).toContain(gitlabWriter) - - const mutationSections = [ - sourceBetween(detailsSource, 'const handleSaveDetails', 'return {'), - sourceBetween(reviewSource, 'const handleSetReviewers', 'const handleSubmitInlineComment'), - sourceBetween( - reviewSource, - 'const handleSubmitInlineComment', - 'const handleResolveDiscussion' - ), - sourceBetween(primarySource, 'const handleClose', 'const handleReopen'), - sourceBetween(primarySource, 'const handleReopen', 'const handleMerge'), - sourceBetween(primarySource, 'const handleMerge', 'const handleSubmitComment'), - sourceBetween(primarySource, 'const handleSubmitComment', 'return {'), - sourceBetween(reviewSource, 'const handleResolveDiscussion', 'return {') - ] - for (const section of mutationSections) { - expect(section).toContain(gitlabWriter) - expect(section).toContain('showGitLabMutationError') - } - }) - - it('keeps nested GitLab row actions from also opening task details by keyboard', () => { - const rowSection = sourceBetween( - componentSource('task-page/gitlab/ItemList.tsx'), - 'onKeyDown={(e) => {', - 'className="grid w-full cursor-pointer' - ) - expect(rowSection).toContain('e.target !== e.currentTarget') - expect(rowSection.indexOf('e.target !== e.currentTarget')).toBeLessThan( - rowSection.indexOf("e.key === 'Enter'") - ) - }) - - it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('task-page/gitlab/ItemList.tsx')).toContain( - 'key={`${item.repoId}:${item.id}`}' - ) - }) - - it('records Linear provider-depth for inline edits, board drops, creation, and workspace use', () => { - const drawerSource = [ - componentSource('linear-item-drawer-edit-controller.tsx'), - componentSource('linear-item-drawer-comment-footer.tsx') - ].join('\n') - const linearWriter = "recordFeatureInteraction('linear-tasks')" - - const taskPageSections = [ - sourceBetween( - componentSource('task-page-linear-issue-model.tsx'), - 'export function LinearStateCell', - 'return (' - ), - sourceBetween( - componentSource('use-task-page-linear-board.ts'), - 'const handleLinearBoardDrop', - 'const toggleLinearDisplayProperty' - ), - sourceBetween( - componentSource('use-task-page-linear-issue-creation.ts'), - 'const handleCreateNewLinearIssue', - 'const nextModel' - ), - sourceBetween( - componentSource('use-task-page-composer-actions.ts'), - 'const handleUseLinearItem', - 'const handleLinearWorkspaceChange' - ) - ] - for (const section of taskPageSections) { - expect(section).toContain(linearWriter) - } - - const drawerMutationSections = [ - sourceBetween(drawerSource, 'const handleStateChange', 'const handlePriorityChange'), - sourceBetween(drawerSource, 'const handlePriorityChange', 'const handleEstimateChange'), - sourceBetween(drawerSource, 'const handleEstimateChange', 'const handleEstimateSubmit'), - sourceBetween(drawerSource, 'const handleAssigneeChange', 'const handleLabelToggle'), - sourceBetween(drawerSource, 'const handleLabelToggle', 'return {'), - sourceBetween(drawerSource, 'const handleSubmit = useCallback(async () => {', 'return (') - ] - for (const section of drawerMutationSections) { - expect(section).toContain(linearWriter) - } - }) - - it('records Jira provider-depth for workspace use', () => { - const jiraWriter = "recordFeatureInteraction('jira-tasks')" - - // End boundary is the declaration after the handler: the Jira connect flow - // now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior - // marker) no longer exists in TaskPage. - expect( - sourceBetween( - componentSource('use-task-page-composer-actions.ts'), - 'const handleUseJiraItem', - 'const nextModel' - ) - ).toContain(jiraWriter) - }) - - it('records browser annotation agent handoff only from the prompt-delivered callback', () => { - const source = componentSource('browser-pane/annotate/use-browser-page-annotation-send.ts') - expect( - source.match(/recordFeatureInteraction\('browser-annotations-sent-to-agent'\)/g) - ).toHaveLength(1) - expect( - sourceBetween( - source, - 'const handleBrowserAnnotationsSentToAgent', - 'const handleClearBrowserAnnotations' - ) - ).toContain("recordFeatureInteraction('browser-annotations-sent-to-agent')") - expect( - sourceBetween( - source, - 'const handleCopyBrowserAnnotations', - 'const handleBrowserAnnotationsSentToAgent' - ) - ).not.toContain("recordFeatureInteraction('browser-annotations-sent-to-agent')") - expect( - sourceBetween( - source, - 'const handleClearBrowserAnnotations', - 'const handleDeleteBrowserAnnotation' - ) - ).not.toContain("recordFeatureInteraction('browser-annotations-sent-to-agent')") - }) - - it('records floating workspace hide only from explicit disable or hide actions', () => { - const allowedSources = [ - componentSource('settings/FloatingWorkspacePane.tsx'), - componentSource('floating-terminal/FloatingTerminalIconContextMenu.tsx') - ].join('\n') - const passiveSources = [ - componentSource('../App.tsx'), - componentSource('floating-terminal/FloatingTerminalPanel.tsx') - ].join('\n') - - expect( - allowedSources.match(/recordFeatureInteraction\('floating-workspace-hidden'\)/g) ?? [] - ).toHaveLength(2) - expect(passiveSources).not.toContain("recordFeatureInteraction('floating-workspace-hidden')") - }) -}) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts deleted file mode 100644 index 630ae68a7a0..00000000000 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname - -function componentSource(relativePath: string): string { - // Why: CRLF checkouts would otherwise break every multi-line source assertion. - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8').replace(/\r\n?/g, '\n') -} - -function destSources(...relativePaths: string[]): string { - return relativePaths.map((relativePath) => componentSource(relativePath)).join('\n') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('GitHubItemDialog source host boundaries', () => { - it('does not keep the stale right-side sheet owner', () => { - const source = destSources( - 'GitHubItemDialog.tsx', - 'github-item-dialog/open-dialog/github-item-dialog.tsx' - ) - - expect(source).not.toContain('@/components/ui/sheet') - expect(source).not.toContain(' { - const source = destSources( - 'github-item-dialog/land-pull-request/pr-reviewers-panel.tsx', - 'github-item-dialog/land-pull-request/pr-reviewers-request-actions.ts' - ) - const section = source - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('sourceSettings') - expect(section).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(section).toContain( - 'const runtimeRepo = getGitHubRuntimeRepoId(sourceContext, item.repoId)' - ) - expect(section).toContain("'github.requestPRReviewers'") - expect(section).toContain("'github.removePRReviewers'") - expect(section).toContain('resolvePullRequestRepo(item, projectOrigin)') - expect(section.match(/prRepo: reviewRepo/g)).toHaveLength(4) - expect(section).toContain('notifyWorkItemDetailsMutation(') - expect(section).toContain('{ local: false }') - }) - - it('routes edit metadata through the same task source as issue mutations', () => { - const source = destSources( - 'github-item-dialog/edit-item-fields/gh-edit-section.tsx', - 'github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts' - ) - const section = source - const helperSection = componentSource('github/github-work-item-edit-mutations.ts') - - expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') - expect(section).toContain('useRepoLabels(') - expect(section).toContain('useRepoLabelsBySlug(') - expect(section).toContain('projectOrigin?.host') - expect(section).toContain('useRepoAssignees(') - expect(section).toContain('useRepoAssigneesBySlug(') - expect(section).toContain('sourceSettings') - expect(helperSection).toContain("'github.updateIssue'") - expect(helperSection).toContain("'github.updatePRState'") - expect(helperSection).toContain("'github.project.updateIssueBySlug'") - expect(helperSection).toContain("'github.project.updatePullRequestBySlug'") - expect(helperSection).toContain("args.sourceContext?.provider === 'github'") - expect(helperSection).toContain('getTaskSourceRuntimeSettings(args.sourceContext)') - expect(helperSection).toContain( - 'getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext)' - ) - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain( - "repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? '')" - ) - expect(helperSection).toContain('{ local: false }') - }) - - it('uses source-aware details routing and cache identity', () => { - const source = destSources( - 'github-item-dialog/load-item-details/work-item-details-cache.ts', - 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts' - ) - const cacheKeySection = sourceBetween( - source, - 'function getWorkItemDetailsCacheKey', - 'function touchWorkItemDetailsCache' - ) - const matchInvalidationSection = sourceBetween( - source, - 'function invalidateWorkItemDetailsCacheByMatch', - 'function patchCachedPRFileViewedState' - ) - - expect(source).toContain('lookupGitHubWorkItemDetailsForSource({') - expect(source).toContain('sourceContext,') - expect(cacheKeySection).toContain('sourceCacheScope') - expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' - ) - }) - - it('treats null details as unavailable while preserving empty detail payloads', () => { - const source = destSources( - 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts', - 'github-item-dialog/load-item-details/work-item-details-fetch-settle.ts' - ) - const loadedSection = sourceBetween( - source, - 'const loading = !!cachedEntry?.pending && !cachedEntry?.details', - '// Why: if a cross-window mutation invalidates' - ) - const resultSection = sourceBetween(source, 'inflight', '.catch((err) => {') - - expect(loadedSection).toContain('const detailsLoaded = Boolean(cachedEntry?.details)') - expect(loadedSection).not.toContain('fetchedAt > 0') - expect(resultSection).toContain('} else if (result === null) {') - expect(resultSection).toContain('error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE') - expect(resultSection).toContain('details: result') - }) - - it('routes PR file viewed mutations through the task source context', () => { - const changeSection = destSources( - 'github-item-dialog/load-item-details/use-github-item-dialog-details.ts', - 'github-item-dialog/load-item-details/pr-file-viewed-change.ts' - ) - const helperSection = componentSource('github/github-work-item-comment-mutations.ts') - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.setPRFileViewed'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('{ local: false }') - expect(changeSection).toContain('canUseDetailsRepoContext') - expect(changeSection).toContain('repoPath: repoPath ??') - expect(changeSection).toContain('sourceContext,') - }) - - it('routes comment mutations through runtime source context when needed', () => { - const helperSource = componentSource('github/github-work-item-comment-mutations.ts') - const helperSection = sourceBetween( - helperSource, - 'function addIssueCommentForRepo', - 'function setPRFileViewedForRepo' - ) - - expect(helperSection).toContain('getGitHubSourceRuntimeHost(args.sourceContext)') - expect(helperSection).toContain("'github.addIssueComment'") - expect(helperSection).toContain("'github.addPRReviewComment'") - expect(helperSection).toContain("'github.addPRReviewCommentReply'") - expect(helperSection).toContain('repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)') - expect(helperSection).toContain('sourceContext: args.sourceContext') - expect(helperSection).toContain('notifyWorkItemDetailsMutation(') - expect(helperSection).toContain('{ local: false }') - }) - - it('routes PR file contents and runtime viewed invalidations through the task source context', () => { - const source = destSources( - 'github-item-dialog/load-item-details/work-item-details-cache.ts', - 'github-item-dialog/load-item-details/pr-file-content-cache.ts' - ) - const commentMutations = componentSource('github/github-work-item-comment-mutations.ts') - const fileContentsSection = sourceBetween( - source, - 'function loadPRFileContents', - 'touchPRFileContentCache(cacheKey, request)' - ) - const fileContentsCacheKeySection = sourceBetween( - source, - 'function getPRFileContentCacheKey', - 'function loadPRFileContents' - ) - const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') - - expect(fileContentsCacheKeySection).toContain( - 'source:${getTaskSourceCacheScope(args.sourceContext)}' - ) - expect(fileContentsSection).toContain("'github.prFileContents'") - expect(fileContentsSection).toContain( - 'repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId)' - ) - expect(fileContentsSection).toContain('sourceContext: args.sourceContext') - expect(fileContentsSection).toContain('sourceContext,') - expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(commentMutations).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') - expect(commentMutations).toContain('options.local !== false') - expect(commentMutations).toContain('notifyWorkItemMutated({') - }) - - it('routes merge actions through the repo owner host (#6957)', () => { - const actionsSection = componentSource( - 'github-item-dialog/land-pull-request/pr-actions-panel.tsx' - ) - - expect(actionsSection).toContain( - 'getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext)' - ) - expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') - expect(actionsSection).toContain( - 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' - ) - expect(actionsSection).toContain("'github.mergePR'") - expect(actionsSection).toContain("'github.setPRAutoMerge'") - expect(actionsSection).toContain('const prRepo = resolvePullRequestRepo(item, projectOrigin)') - expect(actionsSection).not.toContain('prRepo: item.prRepo ?? null') - expect(actionsSection).toContain( - 'repo: getGitHubRuntimeRepoId(sourceContext, repoId ?? item.repoId)' - ) - expect(actionsSection).toContain('sourceContext,') - expect(actionsSection).toContain('notifyWorkItemDetailsMutation(') - expect(actionsSection).toContain('{ local: false }') - }) - - it('routes check actions through the task source context', () => { - const checksSection = destSources( - 'github-item-dialog/inspect-pull-request/checks-tab.tsx', - 'github-item-dialog/inspect-pull-request/checks-tab-actions.ts', - 'github-item-dialog/inspect-pull-request/checks-tab-request-details.ts' - ) - - expect(checksSection).toContain('sourceContext?: TaskSourceContext | null') - expect(checksSection).toContain('sourceContext,') - expect(checksSection).toContain("'github.prChecks'") - expect(checksSection).toContain("'github.rerunPRChecks'") - expect(checksSection).toContain("'github.prCheckDetails'") - expect(checksSection).toContain( - 'repo: getGitHubRuntimeRepoId(ctx.sourceContext, ctx.repoId ?? ctx.itemRepoId)' - ) - expect(checksSection).toContain('window.api.gh.prChecks({') - expect(checksSection).toContain('window.api.gh.rerunPRChecks({') - expect(checksSection).toContain('prCheckDetails({') - expect(checksSection).toMatch( - /withGitHubCheckDetailsTimeout\(\(signal\) =>\s*ctx\.runtimeHost\s*\?\s*callRuntimeRpc[\s\S]*:\s*window\.api\.gh\.prCheckDetails\(\{/ - ) - expect(checksSection).toContain('{ timeoutMs: 30_000, signal }') - }) - - it('makes failed check detail loads retryable and fences stale responses', () => { - const checksSection = destSources( - 'github-item-dialog/inspect-pull-request/checks-tab.tsx', - 'github-item-dialog/inspect-pull-request/checks-tab-actions.ts', - 'github-item-dialog/inspect-pull-request/checks-tab-request-details.ts', - 'github-item-dialog/inspect-pull-request/checks-tab-check-details.tsx' - ) - - expect(checksSection).toContain('createGitHubChecksTabState(checks, checkDetailsContextKey)') - expect(checksSection).toContain('checksState,\n checks,\n checkDetailsContextKey') - expect(checksSection).toContain('resetGitHubChecksTabForSource(current)') - expect(checksSection).toContain( - 'committedChecksContextOwnerRef.current !== refreshContextOwner' - ) - expect(checksSection).toContain('activeChecksRefreshRequestIdRef.current !== refreshRequestId') - expect(checksSection).toContain('current.contextOwner === refreshContextOwner') - expect(checksSection).toContain( - 'const rerunContextOwner = ctx.committedChecksContextOwnerRef.current' - ) - expect(checksSection).toContain( - 'ctx.committedChecksContextOwnerRef.current !== rerunContextOwner' - ) - expect(checksSection).toContain('await refreshGitHubChecksTab(ctx, rerunContextOwner)') - expect(checksSection).toContain('!ctx.mountedRef.current ||') - expect(checksSection).toContain('settleGitHubChecksTabDetails(current, key, requestId, next)') - expect(checksSection).toContain('onClick={() => onRetry(check, getCheckDetailsKey(check))}') - expect(checksSection).toContain('disabled={state.loading}') - expect(checksSection).toContain('aria-busy={state.loading}') - expect(checksSection).toContain("translate('githubChecks.retrying', 'Retrying…')") - expect(checksSection).toContain("'Retry'") - }) - - it('uses hydrated work item details for the page checks tab', () => { - const source = componentSource('github-item-dialog/open-dialog/github-item-dialog-pr-tabs.tsx') - const checksTab = sourceBetween( - source, - ' { - const editSection = destSources( - 'github-item-dialog/edit-item-fields/gh-edit-section.tsx', - 'github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts' - ) - expect(editSection).toContain('assertTaskPageGitHubDialogStateAuthority({') - expect(editSection).toContain('if (authority?.revert())') - - const actionsSection = componentSource( - 'github-item-dialog/land-pull-request/pr-actions-panel.tsx' - ) - expect(actionsSection.match(/assertTaskPageGitHubDialogStateAuthority\(\{/g)).toHaveLength(2) - expect(actionsSection).toContain('if (authority.revert())') - expect(actionsSection).toContain("state: 'merged'") - }) -}) diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index 8b33b3dc40c..ddf7fb92cff 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -4,15 +4,12 @@ * Regression guard (was a repro that documented the broken path). After the fix: * - resolveGitHubUserAvatarSrc prefers API URLs * - PullRequestPage author uses authorAvatarUrl via GitHubUserAvatar - * - TaskPage ReviewChipAvatar no longer hardcodes github.com login.png * * Re-run: * pnpm exec vitest run --config config/vitest.config.ts \ * src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts */ import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { githubAvatarUrl, resolveGitHubUserAvatarSrc } from './github-user-avatar' describe('issue #8784 GHE avatar fallback (regression)', () => { @@ -38,86 +35,4 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { // enrichment + image onError → initials when this 404s. expect(githubAvatarUrl('corp-user')).toBe('https://github.com/corp-user.png?size=64') }) - - it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => { - const prPage = readFileSync(join(__dirname, '../pull-request-page/page/header.tsx'), 'utf8') - expect(prPage).toMatch(/GitHubUserAvatar/) - expect(prPage).toMatch(/authorAvatarUrl/) - // Why: author chip must not ignore API avatar_url and only pass login. - expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) - - const reviewChip = readFileSync(join(__dirname, '../task-page/github/Avatars.tsx'), 'utf8') - expect(reviewChip).toMatch(/GitHubUserAvatar/) - // Why: list chip must not hardcode github.com/{login}.png. - expect(reviewChip).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) - }) - - // GHES URLs can exist but fail unauthenticated; target slots need onError fallbacks. - // Scope checks because TaskPage also renders non-GitHub provider avatars. - const GITHUB_AVATAR_SLOTS = [ - { - file: 'pull-request-page/reviewers/picker-row.tsx', - fn: 'ReviewerPickerRow', - login: 'reviewer.login', - displayName: 'reviewer.name' - }, - { - file: 'pull-request-page/conversation/comment-card.tsx', - fn: 'ConversationCommentCard', - login: 'comment.author', - displayName: null - }, - { - file: 'pull-request-page/mentions/textarea.tsx', - fn: 'MentionTextarea', - login: 'option.login', - displayName: 'option.name' - }, - { - file: 'task-page/github/Avatars.tsx', - fn: 'GitHubAssigneeAvatar', - login: 'assignee.login', - displayName: 'assignee.name' - }, - { - file: 'task-page/github/AssigneesCell.tsx', - fn: 'GHAssigneesCell', - login: 'user.login', - displayName: 'user.name' - }, - { - file: 'task-page/github/ReviewerPicker.tsx', - fn: 'TaskPageGitHubReviewerPicker', - login: 'reviewer.login', - displayName: 'reviewer.name' - } - ] as const - - function componentBody(file: string, fn: string): string { - const source = readFileSync(join(__dirname, '..', file), 'utf8') - const start = source.indexOf(`function ${fn}`) - expect(start, `${file}: function ${fn} not found`).toBeGreaterThanOrEqual(0) - const next = source.indexOf('\nfunction ', start + 1) - return source.slice(start, next === -1 ? undefined : next) - } - - it.each(GITHUB_AVATAR_SLOTS)( - 'renders the $fn avatar through GitHubUserAvatar (#13976)', - ({ file, fn, login }) => { - const body = componentBody(file, fn) - - // Reject aliases and resolver expressions as well as direct avatarUrl fields. - expect(body, `${fn} still renders a bare img`).not.toMatch(/]/) - expect(body, `${fn} does not use GitHubUserAvatar`).toContain(' slot.displayName !== null))( - 'passes the $fn display name so initials are not reduced to one letter (#13976)', - ({ file, fn, displayName }) => { - expect(componentBody(file, fn)).toContain(`name={${displayName}}`) - } - ) }) diff --git a/src/renderer/src/components/linear-issue-workspace-api-parity.test.ts b/src/renderer/src/components/linear-issue-workspace-api-parity.test.ts deleted file mode 100644 index 91310627cf7..00000000000 --- a/src/renderer/src/components/linear-issue-workspace-api-parity.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -function componentSource(file: string): string { - return readFileSync(join(__dirname, file), 'utf8') -} - -describe('Linear issue workspace provider API parity', () => { - it('keeps detail and comments on the selected source and workspace', () => { - const source = componentSource('linear-issue-workspace-detail-state.ts') - expect(source).toContain('initialRequest.providerSettings,') - expect(source).toContain('initialRequest.issue.id,') - expect(source).toContain('initialRequest.issue.workspaceId') - expect(source).toContain('targetProviderSettings,') - expect(source).toContain('targetIssue.id,') - expect(source).toContain('targetIssue.workspaceId') - }) - - it('keeps project search and assignment source-scoped', () => { - const source = componentSource('linear-issue-project-selector.tsx') - expect(source).toContain( - 'linearListProjects(providerSettings, requestQuery, 20, issue.workspaceId)' - ) - expect(source).toMatch( - /linearUpdateIssue\(\s*providerSettings,\s*issue\.id,\s*\{ projectId: project\.id \},\s*issue\.workspaceId/ - ) - expect(source).toContain('patchLinearIssue(issue.id, { project }, { sourceContext })') - }) - - it('keeps sub-issue create and open payloads source-scoped', () => { - const source = componentSource('linear-issue-sub-issues.tsx') - expect(source).toContain('parentIssueId: issue.id') - expect(source).toContain('teamId: issue.team.id') - expect(source).toContain('workspaceId: issue.workspaceId') - expect(source).toContain('projectId: issue.project?.id ?? null') - expect(source).toMatch( - /fetchLinearIssue\(subIssue\.id, issue\.workspaceId, \{\s*sourceContext\s*\}\)/ - ) - }) - - it('keeps git and folder workspace attachments in the same lookup', () => { - const source = componentSource('LinearIssueWorkspace.tsx') - expect(source).toContain('...allWorktrees') - expect(source).toContain('...folderWorkspaces.map(folderWorkspaceToWorktree)') - expect(source).toContain('findLinearIssueWorkspaceAttachment(attachmentWorkspaces') - expect(source).toContain('openLinearIssueWorkspaceOrStart(detail.displayed') - }) -}) diff --git a/src/renderer/src/components/linear-issue-workspace-header.test.ts b/src/renderer/src/components/linear-issue-workspace-header.test.ts deleted file mode 100644 index 4fd36b23bf4..00000000000 --- a/src/renderer/src/components/linear-issue-workspace-header.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const source = readFileSync(join(__dirname, 'linear-issue-workspace-header.tsx'), 'utf8') - -function headerSource(): string { - const start = source.indexOf('
    ') - expect(header).toContain('') - expect(header).toContain("'Start workspace'") - expect(header).toContain("'Start workspace from issue'") - expect(header).not.toContain("'Copy issue identifier'") - expect(header).toMatch(/size="sm"[\s\S]*onClick=\{onOpenOrUseIssue\}/) - expect(header).not.toMatch(/size="icon-sm"\s+onClick=\{onOpenOrUseIssue\}/) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx deleted file mode 100644 index ffa8787e753..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import React from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -describe('NativeChatStructuredSession transport-unconfirmed sends', () => { - afterEach(() => { - cleanup() - resetStructuredSessionMocks() - }) - - it('retries an unconfirmed transport send and clears the delivery notice', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValueOnce({ - ok: true, - value: { - submission: { - clientMessageId: 'client-1', - dispatchState: 'accepted' - } - } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('hello', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - }) - - it('resends a transport-unconfirmed head so later messages are not wedged', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - expect(send?.('second', [])).toBe(true) - // The head is probed automatically, clears, and the queue drains. - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3), { timeout: 10000 }) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - }, 20000) - - it('probes the same operation without marking an explicit user retry', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 10000 }) - - const first = mocks.call.mock.calls[0]?.[2] as Record - const probe = mocks.call.mock.calls[1]?.[2] as Record - expect(probe.retryUnknown).toBeUndefined() - // Same operation id: both dedupe layers key off it. - expect((probe.envelope as { clientOperationId: string }).clientOperationId).toBe( - (first.envelope as { clientOperationId: string }).clientOperationId - ) - }, 20000) - - it('parks a host-confirmed unknown instead of probing it', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - // The host now reports an unresolved unknown: another replay is the user's call. - mocks.submissions = [ - { - clientMessageId: sent.envelope.clientOperationId, - fence: 1, - payloadFingerprint: 'fp', - dispatchState: 'unknown', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - ] - // Queue a second message purely to re-render so the effect observes the - // new submissions; it must stay wedged behind the parked head. - await act(async () => { - send?.('second', []) - }) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) - expect(mocks.call).toHaveBeenCalledOnce() - }, 20000) - - it('still probes while streaming batches rebuild the submissions array', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - const makeView = (): React.ReactElement => ( - - ) - const { rerender } = render(makeView()) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // Each batch mints a fresh submissions array for an unrelated message. An - // array-identity dependency restarts the backoff on every one of these, so a - // stream that outlasts the delay would never let the probe fire. - for (let index = 0; index < 12; index += 1) { - mocks.submissions = [ - { - clientMessageId: `other-${index}`, - fence: 1, - payloadFingerprint: 'fp', - dispatchState: 'accepted', - providerItemId: null, - reason: null, - submittedAt: index, - resolvedAt: index - } - ] - await act(async () => { - rerender(makeView()) - await new Promise((resolve) => setTimeout(resolve, 250)) - }) - } - - // Asserted with no trailing grace period: the probe must have fired *during* - // the stream, not after it went quiet. - expect(mocks.call).toHaveBeenCalledTimes(2) - }, 20000) - - it('restarts probe delay when the runtime target changes', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - - const makeView = ( - target: { kind: 'local' } | { kind: 'environment'; environmentId: string } - ) => ( - - ) - const { rerender } = render(makeView({ kind: 'local' })) - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)) - }) - rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 600)) - }) - expect(mocks.call).toHaveBeenCalledOnce() - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 }) - }, 10000) - - it('never auto-probes an entry the user already force-retried', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - // Both the original send and the user's explicit Retry fail at the transport. - mocks.call.mockRejectedValue(new Error('socket closed')) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - // User retries with the same envelope and no legacy redelivery signal. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record | undefined - expect(forcedRequest?.retryUnknown).toBeUndefined() - - // That retry also failed at the transport. The probe must not repeat an - // explicit retry automatically. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) - expect(mocks.call).toHaveBeenCalledTimes(2) - }, 20000) - - it('does not hot-loop when the host answers pending', async () => { - mocks.mode = 'outbox' - mocks.call.mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'pending' } } - }) - - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // A pending row parks the entry under the backoff instead of re-dispatching - // immediately. Without that, this window is an unbounded back-to-back RPC flood. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 2500)) - }) - expect(mocks.call.mock.calls.length).toBeLessThanOrEqual(3) - }, 20000) - - it('keeps probing past the old five-attempt budget', async () => { - mocks.mode = 'outbox' - mocks.call.mockRejectedValue(new Error('socket closed')) - vi.useFakeTimers({ shouldAdvanceTime: true }) - try { - render( - - ) - - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - - // Backoff is 1+2+4+8+16 = 31s for five probes, which was the old hard budget. - // Step past it; a seventh call proves the probe re-arms instead of giving up. - for (let step = 0; step < 12; step += 1) { - await act(async () => { - await vi.advanceTimersByTimeAsync(8_000) - }) - } - expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(7) - } finally { - vi.useRealTimers() - } - }, 30000) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts deleted file mode 100644 index 2c8a08ffa89..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import fs from 'node:fs' -import { describe, expect, it } from 'vitest' - -const composerField = fs.readFileSync( - new URL('./NativeChatComposerField.tsx', import.meta.url), - 'utf8' -) -const autocompleteMenus = fs.readFileSync( - new URL('./NativeChatAutocompleteMenus.tsx', import.meta.url), - 'utf8' -) - -describe('native chat composer paint containment (#10481)', () => { - it('bounds caret repaints to the composer input shell', () => { - expect(composerField).toContain('[contain:paint]') - }) - - it('keeps the outer composer uncontained so the pickers can overflow it', () => { - // The pickers are siblings that render above the shell via `bottom-full`; - // containing their parent would clip them. - const outerShell = composerField.slice(0, composerField.indexOf('[contain:paint]')) - expect(outerShell).toContain('
    ') - expect(outerShell).not.toContain('contain:paint') - }) - - it('lifts both pickers above the contained shell', () => { - // The shell is a stacking context now, so it paints at z-index 0 in tree - // order — an unlayered picker would lose its drop shadow to it. - for (const picker of ['bottom-full left-0 right-0 z-20', 'bottom-full left-3 right-3 z-20']) { - expect(autocompleteMenus).toContain(picker) - } - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-noise.test.ts b/src/renderer/src/components/native-chat/native-chat-noise.test.ts deleted file mode 100644 index a9942ed82d7..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-noise.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { isNoiseMessage, stripNoiseMessages } from './native-chat-noise' - -function msg( - role: NativeChatMessage['role'], - text: string, - blocks?: NativeChatMessage['blocks'] -): NativeChatMessage { - return { - id: text.slice(0, 8), - role, - blocks: blocks ?? [{ type: 'text', text }], - timestamp: 0, - source: 'transcript' - } -} - -describe('isNoiseMessage', () => { - it('flags task-notification and system-reminder user turns', () => { - expect(isNoiseMessage(msg('user', '\nx'))).toBe(true) - expect(isNoiseMessage(msg('user', '\nbe careful'))).toBe(true) - expect( - isNoiseMessage( - msg( - 'user', - 'Caveat: The messages below were generated by the user while running local commands' - ) - ) - ).toBe(true) - expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true) - }) - - it('keeps real user messages', () => { - expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) - expect(isNoiseMessage(msg('user', 'pasted code'))).toBe(false) - }) - - it('keeps assistant and tool turns', () => { - expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) - expect(isNoiseMessage(msg('system', 'Conversation interrupted'))).toBe(false) - }) - - it('keeps a user turn that carries tool results', () => { - expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false) - }) - - it('stripNoiseMessages removes only the noise', () => { - const out = stripNoiseMessages([ - msg('user', 'hello'), - msg('user', 'done'), - msg('assistant', 'hi') - ]) - expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts deleted file mode 100644 index 12539a08acc..00000000000 --- a/src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const WORKTREE_CONTEXT_SOURCE = readFileSync( - join(__dirname, 'source-control/listing/use-worktree-context.ts'), - 'utf8' -) -const PR_GENERATION_SOURCE = readFileSync( - join(__dirname, 'source-control/review/use-pull-request-generation.ts'), - 'utf8' -) -const CREATE_REVIEW_COMPOSER_SOURCE = readFileSync( - join(__dirname, 'source-control/review/use-create-review-composer.ts'), - 'utf8' -) -const STATUS_REFRESH_SOURCE = readFileSync( - join(__dirname, 'source-control/sync/use-status-refresh.ts'), - 'utf8' -) -const BASE_REF_DEFAULT_SOURCE = readFileSync( - join(__dirname, 'source-control/sync/use-base-ref-default.ts'), - 'utf8' -) - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('SourceControl host-context boundaries', () => { - it('snapshots PR generation host ownership and reuses it after async branch preparation', () => { - const generateSection = sourceBetween( - PR_GENERATION_SOURCE, - 'const handleGeneratePullRequestFieldsForActive = useCallback(', - 'const handleCancelGeneratePullRequestFieldsForActive = useCallback(' - ) - expect(generateSection).toContain('runtimeTargetSettings: activeRepoSettings') - expect(generateSection).toContain('settings: context.runtimeTargetSettings') - - const cancelSection = sourceBetween( - PR_GENERATION_SOURCE, - 'const handleCancelGeneratePullRequestFieldsForActive = useCallback(', - 'const handlePullRequestGenerationSeedRestored = useCallback(' - ) - expect(cancelSection).toContain('settings: record.context.runtimeTargetSettings') - - const refreshSection = sourceBetween( - STATUS_REFRESH_SOURCE, - 'const refreshGitStatusAfterPullRequestGeneration = useCallback(', - ' return {' - ) - expect(refreshSection).toContain('settings: context.runtimeTargetSettings') - expect(refreshSection).not.toContain('settings: activeRepoSettings') - }) - - it('routes create-review field generation through caller-provided owner settings', () => { - const composerCall = sourceBetween( - CREATE_REVIEW_COMPOSER_SOURCE, - '} = useCreatePullRequestDialogFields({', - 'const handleGeneratePullRequestFieldsClick = useCallback' - ) - expect(composerCall).toContain('settings: activeRepoSettings') - - const hookSource = readFileSync( - join(__dirname, 'use-create-pull-request-field-generation.ts'), - 'utf8' - ) - const requestContext = sourceBetween(hookSource, 'const requestContext = {', 'const seed = {') - expect(requestContext).toContain('settings,') - expect(requestContext).not.toContain('useAppStore.getState().settings') - }) - - it('keeps eligibility base refreshes scoped to repo execution ownership', () => { - const ownerSettingsSection = sourceBetween( - WORKTREE_CONTEXT_SOURCE, - 'const activeRepoSettings = useMemo(', - 'const activeRepoRuntimeEnvironmentId' - ) - expect(ownerSettingsSection).not.toContain('activeRepo ?? null') - expect(ownerSettingsSection).toContain( - '[activeRepoConnectionId, activeRepoExecutionHostId, activeRepoId, settings]' - ) - - const baseRefSection = sourceBetween( - BASE_REF_DEFAULT_SOURCE, - '// Why: reset to null so that effectiveBaseRef becomes falsy until the IPC', - ' return defaultBaseRef' - ) - expect(baseRefSection).toContain( - 'getRuntimeRepoBaseRefDefault(\n { activeRuntimeEnvironmentId: activeRepoRuntimeEnvironmentId },\n activeRepoId' - ) - const dependencyBlock = sourceBetween(baseRefSection, ' }, [', ' ])') - const dependencyEntries = dependencyBlock - .split('\n') - .slice(1) - .map((line) => line.trim().replace(/,$/, '')) - .filter((line) => line.length > 0 && !line.startsWith('//')) - expect(dependencyEntries).toEqual([ - 'activeRepoConnectionId', - 'activeRepoExecutionHostId', - 'activeRepoId', - 'activeRepoRuntimeEnvironmentId', - 'isBranchVisible', - 'isFolder' - ]) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 567e080360a..db39129526e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -819,11 +819,4 @@ describe('useAiVaultSessionRefresh in a non-secure context', () => { }) }) }) - - // Guards the stub itself: an own-property stub of randomUUID is unrestorable, so a - // leaky teardown would silently strip the real method from every later test in the file. - it('leaves the real crypto.randomUUID in place afterwards', () => { - // oxlint-disable-next-line no-restricted-properties -- asserting the restore this case exists for - expect(typeof globalThis.crypto.randomUUID).toBe('function') - }) }) diff --git a/src/renderer/src/components/settings/GeneralPane.test.ts b/src/renderer/src/components/settings/GeneralPane.test.ts index 482fc1df787..e8fd5ea998b 100644 --- a/src/renderer/src/components/settings/GeneralPane.test.ts +++ b/src/renderer/src/components/settings/GeneralPane.test.ts @@ -4,7 +4,6 @@ import { getDesktopPlatformFromUserAgent, getGeneralPaneSearchEntries, getTabOrderControlSearchKeywords, - shouldCommitOpenInApplicationsDraft, shouldShowProjectRuntimeSection, updateAutoSaveDelayDraftState } from './GeneralPane' @@ -30,36 +29,6 @@ describe('GeneralPane auto-save delay drafts', () => { }) }) -describe('GeneralPane open-in application drafts', () => { - it('does not commit rows until both label and command are present', () => { - expect( - shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: 'Cursor', command: '' }]) - ).toBe(false) - expect( - shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: '', command: 'cursor' }]) - ).toBe(false) - expect( - shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: ' ', command: 'cursor' }]) - ).toBe(false) - expect( - shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: 'Cursor', command: ' ' }]) - ).toBe(false) - }) - - it('allows commit when every draft row has a label and command', () => { - expect(shouldCommitOpenInApplicationsDraft([])).toBe(true) - expect( - shouldCommitOpenInApplicationsDraft([{ id: 'cursor', label: 'Cursor', command: 'cursor' }]) - ).toBe(true) - expect( - shouldCommitOpenInApplicationsDraft([ - { id: 'cursor', label: 'Cursor', command: 'cursor' }, - { id: 'zed', label: 'Zed', command: 'zed' } - ]) - ).toBe(true) - }) -}) - describe('GeneralPane desktop platform detection', () => { it('keeps Windows available for Windows-only CLI settings', () => { expect( diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts deleted file mode 100644 index 15f7cc04a98..00000000000 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const SOURCE = readFileSync(join(__dirname, 'use-delete-worktree-status-hydration.ts'), 'utf8') - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('DeleteWorktreeDialog host-context boundaries', () => { - it('preloads git status from the selected worktree owner instead of the focused host', () => { - const effect = sourceBetween(SOURCE, 'deleteTargets.filter(', 'return () => {') - - expect(effect).toContain('getSettingsForWorktreeRuntimeOwner') - expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo') - expect(effect).toContain('target.id') - expect(effect).not.toContain('settings,\n worktreeId: target.id') - }) - - it('does not restart pending status requests when one target hydrates', () => { - const effect = sourceBetween(SOURCE, 'useEffect(() => {', '}, [') - - expect(SOURCE).not.toContain('useAppStore((state) => state.gitStatusByWorktree)') - expect(effect).toContain('useAppStore.getState().gitStatusByWorktree') - expect(effect).toContain('const controller = new AbortController()') - expect(effect).toContain('signal: controller.signal') - expect(effect).toContain('controller.abort()') - }) -}) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 1c238615628..668596ef6dd 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -36,15 +36,9 @@ describe('StatusIndicator', () => { it('renders working as a yellow spinner ring', () => { const markup = renderMarkup('working') - expect(markup).toContain('border-yellow-500') - expect(markup).toContain('border-t-transparent') - // Why: rotation must come from the compositor-driven CSS animation, not a - // JS clock writing per-element styles on the input thread (STA-3328). - expect(markup).toContain('agent-working-spinner') + // The spinner's own classes and animation contract belong to AgentWorkingSpinner.test.tsx; + // this pins only that 'working' reaches for it. expect(markup).toContain('data-agent-spinner') - // Why: under reduced motion the top border is filled so the static ring - // reads as a complete marker, not a broken partial spinner (#9515). - expect(markup).toContain('motion-reduce:border-t-yellow-500') }) it('renders monitoring as a static heartbeat glyph', () => { diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts deleted file mode 100644 index 2e7523e5ecb..00000000000 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -function readWorktreeListSource(): string { - return readFileSync( - fileURLToPath(new URL('./worktree-list/rows/SectionHeader.tsx', import.meta.url)), - 'utf8' - ) -} - -function readHeaderDragSource(): string { - return readFileSync( - fileURLToPath(new URL('./worktree-list/drag/use-header-drag.ts', import.meta.url)), - 'utf8' - ) -} - -describe('Project Group header drag DOM source', () => { - it('renders concrete Project Group header drag attributes separately from repo headers', () => { - const source = readWorktreeListSource() - - expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') - expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') - expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') - expect(source).toContain('data-project-group-header-drag-handle=') - }) - - it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { - const source = readHeaderDragSource() - - expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') - // Why: the commit must carry the group's owner host so a non-focused host still persists the order. - expect(source).toContain( - 'void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined })' - ) - }) - - it('keeps grab cursor on the title surface and dual handle attrs on row + surface', () => { - // Why: lock the cursor/hit-test split so a cleanup does not put grab back on the whole row - // or drop row-level handle attrs that arm drag from indent/padding. - const source = readWorktreeListSource() - const headerBlockStart = source.indexOf('data-repo-header-id={projectIdForHeader}') - const headerBlockEnd = source.indexOf('', headerBlockStart) - expect(headerBlockStart).toBeGreaterThan(-1) - expect(headerBlockEnd).toBeGreaterThan(headerBlockStart) - const headerBlock = source.slice(headerBlockStart, headerBlockEnd) - - // Dual handle placement: row (indent/padding) + title surface. - expect(headerBlock.match(/data-repo-header-drag-handle=/g)?.length).toBe(2) - expect(headerBlock.match(/data-project-group-header-drag-handle=/g)?.length).toBe(2) - // Surface owns grab; outer row only gets cursor-pointer when not draggable. - expect(headerBlock).toContain( - "!(isDraggableRepoHeader || isDraggableProjectGroupHeader) && 'cursor-pointer'" - ) - expect(headerBlock).toContain("'flex min-w-0 flex-1 items-center gap-1.5 self-stretch'") - expect(headerBlock).toContain("'cursor-grab active:cursor-grabbing'") - // Row-level ternary grab must stay gone. - expect(headerBlock).not.toMatch( - /isDraggableRepoHeader \|\| isDraggableProjectGroupHeader\s*\?\s*'cursor-grab/ - ) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts index 430736c92be..9f5562d91cd 100644 --- a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts +++ b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import type { HostSectionRow } from './host-section-rows' import { @@ -151,27 +149,3 @@ describe('getCyclableWorktreeIds', () => { expect(getCyclableWorktreeIds(rows, 'single-location')).toEqual(['visible-after-host']) }) }) - -describe('WorktreeList keyboard cycling', () => { - it('cycles over the rendered rows instead of rebuilding a parallel layout', () => { - const source = readFileSync( - fileURLToPath(new URL('./worktree-list/navigation/use-keyboard.ts', import.meta.url)), - 'utf8' - ) - const navigateWorktree = source.slice( - source.indexOf('const navigateWorktree = useCallback('), - source.indexOf('const handleContainerKeyDown = useCallback(') - ) - - // Why: a second buildRows call drifts from the rendered layout (host sections, - // pinned placement); cycling must read the same rows the viewport renders. - expect(navigateWorktree).toContain('getCyclableWorktreeRows(rows, pinnedDisplayPolicy)') - expect(navigateWorktree).toContain('getCyclableRowIdentity') - // Why: the active host is stored resolved while a local row is unqualified; comparing raw identities wraps to the top. - expect(navigateWorktree).toContain('resolveActiveCycleIdentity') - expect(navigateWorktree).not.toContain('composeWorktreeHostIdentity') - expect(navigateWorktree).toContain('executionHostId: nextWorktree.hostId') - expect(navigateWorktree).toContain('resolveCycledWorktreeId') - expect(navigateWorktree).not.toContain('buildRows(') - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts index d95c204026c..fb53b8d016a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts @@ -1,25 +1,11 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { buildRows } from './build-rows' import { ALL_GROUP_META, getPRGroupKey, PINNED_GROUP_KEY } from './group-keys' import { getGroupKeyForWorktree } from './worktree-group-keys' -import { - REPO_HEADER_ACTION_BUTTON_CLASS, - REPO_HEADER_ACTION_REVEAL_CLASS -} from '../../repo-header-action-button-class' import { repo, worktree, repoMap } from '../../worktree-list-groups-test-fixtures' import type { Repo } from '../../../../../../shared/repo-types' import type { Worktree } from '../../../../../../shared/worktree/types' -function readWorktreeListSource(): string { - return readFileSync(fileURLToPath(new URL('../../WorktreeList.tsx', import.meta.url)), 'utf8') -} - -function readSectionHeaderRowSource(): string { - return readFileSync(fileURLToPath(new URL('../rows/SectionHeader.tsx', import.meta.url)), 'utf8') -} - describe('getPRGroupKey', () => { it('puts merged PRs in the done group', () => { const prCache = { @@ -382,40 +368,3 @@ describe('buildRows with pinned worktrees', () => { ]) }) }) - -describe('WorktreeList header styles', () => { - it('does not title-case workspace group labels', () => { - const source = readSectionHeaderRowSource() - - expect(source).not.toContain('leading-none capitalize') - }) - - it('collapses repo header actions without reserving title width', () => { - expect(REPO_HEADER_ACTION_REVEAL_CLASS).toContain('min-w-0 max-w-0 -ml-1.5') - expect(REPO_HEADER_ACTION_REVEAL_CLASS).toContain('focus:ml-0 focus:max-w-5 focus:opacity-100') - expect(REPO_HEADER_ACTION_REVEAL_CLASS).toContain( - 'group-hover:ml-0 group-hover:max-w-5 group-hover:opacity-100' - ) - expect(REPO_HEADER_ACTION_BUTTON_CLASS).toContain( - 'transition-[margin,max-width,opacity,background-color,color]' - ) - expect(REPO_HEADER_ACTION_BUTTON_CLASS).toContain( - 'data-[state=open]:ml-0 data-[state=open]:max-w-5 data-[state=open]:opacity-100' - ) - }) - - it('resolves repo header color from project group headers only', () => { - const source = readSectionHeaderRowSource() - - expect(source).toContain('resolveProjectGroupHeaderColor({') - expect(source).toContain('headerKey: row.key') - expect(source).toContain('color={repoHeaderColor}') - }) - - it('adapts projected setup rows for sidebar project grouping', () => { - const source = readWorktreeListSource() - - expect(source).toContain('const projectHostSetupProjection = useProjectHostSetupProjection()') - expect(source).toContain('projectHostSetups: projectHostSetupProjection.setups') - }) -}) diff --git a/src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts b/src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts deleted file mode 100644 index e213100816e..00000000000 --- a/src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -describe('PetStatusSegment layout', () => { - it('does not reserve fixed right padding on the pet menu trigger', () => { - const source = readFileSync(join(__dirname, 'PetStatusSegment.tsx'), 'utf8') - - // Why: the old pr-[6.5rem] reserved ~104px of empty space after the label and - // shoved neighboring status-bar segments left; unit-guard so CI catches a - // reintroduction without needing Electron Playwright. - expect(source).not.toMatch(/pr-\[\d+(?:\.\d+)?rem\]/) - expect(source).toMatch(/className="group inline-flex items-center cursor-pointer pl-1 py-0\.5"/) - }) -}) diff --git a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts deleted file mode 100644 index ec7c7a62848..00000000000 --- a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' - -const CONTROLLER_PATH = resolve(__dirname, 'use-resource-usage-status-controller.ts') -const DERIVED_MODEL_PATH = resolve(__dirname, 'use-resource-usage-derived-model.ts') - -/** - * Both destructive paths — bulk "kill orphans" and a single row's kill — must classify from the - * same binding inputs. When the merge call site re-listed the fields instead of reusing the - * object, it silently omitted deferred SSH sessions: bulk cleanup spared them while the row's - * kill destroyed them with no confirmation. Same bug, one click over (#8459). - */ -describe('resource session classification parity', () => { - it('feeds the row merge the same binding inputs as the bulk selector', () => { - const source = readFileSync(DERIVED_MODEL_PATH, 'utf8') - const mergeCall = source.slice( - source.indexOf('mergeSnapshotAndSessions(resourceSnapshot'), - source.indexOf('ambiguousWorktreeIds\n })') - ) - - expect(mergeCall).toContain('...resourceSessionBindings') - // Any of these appearing inline means the call site is re-deriving bindings and can drift. - for (const field of [ - 'ptyIdsByTabId,', - 'tabsByWorktree,', - 'terminalLayoutsByTabId,', - 'deferredSshSessionIdsByTabId,', - 'workspaceSessionReady,' - ]) { - expect(mergeCall).not.toContain(field) - } - }) - - it('keeps every binding source in the one object both paths read', () => { - const source = readFileSync(CONTROLLER_PATH, 'utf8') - const bindings = source.slice( - source.indexOf('const resourceSessionBindings = useMemo'), - source.indexOf('const popoverBodyRef') - ) - - for (const field of [ - 'ptyIdsByTabId', - 'tabsByWorktree', - 'terminalLayoutsByTabId', - 'deferredSshSessionIdsByTabId', - 'workspaceSessionReady' - ]) { - expect(bindings).toContain(field) - } - }) -}) diff --git a/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts b/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts deleted file mode 100644 index 3bf7a94c1dd..00000000000 --- a/src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname -const PRODUCTION_MODULES = [ - 'WorkspaceSpaceManagerPanel.tsx', - 'use-workspace-space-decision-projection.ts', - 'use-workspace-space-git-refresh-action.ts', - 'use-workspace-space-manager-bindings.ts', - 'use-workspace-space-manager-panel.ts', - 'use-workspace-space-manager-projection.ts', - 'workspace-space-breakdown-list.tsx', - 'workspace-space-decision-details.ts', - 'workspace-space-decision-hover-card.tsx', - 'workspace-space-manager-overview.tsx', - 'workspace-space-manager-state-types.ts', - 'workspace-space-manager-table.tsx', - 'workspace-space-manager-toolbar.tsx', - 'workspace-space-manager-view.tsx', - 'workspace-space-metrics.tsx', - 'workspace-space-selection-controls.tsx', - 'workspace-space-status-badge.tsx', - 'workspace-space-treemap.tsx', - 'workspace-space-worktree-row.tsx' -] as const - -function source(relativePath: string): string { - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8').replace(/\r\n?/g, '\n') -} - -describe('workspace space manager source boundaries', () => { - it('keeps the public export path as a controller/view facade', () => { - const facade = source('WorkspaceSpaceManagerPanel.tsx') - - expect(facade).toContain('useWorkspaceSpaceManagerPanel()') - expect(facade).toContain('') - expect(facade).toContain('export { getWorkspaceDecisionDetails }') - expect(facade).not.toContain('max-lines') - }) - - it('keeps every production module below its physical line limit', () => { - for (const moduleName of PRODUCTION_MODULES) { - const moduleSource = source(moduleName) - const limit = moduleName.endsWith('.tsx') ? 400 : 300 - - expect(moduleSource.split('\n').length - 1, moduleName).toBeLessThan(limit) - expect(moduleSource, moduleName).not.toContain('disable max-lines') - } - }) - - it('preserves bounded git refreshes and source-owned runtime routing', () => { - const action = source('use-workspace-space-git-refresh-action.ts') - const projection = source('use-workspace-space-manager-projection.ts') - - expect(action).toContain('const currentState = useAppStore.getState()') - expect(action).toContain('connectionId:') - expect(action).toContain('getRuntimeGitStatus({') - expect(action).toContain('getWorkspaceSpaceWorktreeIdentity(worktree)') - expect(action).toContain('inFlightGitStatusRefreshes.current.delete(requestKey)') - expect(projection).toContain('const GIT_STATUS_REFRESH_CONCURRENCY = 6') - expect(projection).toContain('await refreshWorkspaceGitStatus(worktree)') - expect(projection).toContain('void Promise.all(') - expect(projection).toContain('cancelled = true') - }) - - it('preserves normal, force, and open-workspace action routing', () => { - const controller = source('use-workspace-space-manager-panel.ts') - const table = source('workspace-space-manager-table.tsx') - - expect(controller).toContain('runWorktreeBatchDelete(identities, {') - expect(controller).toContain('forceConfirm: true') - expect(controller).toContain('prepareActiveWorktreeFocusAfterDelete(worktree.worktreeId)') - expect(controller).toContain( - 'removeWorktree(\n { id: worktree.worktreeId, executionHostId: worktree.executionHostId ?? null },\n true,\n { allowUnverifiedPtyStop: true }\n )' - ) - expect(table).toContain('activateAndRevealWorktree(worktree.worktreeId, {') - }) -}) diff --git a/src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts b/src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts index 2b3dfadf473..a2b30515f1d 100644 --- a/src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts +++ b/src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts @@ -1,28 +1,11 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { resolveClientHostedBrowserRowStripGroupId } from './client-hosted-browser-row-strip-placement' -function itemSurfaceSource(): string { - return readFileSync(join(__dirname, 'tab-bar-item-surface.tsx'), 'utf8') -} - describe('resolveClientHostedBrowserRowStripGroupId', () => { - it('names exactly one owning strip when the worktree is split', () => { - const groups = [{ id: 'group-left' }, { id: 'group-right' }, { id: 'group-bottom' }] - - const owner = resolveClientHostedBrowserRowStripGroupId(groups) - - expect(groups.filter((group) => group.id === owner)).toHaveLength(1) - }) - // Why: picking the focused group instead would make a row hop strips as the user clicks around. it('keeps the same owner as focus moves', () => { const groups = [{ id: 'group-left' }, { id: 'group-right' }] - expect(resolveClientHostedBrowserRowStripGroupId(groups)).toBe( - resolveClientHostedBrowserRowStripGroupId([...groups]) - ) expect(resolveClientHostedBrowserRowStripGroupId(groups)).toBe('group-left') }) @@ -30,28 +13,3 @@ describe('resolveClientHostedBrowserRowStripGroupId', () => { expect(resolveClientHostedBrowserRowStripGroupId([])).toBeNull() }) }) - -/** - * A client-hosted placeholder is retired by activating anything else in its strip. That only holds - * while every row kind's activation goes through the one wrapper — a new kind wired straight to - * its `onActivate*` prop would leave the placeholder covering the pane it just switched to. - */ -describe('tab-bar activation census', () => { - it('routes every row kind activation through the placeholder-clearing wrapper', () => { - const source = itemSurfaceSource() - const activations = source.match(/onActivate=\{[^}]*\}/g) ?? [] - - expect(activations.length).toBeGreaterThan(0) - for (const activation of activations) { - expect(activation, 'a row kind activates without retiring the placeholder').toContain( - 'activateRealTab(' - ) - } - }) - - it('clears the placeholder before handing off to the real activation', () => { - expect(itemSurfaceSource()).toMatch( - /clearClientHostedBrowserRowSelection\(\)\s*\n\s*activate\?\.\(arg\)/ - ) - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx index 2cdff8b1931..a3f85044626 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx @@ -1,7 +1,5 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import type React from 'react' import { afterEach, describe, expect, it } from 'vitest' import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' @@ -166,21 +164,3 @@ describe('client-hosted row while a real tab is activated', () => { } ) }) - -/** - * The fixtures above cannot cover a row kind that does not exist yet, and a new one wired straight - * to its own active-tab selector is exactly how this regressed the first time. - */ -describe('tab-bar active-state census', () => { - it('suppresses every row kind while a client-hosted row owns the strip', () => { - const source = readFileSync(join(__dirname, 'tab-bar-item-surface.tsx'), 'utf8') - const decisions = source.match(/isActive=\{[^}]*\}/g) ?? [] - - expect(decisions.length).toBeGreaterThan(0) - for (const decision of decisions) { - expect(decision, 'a row kind underlines itself past a client-hosted row').toContain( - '!clientHostedRowOwnsActiveState' - ) - } - }) -}) diff --git a/src/renderer/src/components/task-drawer-source-boundary.test.ts b/src/renderer/src/components/task-drawer-source-boundary.test.ts deleted file mode 100644 index 1a2f64504e7..00000000000 --- a/src/renderer/src/components/task-drawer-source-boundary.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const COMPONENT_ROOT = __dirname - -function componentSource(relativePath: string): string { - return readFileSync(join(COMPONENT_ROOT, relativePath), 'utf8').replace(/\r\n?/g, '\n') -} - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('task drawer source boundaries', () => { - it('threads GitHub task source context through detail mutations', () => { - const source = [ - componentSource('github-item-dialog/edit-item-fields/gh-edit-section.tsx'), - componentSource('github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts') - ].join('\n') - const issueUpdate = sourceBetween( - componentSource('github/github-work-item-edit-mutations.ts'), - 'async function runIssueUpdate', - 'async function runWorkItemBodyUpdate' - ) - const commentUpdate = sourceBetween( - componentSource('github/github-work-item-comment-mutations.ts'), - 'function addIssueCommentForRepo', - 'function addPRReviewCommentForRepo' - ) - const editSection = source - - expect(issueUpdate).toContain('sourceContext: args.sourceContext') - expect(commentUpdate).toContain('sourceContext: args.sourceContext') - expect(editSection).toContain('sourceContext,') - expect(editSection).toContain( - 'patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext })' - ) - expect(editSection).toContain( - 'patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext })' - ) - }) - - it('threads GitLab task source context through the shared drawer selector', () => { - const controllerSource = componentSource('GitLabItemDialog.tsx') - const source = [ - controllerSource, - componentSource('gitlab-item-dialog/use-gitlab-item-dialog-effects.ts'), - componentSource('gitlab-item-dialog/use-gitlab-details-editing.ts'), - componentSource('gitlab-item-dialog/use-gitlab-primary-actions.ts'), - componentSource('gitlab-item-dialog/use-gitlab-review-actions.ts') - ].join('\n') - const selector = sourceBetween( - controllerSource, - 'const repoSelector = useMemo', - 'const updateCommentDraft' - ) - - expect(selector).toContain('...(repoId ? { repoId } : {})') - expect(selector).toContain('...(sourceContext ? { sourceContext } : {})') - expect(selector).toContain('}, [repoId, repoPath, sourceContext])') - expect(source).toContain('workItemDetails({ ...repoSelector') - expect(source).toContain('updateMR({ ...repoSelector') - expect(source).toContain('addMRComment({') - expect(source).toContain('addIssueComment({') - expect(source).toContain('...repoSelector') - }) - - it('uses Linear task source context for drawer reads, mutations, and optimistic patches', () => { - const drawerSource = componentSource('LinearItemDrawer.tsx') - const editSection = componentSource('linear-item-drawer-edit-controller.tsx') - const drawer = sourceBetween( - drawerSource, - 'export default function LinearItemDrawer', - 'return renderLinearItemDrawerSheet' - ) - - expect(editSection).toContain('const providerSettings = sourceContext ?? settings') - expect(editSection).toContain('linearUpdateIssue(providerSettings') - expect(editSection).toContain( - 'patchLinearIssue(issue.id, { state: stateValue }, { sourceContext })' - ) - expect(editSection).toContain( - 'patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext })' - ) - expect(drawer).toContain('const providerSettings = sourceContext ?? settings') - expect(drawer).toContain('linearGetIssue(providerSettings') - expect(drawer).toContain('linearIssueComments(providerSettings') - }) - - it('uses Jira task source context for drawer reads, mutations, and optimistic patches', () => { - const source = componentSource('JiraIssueWorkspace.tsx') - const drawer = sourceBetween(source, 'export default function JiraIssueWorkspace', 'return (') - - expect(drawer).toContain('const providerSettings = sourceContext ?? settings') - expect(drawer).toContain('jiraIssueComments(providerSettings') - expect(drawer).toContain('jiraGetIssue(providerSettings') - expect(drawer).toContain('jiraListTransitions(providerSettings') - expect(drawer).toContain('jiraUpdateIssue(providerSettings') - expect(drawer).toContain('jiraAddIssueComment(') - expect(drawer).toContain('patchJiraIssue(displayed.key, optimistic, { sourceContext })') - expect(drawer).toContain('patchJiraIssue(previous.key, previous, { sourceContext })') - }) -}) diff --git a/src/renderer/src/components/task-page-github-issue-creation.test.ts b/src/renderer/src/components/task-page-github-issue-creation.test.ts deleted file mode 100644 index bacc3988bb8..00000000000 --- a/src/renderer/src/components/task-page-github-issue-creation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readTaskPageSource } from './task-page-source-family.test-support' -import { readFileSync } from 'node:fs' - -const taskPageSource = readTaskPageSource('use-task-page-github-issue-creation.ts') -const newIssueDraftSource = readFileSync( - new URL('./use-task-page-github-issue-draft.ts', import.meta.url), - 'utf8' -) -const newIssueRepoResetSource = readFileSync( - new URL('./task-page-new-issue-draft.ts', import.meta.url), - 'utf8' -) - -function issueCreationSection(): string { - const start = taskPageSource.indexOf('const handleCreateNewIssue') - expect(start).toBeGreaterThanOrEqual(0) - const end = taskPageSource.indexOf('const nextModel', start) - expect(end).toBeGreaterThan(start) - return taskPageSource.slice(start, end) -} - -describe('TaskPage GitHub issue creation', () => { - it('keeps issue creation targeted to the first selected repo on a fresh mount', () => { - expect(newIssueDraftSource).toContain( - 'selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null' - ) - // Why: a repo leaving the selection mid-draft must reset to the first selected repo. - expect(newIssueRepoResetSource).toContain('newIssueRepoId === null') - expect(newIssueRepoResetSource).toContain('return { repoId: selectedRepoIds[0] ?? null }') - }) - - it('covers the complete remote oversized-body recovery timeout envelope', () => { - const section = issueCreationSection() - - expect(section).toContain("'github.createIssue'") - expect(section).toMatch(/\{\s*timeoutMs: 65_000\s*\}/) - }) - - it('treats a body-save warning as created while preserving the recovery draft', () => { - const section = issueCreationSection() - const warningBranch = section.slice( - section.indexOf('if (result.bodySaveWarning)'), - section.indexOf('// Why: bump the nonce') - ) - - expect(warningBranch).toContain('toast.warning') - expect(warningBranch).toContain('description: result.bodySaveWarning') - expect(warningBranch).toMatch(/setNewIssueDraft\(\{\s*title: ''\s*\}\)/) - expect(warningBranch).toContain('} else {') - expect(warningBranch).toContain('clearNewIssueDraft()') - }) -}) diff --git a/src/renderer/src/components/task-page-source-family.test-support.ts b/src/renderer/src/components/task-page-source-family.test-support.ts deleted file mode 100644 index 14dd9a76d2e..00000000000 --- a/src/renderer/src/components/task-page-source-family.test-support.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' - -const TASK_PAGE_FLAT_SOURCE_PATTERN = /^(?:use-task-page-.*\.ts|task-page-.*\.tsx?)$/ -const TASK_PAGE_DIRECTORY = 'task-page' -function isSourceFile(name: string): boolean { - return !name.includes('.test.') && !name.includes('.test-support.') -} - -// Why: the components/task-page tree nests by provider, so a flat readdir would silently -// return an empty family and turn every ratchet built on it into a no-op. -function readTaskPageDirectory(relativeDirectory: string): string[] { - return readdirSync(join(__dirname, relativeDirectory), { withFileTypes: true }).flatMap( - (entry) => { - const relativePath = `${relativeDirectory}/${entry.name}` - if (entry.isDirectory()) { - return readTaskPageDirectory(relativePath) - } - return /\.tsx?$/.test(entry.name) && isSourceFile(entry.name) ? [relativePath] : [] - } - ) -} - -export const TASK_PAGE_SOURCE_FILES = [ - ...readdirSync(__dirname).filter( - (name) => TASK_PAGE_FLAT_SOURCE_PATTERN.test(name) && isSourceFile(name) - ), - ...readTaskPageDirectory(TASK_PAGE_DIRECTORY) -].sort() - -export function readTaskPageSource(relativePath: string): string { - return readFileSync(join(__dirname, relativePath), 'utf8') -} - -export function readTaskPageSourceFamily(): string { - return TASK_PAGE_SOURCE_FILES.map( - (relativePath) => `// TaskPage source: ${relativePath}\n${readTaskPageSource(relativePath)}` - ).join('\n') -} diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts deleted file mode 100644 index ae3bc60df21..00000000000 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readTaskPageSource } from './task-page-source-family.test-support' - -const CONTENT_SOURCE = readTaskPageSource('task-page/Content.tsx') -const SOURCE_BAR_SOURCE = readTaskPageSource('task-page/SourceBar.tsx') -const SOURCE_CONTEXT_SOURCE = readTaskPageSource('task-page-source-context.tsx') -const RUNTIME_HOSTS_SOURCE = readTaskPageSource('use-task-page-runtime-hosts.ts') -const GITHUB_DETAIL_SOURCE = readTaskPageSource('use-task-page-github-detail.ts') -const WORKSPACE_ACTIONS_SOURCE = readTaskPageSource('use-task-page-workspace-actions.ts') - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('TaskPage source switching host boundary', () => { - it('renders GitHub item details from the task-detail page owner only', () => { - const detailSection = sourceBetween( - CONTENT_SOURCE, - '') - expect(detailSection).toContain('workItem={dialogWorkItem}') - expect(detailSection).toContain(' { - const section = sourceBetween( - SOURCE_BAR_SOURCE, - '{visibleSourceOptions.map((source) => {', - "{taskSource === 'linear' && linearConnected ?" - ) - - expect(section).toContain('openTaskPage(') - expect(section).toContain('taskSource: source.id') - expect(section).toContain('defaultTaskSource: source.id') - expect(section).not.toContain('activeRuntimeEnvironmentId') - expect(section).not.toContain('projectHostSetupId') - expect(section).not.toContain('workspaceRunContext') - }) - - it('treats missing remote task-source capability as source unavailable', () => { - const section = sourceBetween( - SOURCE_CONTEXT_SOURCE, - 'function getTaskSourceHostAvailabilityForHost', - 'function getTaskPageRepoCacheInput' - ) - - expect(section).toContain('TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY') - expect(section).toContain("reason: 'checking-task-source-capability'") - expect(section).toContain("reason: 'missing-task-source-capability'") - }) - - it('checks runtime-owned provider auth on the owning runtime', () => { - const section = sourceBetween( - RUNTIME_HOSTS_SOURCE, - 'const runtimeTaskSourceHostIds = useMemo(() => {', - 'const nextModel' - ) - - expect(section).toContain('TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY') - expect(section).toContain("'preflight.check'") - expect(section).toMatch(/\{\s*kind: 'environment',\s*environmentId: parsed\.environmentId\s*\}/) - expect(RUNTIME_HOSTS_SOURCE).toContain('runtimePreflightStatusByHostId') - }) - - it('preserves exact GitLab project identity when opening or starting from an item', () => { - const sourceContextBuilder = sourceBetween( - SOURCE_CONTEXT_SOURCE, - 'function getTaskPageRepoSourceContext', - 'function getTaskSourceHostAvailabilityForHost' - ) - expect(sourceContextBuilder).toContain('gitlabProjectRef?: GitLabProjectRef | null') - expect(sourceContextBuilder).toContain('buildGitLabProviderIdentity(gitlabProjectRef)') - - const openGitLabDetail = sourceBetween( - GITHUB_DETAIL_SOURCE, - 'const openGitLabDetailPage = useCallback(', - 'const nextModel' - ) - expect(openGitLabDetail).toContain('item.projectRef') - - const startGitLabWorkspace = sourceBetween( - WORKSPACE_ACTIONS_SOURCE, - 'const openComposerForGitLabItem = useCallback(', - 'const handleUseGitLabItem = useCallback(' - ) - expect(startGitLabWorkspace).toContain('item.projectRef') - }) -}) diff --git a/src/renderer/src/components/task-page-task-creation-drafts.test.ts b/src/renderer/src/components/task-page-task-creation-drafts.test.ts deleted file mode 100644 index 969c1b0c373..00000000000 --- a/src/renderer/src/components/task-page-task-creation-drafts.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { - readTaskPageSource, - readTaskPageSourceFamily -} from './task-page-source-family.test-support' - -const taskPageSource = readTaskPageSourceFamily() -const linearCreateDialogsSource = readFileSync( - new URL('./use-task-page-linear-creation-state.ts', import.meta.url), - 'utf8' -) -const jiraCreateDialogSource = readFileSync( - new URL('./use-task-page-jira-creation-state.ts', import.meta.url), - 'utf8' -) -const jiraCreateSubmitSource = readFileSync( - new URL('./use-task-page-jira-issue-creation.ts', import.meta.url), - 'utf8' -) -const draftWriterSource = readFileSync( - new URL('./task-page-draft-storage.tsx', import.meta.url), - 'utf8' -) -const draftRetentionSource = [linearCreateDialogsSource, jiraCreateDialogSource].join('\n') - -function sectionBetween(source: string, startAnchor: string, endAnchor: string): string { - const start = source.indexOf(startAnchor) - expect(start, `missing anchor: ${startAnchor}`).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endAnchor, start) - expect(end, `missing anchor: ${endAnchor}`).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('TaskPage Linear/Jira creation drafts', () => { - it('uses the contentful gate for each session draft writer', () => { - expect(draftWriterSource.split('isTaskCreationDraftContentful(draft)')).toHaveLength(4) - }) - - it('retains all three drafts on dismissal without subscribing TaskPage to draft actions', () => { - expect(draftRetentionSource.split('useTaskCreationDraftRetention({')).toHaveLength(4) - expect(taskPageSource).not.toMatch( - /useAppStore\(\(s\) => s\.(?:set|clear)New(?:LinearProject|LinearIssue|JiraIssue)Draft\)/ - ) - expect(draftRetentionSource).not.toMatch( - /useAppStore\(\(s\) => s\.(?:set|clear)New(?:LinearProject|LinearIssue|JiraIssue)Draft\)/ - ) - }) - - it('restores dismissed typed text when each dialog reopens', () => { - const linearFilters = readFileSync( - new URL('./task-page/linear/Filters.tsx', import.meta.url), - 'utf8' - ) - const jiraFilters = readFileSync( - new URL('./task-page/jira/Filters.tsx', import.meta.url), - 'utf8' - ) - expect(linearFilters).toContain("setNewLinearProjectName(draft?.name ?? '')") - expect(linearFilters).toContain("setNewLinearProjectDescription(draft?.description ?? '')") - expect(linearFilters).toContain("setNewLinearProjectContent(draft?.content ?? '')") - expect(linearFilters).toContain("setNewLinearIssueTitle(issueDraft?.title ?? '')") - expect(linearFilters).toContain("setNewLinearIssueBody(issueDraft?.body ?? '')") - expect(jiraFilters).toContain("setNewJiraIssueTitle(draft?.title ?? '')") - expect(jiraFilters).toContain("setNewJiraIssueBody(draft?.body ?? '')") - }) - - it('discards each recovery draft only on a successful create', () => { - const linearProjectSection = sectionBetween( - readTaskPageSource('use-task-page-linear-project-creation.ts'), - 'const handleCreateNewLinearProject', - 'const nextModel' - ) - expect(linearProjectSection).toContain('discardNewLinearProjectDraft()') - - const linearIssueSection = sectionBetween( - readTaskPageSource('use-task-page-linear-issue-creation.ts'), - 'const handleCreateNewLinearIssue', - 'const nextModel' - ) - expect(linearIssueSection).toContain('discardNewLinearIssueDraft()') - - const jiraIssueSection = sectionBetween( - readTaskPageSource('use-task-page-jira-issue-creation.ts'), - 'const handleCreateNewJiraIssue', - 'const nextModel' - ) - expect(jiraIssueSection).toContain('discardNewJiraIssueDraft()') - }) - - it('surfaces Jira create transport failures without leaking stale-context toasts', () => { - const jiraIssueSection = sectionBetween( - jiraCreateSubmitSource, - 'const handleCreateNewJiraIssue', - 'const nextModel' - ) - expect(jiraIssueSection).toContain('} catch (error) {') - expect(jiraIssueSection).toContain( - 'submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current' - ) - expect(jiraIssueSection).toContain('toast.error(') - }) -}) diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts deleted file mode 100644 index 4eedb629f2a..00000000000 --- a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { - readTaskPageSource, - readTaskPageSourceFamily -} from './task-page-source-family.test-support' - -const TASK_PAGE_SOURCE = readTaskPageSourceFamily() -const WORKSPACE_ACTIONS_SOURCE = readTaskPageSource('use-task-page-workspace-actions.ts') -const COMPOSER_ACTIONS_SOURCE = readTaskPageSource('use-task-page-composer-actions.ts') -const PROJECT_VIEW_SOURCE = readFileSync( - join(__dirname, 'github-project', 'ProjectViewWrapper.tsx'), - 'utf8' -) -const COMPOSER_MODAL_SOURCE = readFileSync(join(__dirname, 'NewWorkspaceComposerModal.tsx'), 'utf8') -const QUICK_SUBMIT_PREPARATION_SOURCE = - readFileSync(join(__dirname, '../hooks/composer-state/quick-submit-preparation.ts'), 'utf8') + - readFileSync(join(__dirname, '../hooks/composer-state/quick-creation-execution.ts'), 'utf8') + - readFileSync(join(__dirname, '../hooks/composer-state/quick-creation-request.ts'), 'utf8') - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('TaskPage workspace creation source boundaries', () => { - it('prefills the workspace composer for GitHub issues and pull requests', () => { - const section = sourceBetween( - WORKSPACE_ACTIONS_SOURCE, - 'const openComposerForItem = useCallback(', - 'const handleUseWorkItem = useCallback(' - ) - - expect(section).toContain("provider: 'github'") - expect(section).toContain('type: item.type') - expect(section).toContain('number: item.number') - expect(section).toContain('title: item.title') - expect(section).toContain('url: item.url') - expect(section).toContain("openModal('new-workspace-composer', {") - expect(section).toContain("getTaskPageRepoSourceContext(repoMap.get(item.repoId), 'github')") - expect(section).toContain('prefilledName: getGitHubWorkItemWorkspaceSeed(item)') - expect(section).toContain('initialRepoId: item.repoId') - expect(section).toContain('initialGitHubWorkItem: item') - expect(section).toContain("enableIssueAutomation: item.type === 'issue'") - expect(section).toContain("telemetrySource: 'sidebar'") - }) - - it('forwards PR start-point data and issue automation through quick submit', () => { - expect(COMPOSER_MODAL_SOURCE).toContain( - 'initialGitHubWorkItem: modalData.initialGitHubWorkItem ?? null' - ) - expect(COMPOSER_MODAL_SOURCE).toContain( - 'enableIssueAutomation: modalData.enableIssueAutomation === true' - ) - const quickSubmit = QUICK_SUBMIT_PREPARATION_SOURCE - expect(quickSubmit).toContain('readAndConfirmRuntimeIssueCommand(') - expect(quickSubmit).toContain('selectedRepoExecutionHostId') - expect(quickSubmit).toContain('isSubmissionCancelled') - expect(quickSubmit).toContain( - '...(input.issueCommand ? { issueCommand: input.issueCommand } : {})' - ) - }) - - it('routes TaskPage GitHub starts directly to the composer', () => { - const section = sourceBetween( - WORKSPACE_ACTIONS_SOURCE, - 'const handleUseWorkItem = useCallback(', - 'const handleOpenOrUseGitHubWorkItem = useCallback(' - ) - - expect(section).toContain("recordFeatureInteraction('github-tasks')") - expect(section).toContain('openComposerForItem(item)') - expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') - expect(WORKSPACE_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') - expect(TASK_PAGE_SOURCE).not.toContain('@/lib/github-work-item-background-create') - }) - - it('routes TaskPage Linear starts directly to the composer', () => { - const composerSection = sourceBetween( - COMPOSER_ACTIONS_SOURCE, - 'const openComposerForLinearItem = useCallback(', - 'const handleUseLinearItem = useCallback(' - ) - const handlerSection = sourceBetween( - COMPOSER_ACTIONS_SOURCE, - 'const handleUseLinearItem = useCallback(', - 'const handleOpenOrUseLinearItem = useCallback(' - ) - - expect(composerSection).toContain('buildLinearIssueLinkedWorkItem(issue)') - expect(composerSection).toContain("openModal('new-workspace-composer', {") - expect(composerSection).toContain('taskSourceContext: linearTaskSourceContext') - expect(composerSection).toContain('prefilledName: getLinearIssueWorkspaceName(issue)') - expect(composerSection).toContain("telemetrySource: 'sidebar'") - expect(handlerSection).toContain("recordFeatureInteraction('linear-tasks')") - expect(handlerSection).toContain('openComposerForLinearItem(issue)') - }) - - it('resumes an attachment from the primary action and composes when none exists', () => { - const section = sourceBetween( - WORKSPACE_ACTIONS_SOURCE, - 'const handleOpenOrUseGitHubWorkItem = useCallback(', - 'const openComposerForGitLabItem = useCallback(' - ) - - expect(section).toContain('findGithubWorkItemWorkspaceAttachment(') - expect(section).toContain('if (!currentAttached)') - expect(section).toContain('handleUseWorkItem(item)') - expect(section).toContain('activateAndRevealWorktree(currentAttached.id, {') - }) - - it('uses the shared composer handler from GitHub detail and start-new actions', () => { - const detail = readFileSync(join(__dirname, 'task-page/Content.tsx'), 'utf8') - const actions = readFileSync(join(__dirname, 'task-page/github/Rows.tsx'), 'utf8') - expect(detail.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2) - expect(actions.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength(2) - }) - - it('keeps project-view GitHub actions on the direct start-work path for issue #4756', () => { - const startAnchor = PROJECT_VIEW_SOURCE.includes('const handleStartWork') - ? 'const handleStartWork = useCallback(' - : '// Why: issue #4756 keeps project-view actions on the direct' - const endAnchor = PROJECT_VIEW_SOURCE.includes('const handleStartWork') - ? 'const closeDialogRepoItem' - : 'openModalFallback: () => {' - const section = sourceBetween(PROJECT_VIEW_SOURCE, startAnchor, endAnchor) - - expect(section).toContain('void launchWorkItemDirect({') - expect(section).toContain("launchSource: 'task_page'") - expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts deleted file mode 100644 index 70a214abfd5..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. -import ts from 'typescript-api' -import { describe, expect, it } from 'vitest' - -const LISTENER_SOURCE_PATTERN = - /^(?:TerminalPane\.tsx|terminal-pane-paste-listeners\.ts|use-terminal-pane-(?:chat-state|close-actions|context-actions|controller|foundation|global-listeners|layout-bindings|layout-persistence|lifecycle-stage|mobile-actions|paste-listeners|projection|reconciliation|startup-actions|store-bindings|title-effects|title-state)\.ts)$/ -const PRE_REFACTOR_LISTENER_ORDER_SHA256 = - '2a2c5caaa368636d761ec819a7858f6b8010e578ccdd5c1be5960f87968c7e8a' - -type FunctionDefinition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } - -function readDefinitions(): Map { - const definitions = new Map() - for (const relativePath of readdirSync(__dirname).filter((name) => - LISTENER_SOURCE_PATTERN.test(name) - )) { - const filePath = join(__dirname, relativePath) - const sourceFile = ts.createSourceFile( - filePath, - readFileSync(filePath, 'utf8'), - ts.ScriptTarget.Latest, - true, - relativePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - ) - const visit = (node: ts.Node): void => { - if ( - ts.isFunctionDeclaration(node) && - node.name && - (node.name.text === 'TerminalPane' || - node.name.text === 'registerTerminalPanePasteListeners' || - node.name.text.startsWith('useTerminalPane')) - ) { - definitions.set(node.name.text, { declaration: node, sourceFile }) - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - } - return definitions -} - -function readFlattenedListeners(): string[] { - const definitions = readDefinitions() - const listeners: string[] = [] - const visitDefinition = (name: string, active: ReadonlySet): void => { - const definition = definitions.get(name) - if (!definition?.declaration.body) { - throw new Error(`Missing terminal pane stage: ${name}`) - } - if (active.has(name)) { - throw new Error(`Recursive terminal pane stage: ${name}`) - } - const nextActive = new Set([...active, name]) - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - if ( - ts.isPropertyAccessExpression(node.expression) && - ['addEventListener', 'removeEventListener'].includes(node.expression.name.text) - ) { - listeners.push( - [ - node.expression.expression.getText(definition.sourceFile), - node.expression.name.text, - ...node.arguments.map((argument) => - argument.getText(definition.sourceFile).replace(/\s+/g, '') - ) - ].join('|') - ) - } - if (ts.isIdentifier(node.expression) && definitions.has(node.expression.text)) { - visitDefinition(node.expression.text, nextActive) - } - } - ts.forEachChild(node, visit) - } - visit(definition.declaration.body) - } - - visitDefinition('TerminalPane', new Set()) - return listeners -} - -describe('TerminalPane refactor listener parity', () => { - it('preserves ordered listener registration and cleanup', () => { - const listeners = readFlattenedListeners() - expect(listeners).toHaveLength(24) - expect(createHash('sha256').update(listeners.join('\n')).digest('hex')).toBe( - PRE_REFACTOR_LISTENER_ORDER_SHA256 - ) - }) -}) diff --git a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts index b2cdcb5b2ad..82f686f851f 100644 --- a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts +++ b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts @@ -8,8 +8,6 @@ * Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> probePtyRunningWork * (split panes) or closeTerminalTab's guard (last pane) -> CloseTerminalDialog. */ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -92,21 +90,6 @@ describe('#10142 close confirmation policy is the same for keyboard and mouse', }) }) - // Control: the keyboard entry point does probe for running children. - it('keyboard Cmd+W path probes for running child processes before closing', () => { - const source = readFileSync( - join(__dirname, '../terminal-pane/use-terminal-pane-close-actions.ts'), - 'utf8' - ) - const handler = source.slice(source.indexOf('const handleRequestClosePane')) - // The shared probe, not a direct inspect: the pane path asks the same question as the tab - // guard, and routing both through one measurement is what stops them drifting on what an - // unanswered host means. - expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain( - 'probePtyRunningWork' - ) - }) - // Control: the harness does observe a guard when one exists — pinning blocks the same mouse close. it('mouse close routes a pinned tab through its confirmation guard', () => { const state = stateWithBusyTerminalTab(closeTab) @@ -121,13 +104,6 @@ describe('#10142 close confirmation policy is the same for keyboard and mouse', expect(closeTab).not.toHaveBeenCalled() }) - it('mouse close (X button / middle-click) consults the running-process probe', async () => { - closeTerminalTab('tab-busy') - await Promise.resolve() - - expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalled() - }) - it('mouse close (X button / middle-click) does not drop a busy tab without confirmation', async () => { closeTerminalTab('tab-busy') await Promise.resolve() diff --git a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx index b1f6e087617..c1763411586 100644 --- a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx +++ b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx @@ -1,6 +1,4 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { @@ -195,21 +193,6 @@ describe('useTerminalProviderSnapshotCapability', () => { hook.unmount() }) - // Why source-pinned: the hook test cannot prove the quiet parking effect consumes the external revision. - it('invalidates the Terminal parking pass when a capability verdict changes', () => { - const projectionSource = readFileSync( - join(__dirname, '../use-terminal-workspace-projection.ts'), - 'utf8' - ) - const parkingSource = readFileSync(join(__dirname, '../use-terminal-parking-pass.ts'), 'utf8') - - expect(projectionSource).toContain( - 'const terminalProviderSnapshotCapabilityRevision = useTerminalProviderSnapshotCapability(' - ) - expect(projectionSource).toContain('terminalProviderSnapshotCapabilityRevision') - expect(parkingSource.match(/terminalProviderSnapshotCapabilityRevision/g)).toHaveLength(2) - }) - it('cancels an unknown-capability retry when the hook unmounts', async () => { vi.useFakeTimers() resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: null }]) diff --git a/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts index 73ce01b2951..56fe90c0eb2 100644 --- a/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest' import { isSleepingSweepExemptWorkspace } from './sidebar/visible-worktrees' import type { Worktree } from '../../../shared/worktree/types' -import { readWorktreeJumpPaletteSource } from './worktree-jump-palette-source.test-support' - -const worktreeSource = readWorktreeJumpPaletteSource('use-worktree-jump-palette-worktrees.ts') -const storeSource = readWorktreeJumpPaletteSource('use-worktree-jump-palette-store-state.ts') function makeWorktree(overrides: Partial = {}): Worktree { return { @@ -30,28 +26,6 @@ function makeWorktree(overrides: Partial = {}): Worktree { } describe('Cmd+J empty-query "Hide sleeping" pass (#8873)', () => { - // Why source-level: the palette re-implements the sidebar's filter pass - // inline, so the only structural guarantee that the two agree is that both - // call the shared predicate. A behavioral copy here would not catch a - // hand-rolled duplicate creeping back in. - it('routes the sleeping sweep through the shared exemption predicate', () => { - const start = worktreeSource.indexOf('const emptyQueryVisibleWorktrees = useMemo(') - expect(start).toBeGreaterThanOrEqual(0) - const end = worktreeSource.indexOf('const { visibleWorktreesForState', start) - const filterPass = worktreeSource.slice(start, end) - - expect(filterPass).toContain( - '!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace)' - ) - expect(filterPass).toContain('alwaysShowDefaultBranchWorkspace,') - }) - - it('reads the flag from the same store field the sidebar uses', () => { - expect(storeSource).toMatch( - /const alwaysShowDefaultBranchWorkspace = useAppStore\(\s*\(state\) => state\.alwaysShowDefaultBranchWorkspace\s*\)/ - ) - }) - it('exempts a project entry point by default and honours an explicit opt-out', () => { const main = makeWorktree() diff --git a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts deleted file mode 100644 index 0815cc7092f..00000000000 --- a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readWorktreeJumpPaletteSource } from './worktree-jump-palette-source.test-support' - -const source = [ - readWorktreeJumpPaletteSource('use-worktree-jump-palette-create-action.ts'), - readWorktreeJumpPaletteSource('worktree-jump-palette-create-worktree.ts') -].join('\n') - -function sourceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('WorktreeJumpPalette source-context boundaries', () => { - it('defers pasted GitHub URL resolution to the composer so cross-project detection runs', () => { - // Why: pasting a cross-project URL must surface the same "Switch project?" - // prompt as Cmd+N. The palette hands the raw URL to the composer's name - // field instead of pre-resolving it against an arbitrary repo, which - // silently linked cross-project items to the wrong project. - const githubLinkSection = sourceBetween('if (ghLink) {', 'if (ghNumber !== null) {') - expect(githubLinkSection).toContain('prefilledName: trimmed') - expect(githubLinkSection).not.toContain('lookupGitHubWorkItemByOwnerRepoForSource') - }) - - it('resolves typed raw issue/PR numbers through the lookup repo source host', () => { - expect(source).toContain('buildTaskSourceContextFromRepo') - - const rawNumberSection = sourceBetween( - 'void lookupGitHubWorkItemForSource({', - '.then((item) => {' - ) - expect(rawNumberSection).toContain('sourceContext') - }) -}) diff --git a/src/renderer/src/components/worktree-jump-palette-source.test-support.ts b/src/renderer/src/components/worktree-jump-palette-source.test-support.ts deleted file mode 100644 index 6cb92e66de6..00000000000 --- a/src/renderer/src/components/worktree-jump-palette-source.test-support.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' - -export function readWorktreeJumpPaletteSource(relativePath: string): string { - return readFileSync(join(__dirname, relativePath), 'utf8') -} diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts index 57964af2e99..5fbdd00ffed 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts @@ -156,19 +156,6 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['live']) }) - it('leaves a tab alone when no tombstone names it', () => { - // The pre-existing absence-is-not-closed trade, restated: a host that still lists a tab closed - // on some other client keeps it here, because absence is all this merge would have to go on. - const agent = terminalTab('agent') - const closedElsewhere = terminalTab('closed-elsewhere') - const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, closedElsewhere] } }) - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - - const merged = merge(current, remote, { [WORKTREE]: [agent, closedElsewhere] }) - - expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toContain('closed-elsewhere') - }) - it('neither retires nor writes back a record once the host stops listing the tab', () => { // Main alone writes records; an acknowledgement used to delete them here, which made a workspace // the user emptied read as never initialized as soon as the host caught up. diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 764a033275f..9d76c9f9a23 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -1,8 +1,5 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { - canResolveFolderSmartGitHubSubmit, getInitialAutoManagedWorkspaceName, getInitialGitHubPrStartPointSelection, getMatchingLinkedTaskSourceContext, @@ -11,54 +8,6 @@ import { resolveInitialWorkspaceRunSeed } from './useComposerState' -function readComposerModule(fileName: string): string { - return readFileSync(join(__dirname, 'composer-state', fileName), 'utf8') -} - -const COMPOSER_SOURCE = { - cardProps: readComposerModule('composer-card-props.ts'), - derived: readComposerModule('derived-composer-state.ts'), - folderSubmit: readComposerModule('folder-submit-orchestration.ts'), - fullCreation: readComposerModule('full-creation-execution.ts'), - fullCreationStartup: readComposerModule('full-creation-startup.ts'), - fullSubmitOrchestration: readComposerModule('full-submit-orchestration.ts'), - fullSubmitPreparation: readComposerModule('full-submit-preparation.ts'), - fullSubmitSourcePreparation: readComposerModule('full-submit-source-preparation.ts'), - githubProvider: readComposerModule('github-provider-selection.ts'), - githubSourceApplication: readComposerModule('github-source-application.ts'), - githubSubmit: readComposerModule('github-submit-resolution.ts'), - gitlabProvider: readComposerModule('gitlab-provider-selection.ts'), - hostRuntime: readComposerModule('host-runtime-effects.ts'), - initialTarget: readComposerModule('initial-target-state.ts'), - issueSource: readComposerModule('issue-source-actions.ts'), - linkedItemLookup: readComposerModule('linked-item-lookup-effects.ts'), - navigation: readComposerModule('composer-navigation-actions.ts'), - projectTarget: readComposerModule('project-target-actions.ts'), - providerRuntime: readComposerModule('provider-runtime-sync.ts'), - quickCreation: readComposerModule('quick-creation-execution.ts'), - quickSubmitAction: readComposerModule('quick-submit-action.ts'), - quickSubmitPreparation: readComposerModule('quick-submit-preparation.ts'), - quickSubmitSourcePreparation: readComposerModule('quick-submit-source-preparation.ts'), - runtimeTarget: readComposerModule('runtime-target-selection.ts'), - sourceContext: readComposerModule('source-context-state.ts'), - sourceIdentity: readComposerModule('source-identity-actions.ts'), - targetChange: readComposerModule('target-change-actions.ts'), - quickStartup: readComposerModule('quick-startup-plan.ts'), - workspaceIdentity: readComposerModule('workspace-identity-state.ts') -} as const -const RECIPE_OPTIONS_SOURCE = readFileSync( - join(__dirname, 'useEphemeralVmRecipeOptions.ts'), - 'utf8' -) - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - describe('useComposerState host-context boundaries', () => { it('seeds TaskPage pull requests as submit-time PR start points', () => { const item = { @@ -156,14 +105,6 @@ describe('useComposerState host-context boundaries', () => { ).toEqual({ workspaceName: 'title-derived-name', displayName: 'Title derived name' }) }) - it('requires pasted PR recovery to match the selected GitHub host', () => { - const recoverySection = COMPOSER_SOURCE.derived - - expect(recoverySection).toContain('githubRepoIdentityKey(fromName.slug)') - expect(recoverySection).toContain('githubRepoIdentityKey(selectedRepoSlug)') - expect(recoverySection).not.toContain('fromName.slug.owner.toLowerCase()') - }) - it('auto-owns linked-item generated prefilled names', () => { expect( getInitialAutoManagedWorkspaceName({ @@ -179,112 +120,6 @@ describe('useComposerState host-context boundaries', () => { ).toBe('fix-workspace-name') }) - it('resolves GitHub PR bases against the selected run repo, not the source item repo', () => { - const section = COMPOSER_SOURCE.githubProvider - - expect(section).toContain('const runRepo = selectedRepo ??') - expect(section).toContain('resolveGitHubPrStartPointForRepo') - expect(section).toContain('repoId: runRepo.id') - expect(section).toContain('settings: itemRepoSettings') - expect(section).toContain('smartGitHubPrStartPointSelectionRef.current = startPointSelection') - expect(section).toContain( - 'if (smartGitHubPrStartPointSelectionRef.current !== startPointSelection)' - ) - expect(section).not.toContain('repoId: repoForItem.id') - expect(section).not.toContain('repo: repoForItem.id') - }) - - it('resolves GitLab MR bases against the selected run repo, not the source item repo', () => { - const section = COMPOSER_SOURCE.gitlabProvider - - expect(section).toContain('const runRepo = selectedRepo ??') - expect(section).toContain('repoId: runRepo.id') - expect(section).toContain('getSettingsForRepoRuntimeOwner') - expect(section).toContain('worktree.resolveMrBase') - expect(section).toContain('repo: runRepo.id') - expect(section).not.toContain('repoId: repoForItem.id') - // Why (#6263): an unresolved MR base must surface a toast and clear stale - // state instead of silently dropping the worktree onto origin/master. - expect(section).toContain('toast.error(result.error)') - expect(section).toContain("'Failed to resolve MR base.'") - expect(section).toMatch(/\.catch\(\(error: unknown\) =>/) - }) - - it('clears only repo-scoped linked work items when the repo or project changes', () => { - // Why: Linear and Jira issues are workspace-scoped context — a repo or - // project switch must keep them attached. Jira used to be dropped because - // this path special-cased Linear only. - const repoChangeSection = COMPOSER_SOURCE.targetChange - expect(repoChangeSection).toContain( - '!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)' - ) - - const folderSourceSection = COMPOSER_SOURCE.targetChange - expect(folderSourceSection).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(current)') - - // No switch path may gate the linked-item clear on a Linear-only predicate - // again. (isLinearLinkedWorkItem itself may still appear — it drives the - // separate Linear branch-name feature — but never the preservation decision.) - expect(COMPOSER_SOURCE.targetChange).not.toContain('if (!preserveLinearLinkedWorkItem)') - }) - - it('does not use local SSH gates for runtime-owned folder targets', () => { - const targetSection = COMPOSER_SOURCE.runtimeTarget - expect(targetSection).toContain("parsedFolderTargetHost?.kind === 'runtime'") - expect(targetSection).toContain('connectionId: folderTargetConnectionId') - expect(COMPOSER_SOURCE.runtimeTarget).not.toContain('folderSourceConnectionId') - }) - - it('routes folder target runtime ownership through detection, path status, and create', () => { - const targetSection = COMPOSER_SOURCE.runtimeTarget - expect(targetSection).toContain('folderTargetRuntimeEnvironmentId') - expect(targetSection).toContain("{ kind: 'runtime' as const") - expect(targetSection).toContain('useFolderWorkspaceComposerPathStatus(') - expect(targetSection).toContain('folderTargetRuntimeEnvironmentId') - expect(targetSection).toContain('useDetectedAgents(folderTargetAgentDetectionTarget)') - - const submitSection = COMPOSER_SOURCE.folderSubmit - expect(submitSection).toContain('isRemote: folderTargetIsRemote') - expect(submitSection).toContain( - "launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer'" - ) - expect(submitSection).toContain('runtimeEnvironmentId: folderTargetRuntimeEnvironmentId') - }) - - it('detects composer agents against the repo host: SSH, then runtime, then local (#7082)', () => { - // Why: a repo owned by a paired runtime must show the runtime's agents, not - // the local machine's. SSH stays first priority; runtime falls through before - // local so an SSH repo never double-detects. Regression guard for #7082. - const selectorSection = COMPOSER_SOURCE.workspaceIdentity - expect(selectorSection).toContain('if (isRemote) {') - expect(selectorSection).toContain('s.remoteDetectedAgentIds[connectionId]') - expect(selectorSection).toContain('if (runtimeEnvironmentId) {') - expect(selectorSection).toContain('s.runtimeDetectedAgentIds[runtimeEnvironmentId]') - expect(selectorSection).toContain('return s.detectedAgentIds') - // SSH branch is checked before the runtime branch. - expect(selectorSection.indexOf('if (isRemote) {')).toBeLessThan( - selectorSection.indexOf('if (runtimeEnvironmentId) {') - ) - - expect(COMPOSER_SOURCE.workspaceIdentity).toContain( - 'const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null' - ) - - // Detection effect fans out to the same three hosts in the same order and - // re-runs when the runtime environment changes. - const detectSection = sourceBetween( - COMPOSER_SOURCE.hostRuntime, - 'const detect = isRemote', - '// Per-repo: load yaml hooks' - ) - expect(detectSection).toContain('ensureRemoteDetectedAgents(connectionId!)') - expect(detectSection).toContain('ensureRuntimeDetectedAgents(runtimeEnvironmentId)') - expect(detectSection).toContain('ensureDetectedAgents()') - expect(detectSection).toMatch( - /\}, \[\s*connectionId,\s*runtimeEnvironmentId,\s*isRemote,\s*selectedRepoSshStatus,\s*disabledTuiAgents/ - ) - }) - it('seeds initial workspace run target from the task source context', () => { expect( resolveInitialWorkspaceRunSeed({ @@ -316,299 +151,6 @@ describe('useComposerState host-context boundaries', () => { hostId: 'local', projectHostSetupId: 'setup-local' }) - - const section = COMPOSER_SOURCE.initialTarget - - expect(section).toContain('resolveInitialWorkspaceRunSeed') - expect(section).toContain('initialTaskSourceContext') - expect(section).toContain('projectId: initialRunSeed.projectId') - expect(section).toContain('hostId: initialRunSeed.hostId') - expect(section).toContain('projectHostSetupId: initialRunSeed.projectHostSetupId') - }) - - it('resolves typed GitHub issue/PR input through the selected repo source context', () => { - expect(COMPOSER_SOURCE.sourceContext).toContain( - 'const selectedRepoGitHubSourceContext = useMemo' - ) - - const directLookup = COMPOSER_SOURCE.linkedItemLookup - expect(directLookup).toContain('sourceContext: selectedRepoGitHubSourceContext') - expect(directLookup).toContain('lookupGitHubWorkItemByOwnerRepoForSource') - expect(directLookup).toContain('type: normalizedLinkQuery.directLink.type') - - const submitLookup = COMPOSER_SOURCE.githubSubmit - expect(submitLookup).toContain('sourceContext:') - expect(submitLookup).toContain('selectedRepoGitHubSourceContext') - }) - - it('uses submit-time GitHub PR start points for the create payload', () => { - const submitLookup = COMPOSER_SOURCE.githubSubmit - expect(submitLookup).toContain('resolveGitHubPrStartPointForRepo') - expect(submitLookup).toContain("kind: 'pr-start-point'") - expect(submitLookup).toContain("kind: 'metadata-only'") - expect(submitLookup).toContain('baseBranch: prStartPoint.baseBranch') - expect(submitLookup).toContain('branchNameOverride: prStartPoint.branchNameOverride') - const selectedPrSubmitLookup = sourceBetween( - submitLookup, - 'if (linkedWorkItem) {', - 'const intent = getSmartGitHubSubmitIntent(name)' - ) - expect(selectedPrSubmitLookup).toContain('smartGitHubPrStartPointSelectionRef.current') - expect(selectedPrSubmitLookup).toContain("linkedWorkItemIdentity?.type === 'pr'") - expect(selectedPrSubmitLookup).toContain("startPointIdentity?.type === 'pr'") - expect(selectedPrSubmitLookup).toContain( - 'startPointIdentity.number === linkedWorkItemIdentity.number' - ) - expect(selectedPrSubmitLookup).toContain('resolveGitHubPrStartPointForRepo') - expect(selectedPrSubmitLookup.indexOf('resolveGitHubPrStartPointForRepo')).toBeLessThan( - selectedPrSubmitLookup.indexOf("return { kind: 'none' }") - ) - - const fullSubmit = COMPOSER_SOURCE.fullSubmitSourcePreparation + COMPOSER_SOURCE.fullCreation - expect(fullSubmit).toContain("smartGitHubResolution.kind === 'pr-start-point'") - expect(fullSubmit).toContain("smartGitHubResolution.kind === 'metadata-only'") - expect(fullSubmit).toContain('effectiveLinkedPR !== null || linkedGitLabMR !== null') - expect(fullSubmit).toContain('selectedRepoIsGit ? submitBaseBranch : undefined') - expect(fullSubmit).toContain('submitPushTarget') - expect(fullSubmit).toContain('submitCompareBaseRef') - expect(fullSubmit).not.toContain('smartGitHubResolution?.baseBranch ?? baseBranch') - expect(fullSubmit).not.toContain('smartGitHubResolution?.compareBaseRef ?? compareBaseRef') - expect(fullSubmit).not.toContain('smartGitHubResolution?.pushTarget ?? pushTarget') - expect(fullSubmit).not.toContain( - 'smartGitHubResolution?.branchNameOverride ?? branchNameOverride' - ) - - const quickSubmit = - COMPOSER_SOURCE.quickSubmitSourcePreparation + - COMPOSER_SOURCE.quickSubmitPreparation + - COMPOSER_SOURCE.quickCreation - expect(quickSubmit).toContain("smartGitHubResolution.kind === 'pr-start-point'") - expect(quickSubmit).toContain("smartGitHubResolution.kind === 'metadata-only'") - expect(quickSubmit).toContain('effectiveLinkedPR !== null || linkedGitLabMR !== null') - expect(quickSubmit).toContain('explicitBaseBranch: smartSubmitBaseBranch') - expect(quickSubmit).toContain('pushTarget: submitPushTarget') - expect(quickSubmit).toContain('compareBaseRef: submitCompareBaseRef') - expect(quickSubmit).not.toContain('smartGitHubResolution?.baseBranch ?? baseBranch') - expect(quickSubmit).not.toContain('smartGitHubResolution?.compareBaseRef ?? compareBaseRef') - expect(quickSubmit).not.toContain('smartGitHubResolution?.pushTarget ?? pushTarget') - expect(quickSubmit).not.toContain( - 'smartGitHubResolution?.branchNameOverride ?? branchNameOverride' - ) - }) - - it('saves setup startup policy before creating a workspace', () => { - const persistSection = COMPOSER_SOURCE.providerRuntime - expect(persistSection).toContain('setupAgentStartupPolicySaveRef.current') - expect(persistSection).toContain('pendingSave?.repoId === currentRepo.id') - expect(persistSection).toContain('pendingSave.policy === policy') - expect(persistSection).toContain('await pendingSave.promise') - expect(persistSection).toContain('continue') - expect(COMPOSER_SOURCE.providerRuntime).toContain('setupAgentStartupPolicyDraftRef.current') - - const fullSubmit = COMPOSER_SOURCE.fullCreation - const fullPolicySave = fullSubmit.indexOf('persistSetupAgentStartupPolicy()') - const fullCreate = fullSubmit.indexOf('const result = await createWorktree(') - expect(fullPolicySave).toBeGreaterThanOrEqual(0) - expect(fullCreate).toBeGreaterThan(fullPolicySave) - - const quickSubmit = COMPOSER_SOURCE.quickCreation - const quickPolicySave = quickSubmit.indexOf('persistSetupAgentStartupPolicy()') - const quickCreate = quickSubmit.indexOf('const request = buildQuickCreationRequest(') - expect(quickPolicySave).toBeGreaterThanOrEqual(0) - expect(quickCreate).toBeGreaterThan(quickPolicySave) - }) - - it('resolves submit-time GitHub smart input when folder child repos exist', () => { - expect( - canResolveFolderSmartGitHubSubmit({ - hasFolderSourceRepos: true - }) - ).toBe(true) - expect( - canResolveFolderSmartGitHubSubmit({ - hasFolderSourceRepos: false - }) - ).toBe(false) - - const lookupSection = COMPOSER_SOURCE.githubSubmit - expect(lookupSection).toContain('isProjectGroupTarget') - expect(lookupSection).toContain('folderSourceRepos.filter(isGitRepoKind)') - expect(lookupSection).toContain('Promise.all') - expect(lookupSection).toContain('buildTaskSourceContextFromRepo') - - const section = COMPOSER_SOURCE.folderSubmit - expect(section).toContain('canResolveFolderSmartGitHubSubmit') - expect(section).toContain('hasFolderSourceRepos: folderSourceRepos.length > 0') - expect(section).toContain('? resolvePendingSmartGitHubSubmit()') - expect(section).toContain("Promise.resolve({ kind: 'none' } as const)") - expect(section).toContain("smartGitHubSettlement.status === 'cancelled'") - expect(section).not.toContain('folderSourceRequiresConnection') - }) - - it('clears branch reuse state when manually editing the branch name', () => { - const section = COMPOSER_SOURCE.sourceIdentity - - expect(section).toContain('resolveComposerManualBranchNameChange') - expect(section).toContain('setReuseEligibleBranch(null)') - expect(section).toContain('setReuseSelectedBranch(false)') - expect(section).toContain("branchAutoNameRef.current = ''") - }) - - it('forces repo-scoped source reset when returning from folder target to a repo with the same id', () => { - const handleRepoChange = COMPOSER_SOURCE.targetChange - expect(handleRepoChange).toContain('forceResetStartFrom?: boolean') - expect(handleRepoChange).toContain('value === repoId && !options.forceResetStartFrom') - - const handleProjectChange = COMPOSER_SOURCE.projectTarget - expect(handleProjectChange).toContain( - 'handleRepoChange(nextRepoId, { forceResetStartFrom: isProjectGroupTarget })' - ) - }) - - it('keeps a Linear branch override when its workspace-scoped issue survives a repo change', () => { - const section = COMPOSER_SOURCE.targetChange - - expect(section).toContain('const preservedLinearBranchName = preserveLinearLinkedWorkItem') - expect(section).toContain('getLinearLinkedWorkItemBranchName(linkedWorkItem)') - expect(section).toContain('setBranchNameOverride(preservedLinearBranchName)') - expect(section).toContain( - 'setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName))' - ) - expect(section).toContain("branchAutoNameRef.current = preservedLinearBranchName ?? ''") - }) - - it('clears a Linear branch override when its linked issue is removed', () => { - const section = COMPOSER_SOURCE.sourceIdentity - - expect(section).toContain('const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem)') - expect(section).toContain('if (removedLinearItem)') - expect(section).toContain('setBranchNameOverride(undefined)') - expect(section).toContain('setBranchNameOverridePreservesNameEdits(false)') - expect(section).toContain("branchAutoNameRef.current = ''") - }) - - it('keeps Jira-mode URL edits synchronously blocked before lookup settles', () => { - // Why: derived on every render from the same name the submit path uses, so an in-flight - // URL cannot slip through between the keystroke and the create. - expect(COMPOSER_SOURCE.workspaceIdentity).toContain( - 'isBlockingJiraUrlIntent(smartNameMode, name)' - ) - const section = COMPOSER_SOURCE.sourceIdentity - - expect(section).not.toContain("smartNameMode === 'smart'") - expect(section).not.toContain('setSourceIntentBlocksCreate') - }) - - it('selects a project by its own host instead of pinning the current host', () => { - // Regression: passing the current host as a hard `hostId` made picking a - // project set up only on a different host a silent no-op. The current host - // must be a preference (focusedHostScope), with a fallback to any ready host. - const handleProjectChange = COMPOSER_SOURCE.projectTarget - expect(handleProjectChange).toContain('focusedHostScope: preferredHostId ?? workspaceHostScope') - expect(handleProjectChange).not.toContain('hostId: preferredHostId') - }) - - it('clears GitLab-specific linked state when clearing smart-name selection', () => { - const section = COMPOSER_SOURCE.issueSource - expect(section).toContain("setLinkedIssue('')") - expect(section).toContain('setLinkedPR(null)') - expect(section).toContain('setLinkedGitLabIssue(null)') - expect(section).toContain('setLinkedGitLabMR(null)') - expect(section).toContain('setLinkedWorkItem(null)') - }) - - it('clears stale opposite-provider review fields when selecting linked work items', () => { - const githubApply = COMPOSER_SOURCE.githubSourceApplication - expect(githubApply).toContain('setLinkedGitLabIssue(null)') - expect(githubApply).toContain('setLinkedGitLabMR(null)') - expect(githubApply).toContain('setBranchNameOverridePreservesNameEdits(false)') - expect(githubApply).toContain("branchAutoNameRef.current = ''") - - const gitlabApply = COMPOSER_SOURCE.gitlabProvider - expect(gitlabApply).toContain("setLinkedIssue('')") - expect(gitlabApply).toContain('setLinkedPR(null)') - expect(gitlabApply).toContain('setBranchNameOverridePreservesNameEdits(false)') - expect(gitlabApply).toContain("branchAutoNameRef.current = ''") - - const githubProjectGroupHandler = COMPOSER_SOURCE.githubProvider - const gitlabProjectGroupHandler = COMPOSER_SOURCE.githubProvider - expect(githubProjectGroupHandler).toContain('setLinkedGitLabIssue(null)') - expect(githubProjectGroupHandler).toContain('setLinkedGitLabMR(null)') - expect(gitlabProjectGroupHandler).toContain( - "setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '')" - ) - expect(gitlabProjectGroupHandler).toContain( - "setLinkedPR(identity.type === 'pr' ? identity.number : null)" - ) - }) - - it('disables repo-backed folder smart lookup when a folder target has no source repos', () => { - const cardProps = COMPOSER_SOURCE.cardProps - expect(cardProps).toContain( - 'repoBackedSourcesDisabled: isProjectGroupTarget ? folderSourceRepos.length === 0 : false' - ) - expect(cardProps).toContain( - 'repoBackedSearchRepos: isProjectGroupTarget ? folderSourceRepos : undefined' - ) - }) - - it('surfaces folder submit smart-resolution failures through create error UI', () => { - const section = COMPOSER_SOURCE.folderSubmit - expect(section).toContain('catch (error)') - expect(section).toContain('const formattedError = formatWorkspaceCreateError(error)') - expect(section).toContain('setCreateError(formattedError)') - expect(section).toContain('toast.error(getWorkspaceCreateErrorToastMessage(formattedError))') - expect(section).toContain('if (!folderWorkspaceCreated)') - expect(section).toContain('setCreateError({') - }) - - it('uses submit-time smart metadata for both folder launch mode and startup content', () => { - const section = COMPOSER_SOURCE.folderSubmit - expect(section).toContain( - 'const submitLinkedWorkItem = smartGitHubMetadata?.linkedWorkItem ?? linkedWorkItem' - ) - expect(section).toContain('resolveFolderWorkspaceLaunchDraft(submitLinkedWorkItem, note)') - expect(section).toContain('linkedWorkItem: submitLinkedWorkItem') - }) - - it('gates every submit path on the derived source intent', () => { - // Why: derived from name+mode, so the submitted name and the gate can never disagree. - expect(COMPOSER_SOURCE.workspaceIdentity).toContain( - 'const sourceIntentBlocksCreate = !linkedWorkItem && isBlockingJiraUrlIntent(smartNameMode, name)' - ) - const submitSections = [ - COMPOSER_SOURCE.navigation, - COMPOSER_SOURCE.fullSubmitOrchestration, - COMPOSER_SOURCE.quickSubmitAction - ] - - for (const section of submitSections) { - expect(section).toContain('sourceIntentBlocksCreate') - } - }) - - it('passes folder child repos to smart lookup instead of building task source options', () => { - const cardProps = COMPOSER_SOURCE.cardProps - expect(cardProps).toContain( - 'repoBackedSearchRepos: isProjectGroupTarget ? folderSourceRepos : undefined' - ) - expect(cardProps).not.toContain('folderSourceProjectOptions') - expect(cardProps).not.toContain('handleFolderTaskSourceProjectChange') - expect(cardProps).not.toContain('getRepoIdFromNewWorkspaceFolderSourceOptionId') - }) - - it('keeps folder run repo changes inside the selected folder source set', () => { - const section = COMPOSER_SOURCE.targetChange - expect(section).toContain('folderSourceRepos.some((repo) => repo.id === value)') - expect(section).toContain('return') - - const cardProps = COMPOSER_SOURCE.cardProps - expect(cardProps).toContain('allowSmartNameAddProject: !isProjectGroupTarget') - }) - - it('preserves Jira linked items when switching from repo target to folder target', () => { - const section = COMPOSER_SOURCE.projectTarget - expect(section).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)') }) it('restores Jira draft context only when its site and issue identity agree', () => { @@ -644,139 +186,4 @@ describe('useComposerState host-context boundaries', () => { getMatchingLinkedTaskSourceContext({ ...item, jiraIdentifier: 'ORCA-999' }, context) ).toBeNull() }) - - it('resolves quick-create base refs through the worktree-create precedence helper', () => { - const section = COMPOSER_SOURCE.quickSubmitPreparation - expect(section).not.toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') - expect(section).not.toContain('getRuntimeRepoBaseRefDefault') - }) - - it('plans new workspace agent startup from the selected repo runtime', () => { - expect(COMPOSER_SOURCE.runtimeTarget).toContain( - 'const selectedRepoAgentLaunchPlatform = useMemo' - ) - expect(COMPOSER_SOURCE.runtimeTarget).toContain('getLocalRepoProjectExecutionRuntimeContext') - expect(COMPOSER_SOURCE.runtimeTarget).toContain( - 'getAgentLaunchPlatformForRepo(selectedRepo, projectRuntime)' - ) - - const fullStartupPlan = sourceBetween( - COMPOSER_SOURCE.fullSubmitPreparation, - 'const startupPlan = buildAgentStartupPlan({', - 'const shouldSeedInitialAgentStatus =' - ) - expect(fullStartupPlan).toContain('platform: selectedRepoAgentLaunchPlatform') - expect(fullStartupPlan).not.toContain('platform: CLIENT_PLATFORM') - expect(COMPOSER_SOURCE.fullCreation).toContain('startupDraft: startupPlan.draftPrompt') - - const quickStartupPlan = sourceBetween( - COMPOSER_SOURCE.quickCreation, - 'buildQuickComposerStartup({', - 'const startupPolicySettlement =' - ) - expect(quickStartupPlan).toContain('platform: selectedRepoAgentLaunchPlatform') - expect(quickStartupPlan).not.toContain('platform: CLIENT_PLATFORM') - }) - - // Why: activation no longer rebuilds a startup from `createdWithAgent`, so this - // caller's own `startup` is the only thing that launches the agent it planned. - it('passes its own startup to activation when submit planned an agent', () => { - const activation = COMPOSER_SOURCE.fullCreation + COMPOSER_SOURCE.fullCreationStartup - - expect(activation).toContain('...(!structuredLaunch && startup ? { startup } : {})') - expect(activation).toContain('backendStartupTerminalSpawned: true') - expect(activation).toContain('command: args.startupPlan.launchCommand') - expect(activation).toContain('launchAgent: args.agent') - // The removed activation-time fallback must not come back through this caller. - expect(COMPOSER_SOURCE.fullCreation).not.toContain('buildCreatedAgentReopenStartup') - }) - - it('prepares linked quick-create drafts for the selected default agent', () => { - const quickSubmit = COMPOSER_SOURCE.quickCreation + COMPOSER_SOURCE.quickStartup - - expect(quickSubmit).toContain( - 'const promptLinkedWorkItem = agent === null ? null : submitLinkedWorkItem' - ) - expect(quickSubmit).toContain('resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem') - expect(quickSubmit).not.toContain('explicitAgentChoice') - expect(quickSubmit).not.toContain('shouldPrepareQuickLinkedWorkItemAgentPrompt') - expect(COMPOSER_SOURCE.quickCreation).not.toContain('resolveQuickWorkspaceSubmitAgent') - }) - - it('keeps sentinel-based Jira and Linear starts out of issue-command templates', () => { - const submitSources = - COMPOSER_SOURCE.derived + - COMPOSER_SOURCE.fullSubmitSourcePreparation + - COMPOSER_SOURCE.fullSubmitPreparation + - COMPOSER_SOURCE.fullCreation + - COMPOSER_SOURCE.quickSubmitPreparation + - COMPOSER_SOURCE.quickCreation - expect(submitSources).not.toContain('isOrcaCliAvailableForLaunch') - expect(submitSources).not.toContain('hasGeneratedLinearSourceContext') - expect(submitSources).not.toContain('shouldDraftGeneratedLinearContext') - expect(COMPOSER_SOURCE.derived).toMatch( - /willApplyIssueCommandAsPrompt[\s\S]*canUseIssueCommandForLinkedItemProvider\(linkedWorkItemProvider\)/ - ) - - const previewSection = COMPOSER_SOURCE.derived - expect(previewSection).toContain( - 'canUseIssueCommandForLinkedItemProvider(linkedWorkItemProvider)' - ) - - const fullSubmit = - COMPOSER_SOURCE.fullSubmitSourcePreparation + - COMPOSER_SOURCE.fullSubmitPreparation + - COMPOSER_SOURCE.fullCreation + - COMPOSER_SOURCE.fullCreationStartup - expect(fullSubmit).toContain( - 'canUseIssueCommandForLinkedItemProvider(submitLinkedWorkItemProvider)' - ) - expect(fullSubmit).toMatch( - /submitShouldRunIssueAutomation[\s\S]*canUseIssueCommandForLinkedItemProvider\(submitLinkedWorkItemProvider\)/ - ) - expect(fullSubmit).toContain('prompt: submitStartupPrompt') - expect(fullSubmit).toContain('const shouldSeedInitialAgentStatus =') - expect(fullSubmit).toContain('...(args.shouldSeedInitialAgentStatus') - - const quickSubmit = - COMPOSER_SOURCE.quickSubmitPreparation + - COMPOSER_SOURCE.quickCreation + - COMPOSER_SOURCE.quickStartup - expect(quickSubmit).toContain('startupPlan.draftPrompt = draftPrompt') - }) - - it('selects the failed Jira source host before opening integration settings', () => { - const section = COMPOSER_SOURCE.navigation - - expect(section).toContain('getTaskSourceRuntimeSettings(') - expect(section).toContain('smartNameJiraSourceContext') - expect(section).toContain('setActiveRuntimeEnvironmentPreference(targetRuntimeEnvironmentId)') - expect(section.indexOf('setActiveRuntimeEnvironmentPreference')).toBeLessThan( - section.indexOf("openSettingsTarget({ pane: 'integrations'") - ) - expect(section).toContain('if (!selected)') - }) - - it('gates per-workspace environment recipe discovery behind the experimental setting', () => { - const recipeLoadSection = COMPOSER_SOURCE.runtimeTarget - expect(recipeLoadSection).toContain('settings?.experimentalEphemeralVms === true') - expect(recipeLoadSection).toContain('useEphemeralVmRecipeOptions') - expect(recipeLoadSection).toContain('enabled: ephemeralVmsEnabled') - expect(RECIPE_OPTIONS_SOURCE).toContain('args.enabled &&') - expect(RECIPE_OPTIONS_SOURCE).toContain('window.api.ephemeralVm') - expect(RECIPE_OPTIONS_SOURCE).toContain('window.api.plugins.onChanged') - expect(RECIPE_OPTIONS_SOURCE).toContain('requestGeneration') - - const submitSection = COMPOSER_SOURCE.quickCreation - expect(submitSection).toContain( - 'const activeEphemeralVmRecipeId = ephemeralVmsEnabled ? selectedEphemeralVmRecipeId : null' - ) - expect(submitSection).toContain('recipeId: activeEphemeralVmRecipeId') - - const cardPropsSection = COMPOSER_SOURCE.cardProps - expect(cardPropsSection).toContain('ephemeralVmRecipes:') - expect(cardPropsSection).toContain('!ephemeralVmsEnabled') - expect(cardPropsSection).toContain('selectedEphemeralVmRecipeId:') - expect(cardPropsSection).toContain('ephemeralVmRecipeError:') - }) }) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 12ca698f192..e0e4761aad0 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -1,5 +1,3 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { basename, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata' import type { Repo } from '../../../shared/repo-types' @@ -405,42 +403,4 @@ describe('settings navigation metadata', () => { expect(ids({ isMac: false })).not.toContain('developer-permissions') expect(ids({ isMac: true })).toContain('developer-permissions') }) - - it('does not import Settings page or pane UI modules from the metadata hook', () => { - const testDir = import.meta.dirname - // Why: the section tables live in sibling settings-navigation-* modules, so reading only the - // hook would scan a file that no longer holds the imports this guard exists to police. - // Walk recursively so a later split that nests the modules cannot shrink this guard. - const sourceFiles = [ - 'useSettingsNavigationMetadata.ts', - ...readdirSync(testDir, { recursive: true, encoding: 'utf8' }).filter( - (name) => basename(name).startsWith('settings-navigation-') && name.endsWith('.ts') - ) - ] - expect(sourceFiles.length).toBeGreaterThan(1) - const importLines = sourceFiles - .flatMap((name) => readFileSync(resolve(testDir, name), 'utf8').split('\n')) - .filter((line) => line.trim().startsWith('import ')) - .join('\n') - - expect(importLines).not.toMatch(/components\/settings\/Settings(?:'|")/) - expect(importLines).not.toMatch(/components\/settings\/[A-Z][A-Za-z]+Pane(?:'|")/) - expect(importLines).not.toMatch(/components\/stats\/StatsPane(?:'|")/) - }) - - it('does not import Settings page or pane UI modules from the quick action registry', () => { - const testDir = import.meta.dirname - const registrySource = readFileSync( - resolve(testDir, '../components/cmd-j/quick-actions.ts'), - 'utf8' - ) - const importLines = registrySource - .split('\n') - .filter((line) => line.trim().startsWith('import ')) - .join('\n') - - expect(importLines).not.toMatch(/components\/settings\/Settings(?:'|")/) - expect(importLines).not.toMatch(/components\/settings\/[A-Z][A-Za-z]+Pane(?:'|")/) - expect(importLines).not.toMatch(/components\/stats\/StatsPane(?:'|")/) - }) }) diff --git a/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts b/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts deleted file mode 100644 index 0b168e66ef4..00000000000 --- a/src/renderer/src/lib/agent-launch-caller-profile-census.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Ties every caller profile back to the call site it characterizes. The behaviour files feed the -// profiles through the real funnel; without this census a call site could change what it passes and -// the behaviour files would keep happily pinning a shape nobody sends any more. - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' -import { AGENT_LAUNCH_CALLER_PROFILES } from './agent-launch-caller-profiles-test-harness' - -const REPO_ROOT = join(import.meta.dirname, '../../../..') -const FUNNEL = 'src/renderer/src/lib/launch-agent-in-new-tab.ts' - -async function productionCallers(): Promise { - const files = await glob(['src/**/*.ts', 'src/**/*.tsx'], { - cwd: REPO_ROOT, - ignore: ['**/*.test.ts', '**/*.test.tsx'] - }) - return files - .filter((file) => file !== FUNNEL) - .filter((file) => readFileSync(join(REPO_ROOT, file), 'utf8').includes('launchAgentInNewTab(')) - .sort() -} - -describe('agent launch caller profiles', () => { - it('covers every production call site of the funnel and no phantom one', async () => { - const profiled = AGENT_LAUNCH_CALLER_PROFILES.map((profile) => profile.caller).sort() - - expect(profiled).toEqual(await productionCallers()) - }) - - it('names each profile exactly once', () => { - const ids = AGENT_LAUNCH_CALLER_PROFILES.map((profile) => profile.id) - - expect(new Set(ids).size).toBe(ids.length) - expect(ids.length).toBe(13) - }) - - it.each(AGENT_LAUNCH_CALLER_PROFILES.map((profile) => [profile.id, profile] as const))( - 'keeps %s anchored to the arguments its call site still builds', - (_id, profile) => { - const source = readFileSync(join(REPO_ROOT, profile.caller), 'utf8') - - // Why: a positive control on the instrument — an unreadable or renamed file would otherwise - // make every marker below vacuously "not found" and the assertion would just be noise. - expect(source).toContain('launchAgentInNewTab(') - expect(profile.sourceMarkers.length).toBeGreaterThan(0) - for (const marker of profile.sourceMarkers) { - expect(source).toContain(marker) - } - } - ) - - it.each(AGENT_LAUNCH_CALLER_PROFILES.map((profile) => [profile.id, profile] as const))( - 'records whether %s installs launch callbacks, matching its source', - (_id, profile) => { - const source = readFileSync(join(REPO_ROOT, profile.caller), 'utf8') - - expect(source.includes('beforeSurfaceOpen:')).toBe(profile.passesBeforeSurfaceOpen) - expect(source.includes('agentSessionLaunchPlan,')).toBe(profile.passesLaunchPlan) - expect(source.includes('onPromptDelivered')).toBe(profile.passesOnPromptDelivered) - } - ) - - it('pins which call sites read a tab id straight off the synchronous result', () => { - const readers = AGENT_LAUNCH_CALLER_PROFILES.filter((profile) => - profile.readsBack.includes('surface-tab-id') - ).map((profile) => profile.id) - - // Why: this is the coupling a move to an async launch RPC threatens most directly — these - // call sites cannot tolerate a tab id that only exists after the launch settles. - expect(readers.sort()).toEqual([ - 'empty-workspace-default-chat', - 'fix-checks', - 'floating-default-agent', - 'quick-command', - 'source-control-action', - 'source-control-recovery', - 'tab-bar-create-menu', - 'tab-bar-quick-launch-button' - ]) - }) - - it('pins which call sites await the prompt-delivery promise', () => { - const awaiters = AGENT_LAUNCH_CALLER_PROFILES.filter((profile) => - profile.readsBack.includes('prompt-delivery-result') - ).map((profile) => profile.id) - - expect(awaiters.sort()).toEqual(['session-continuation', 'source-control-action']) - }) -}) diff --git a/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts b/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts index 5aaa913044f..a988e1673f5 100644 --- a/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-caller-profiles-test-harness.ts @@ -7,42 +7,23 @@ import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' * The suite feeds these argument shapes through the real funnel instead of driving twelve caller * module graphs: a migration rewrites the funnel's internals, not what a caller hands it, so the * observable outcome of each caller's argument shape is the thing that must survive. - * - * `sourceMarkers` re-anchors each profile to its call site. A caller that stops passing what the - * profile encodes fails the census rather than silently drifting out of coverage. */ export type CallerLaunchArgs = Omit< LaunchAgentInNewTabArgs, 'beforeSurfaceOpen' | 'agentSessionLaunchPlan' | 'onPromptDelivered' > -export type CallerResultRead = - | 'surface-tab-id' - | 'prompt-delivery-result' - | 'null-only' - | 'discarded' - export type AgentLaunchCallerProfile = { /** Production module that owns this call site. */ caller: string /** Stable id used in test titles so a dropped profile is visible in the report. */ id: string - /** Literal fragments the call site must still contain for this profile to be current. */ - sourceMarkers: readonly string[] /** * The argument object this call site builds. Values the call site derives at runtime (agent, * workspace, prompt text, group) use a representative stand-in; values it fixes as a literal are * reproduced exactly. */ args: CallerLaunchArgs - /** The call site installs a `beforeSurfaceOpen` hook. */ - passesBeforeSurfaceOpen: boolean - /** The call site hands in a route plan preflighted before the workspace existed. */ - passesLaunchPlan: boolean - /** The call site installs an `onPromptDelivered` callback. */ - passesOnPromptDelivered: boolean - /** What the call site reads off the synchronous result. */ - readsBack: readonly CallerResultRead[] } const PROMPT = 'Explain the failing check and propose a fix.' @@ -51,51 +32,30 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = { id: 'dashboard-spawn', caller: 'src/renderer/src/components/dashboard/launch-dashboard-agent.ts', - sourceMarkers: ["launchSource: 'unknown'"], - args: { agent: 'codex', worktreeId: 'wt-1', launchSource: 'unknown' }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['null-only'] + args: { agent: 'codex', worktreeId: 'wt-1', launchSource: 'unknown' } }, { id: 'empty-workspace-default-chat', caller: 'src/renderer/src/lib/empty-workspace-default-agent-chat.ts', - sourceMarkers: ["launchSource: 'unknown'", 'pendingActivationSpawn: true'], args: { agent: 'codex', worktreeId: 'wt-1', launchSource: 'unknown', pendingActivationSpawn: true - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: true, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id'] + } }, { id: 'floating-default-agent', caller: 'src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx', - sourceMarkers: ['worktreeId: FLOATING_TERMINAL_WORKTREE_ID', "launchSource: 'shortcut'"], args: { agent: 'codex', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, launchSource: 'shortcut' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id'] + } }, { id: 'source-control-action', caller: 'src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts', - sourceMarkers: [ - 'groupId: groupId ?? worktreeId', - 'agentArgs: launchAgentArgs', - 'promptDelivery,', - 'launchPlatform,' - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -106,21 +66,11 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = launchPlatform: 'darwin', // Caller-supplied, not fixed at the call site: one representative value stands in. launchSource: 'sidebar' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id', 'prompt-delivery-result'] + } }, { id: 'source-control-recovery', caller: 'src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts', - sourceMarkers: [ - 'groupId: activeGroupId ?? activeWorktreeId', - 'agentArgs: savedRecipe.agentArgs', - "promptDelivery: 'submit-after-ready'", - "launchSource: 'source_control_recovery'" - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -130,36 +80,22 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = promptDelivery: 'submit-after-ready', launchPlatform: 'darwin', launchSource: 'source_control_recovery' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id'] + } }, { id: 'git-history-explain-commit', caller: 'src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts', - sourceMarkers: ['prompt: explainPrompt', "promptDelivery: 'submit-after-ready'"], args: { agent: 'codex', worktreeId: 'wt-1', prompt: PROMPT, promptDelivery: 'submit-after-ready' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['discarded'] + } }, { id: 'tab-bar-quick-launch-button', caller: 'src/renderer/src/components/tab-bar/QuickLaunchButton.tsx', - sourceMarkers: [ - '...(prompt !== undefined ? { prompt } : {})', - '...(promptDelivery !== undefined ? { promptDelivery } : {})', - '...(onPromptDelivered !== undefined ? { onPromptDelivered } : {})' - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -167,36 +103,21 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = prompt: PROMPT, promptDelivery: 'submit-after-ready', launchSource: 'tab_bar_quick_launch' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: true, - readsBack: ['surface-tab-id'] + } }, { id: 'tab-bar-create-menu', caller: 'src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts', - sourceMarkers: ['groupId: resolvedGroupId', "launchSource: 'tab_bar_quick_launch'"], args: { agent: 'codex', worktreeId: 'wt-1', groupId: 'group-1', launchSource: 'tab_bar_quick_launch' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id'] + } }, { id: 'terminal-session-fork', caller: 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', - sourceMarkers: [ - "promptDelivery: 'draft'", - "launchSource: 'terminal_context_menu'", - 'agentSessionLaunchPlan,', - 'beforeSurfaceOpen: (surface) =>' - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -204,30 +125,16 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = promptDelivery: 'draft', launchSource: 'terminal_context_menu', launchPlatform: 'darwin' - }, - passesBeforeSurfaceOpen: true, - passesLaunchPlan: true, - passesOnPromptDelivered: false, - readsBack: ['null-only'] + } }, { id: 'terminal-create-shortcut', caller: 'src/renderer/src/components/use-terminal-create-actions.ts', - sourceMarkers: ['groupId: targetGroupId', "launchSource: 'shortcut'"], - args: { agent: 'codex', worktreeId: 'wt-1', groupId: 'group-1', launchSource: 'shortcut' }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['null-only'] + args: { agent: 'codex', worktreeId: 'wt-1', groupId: 'group-1', launchSource: 'shortcut' } }, { id: 'fix-checks', caller: 'src/renderer/src/lib/fix-checks-agent-launch.ts', - sourceMarkers: [ - 'agentArgs: recipe.agentArgs', - "promptDelivery: 'submit-after-ready'", - 'beforeSurfaceOpen: () => {' - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -238,19 +145,11 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = launchPlatform: 'darwin', // Caller-supplied, not fixed at the call site: one representative value stands in. launchSource: 'task_page' - }, - passesBeforeSurfaceOpen: true, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id'] + } }, { id: 'session-continuation', caller: 'src/renderer/src/lib/launch-agent-session-continuation.ts', - sourceMarkers: [ - "promptDelivery: agent === 'claude' ? 'draft' : 'submit-after-ready'", - 'onPromptDelivered: () =>' - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -260,20 +159,11 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = initialCwd: '/repo/worktree/packages/app', // Caller-supplied, not fixed at the call site: one representative value stands in. launchSource: 'command_palette' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: true, - readsBack: ['prompt-delivery-result'] + } }, { id: 'quick-command', caller: 'src/renderer/src/lib/run-quick-command-in-new-tab.ts', - sourceMarkers: [ - "launchSource: 'quick_command'", - 'quickCommandLabel: command.label', - "{ promptDelivery: 'submit-after-ready' as const }" - ], args: { agent: 'codex', worktreeId: 'wt-1', @@ -281,11 +171,7 @@ export const AGENT_LAUNCH_CALLER_PROFILES: readonly AgentLaunchCallerProfile[] = prompt: PROMPT, launchSource: 'quick_command', quickCommandLabel: 'Review' - }, - passesBeforeSurfaceOpen: false, - passesLaunchPlan: false, - passesOnPromptDelivered: false, - readsBack: ['surface-tab-id'] + } } ] diff --git a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts deleted file mode 100644 index 2c9524c9995..00000000000 --- a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' - -const REPO_ROOT = join(import.meta.dirname, '../../../..') -const CENSUS_FILE = 'src/renderer/src/lib/agent-launch-routing-caller-census.test.ts' - -const LAUNCH_AGENT_IN_NEW_TAB_CALLERS = [ - 'src/renderer/src/components/dashboard/launch-dashboard-agent.ts', - // Joined the funnel rather than appearing beside it: this button used to hand-roll the helper's - // terminal arm against `queueTabStartupCommand`. A line going up here is a converged bypass. - 'src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx', - 'src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts', - 'src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts', - 'src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts', - 'src/renderer/src/components/tab-bar/QuickLaunchButton.tsx', - 'src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts', - 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', - 'src/renderer/src/components/use-terminal-create-actions.ts', - 'src/renderer/src/lib/empty-workspace-default-agent-chat.ts', - 'src/renderer/src/lib/fix-checks-agent-launch.ts', - 'src/renderer/src/lib/launch-agent-session-continuation.ts', - 'src/renderer/src/lib/run-quick-command-in-new-tab.ts' -] - -// Why: the planner is the one production module that decides a route. A second resolver call -// site is how the seven launch sites drifted apart before it existed. -const ROUTE_RESOLVER_DEFINITION = 'src/renderer/src/lib/agent-launch-routing.ts' -const ROUTE_PLANNER = 'src/renderer/src/lib/agent-session-launch-plan.ts' -const DIRECT_ROUTE_RESOLVER_CALL = /\b(?:resolveAgentLaunchRoute|structuredAgentLaunchSupported)\(/ -// Why: adopting a verdict bypasses the resolver by design (a persisted quick-create request, a -// resume whose gate already planned), so each adopter is pinned rather than trusted by convention. -const VERDICT_ADOPTERS = [ - 'src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts', - 'src/renderer/src/lib/worktree-creation-structured-session.ts' -] - -async function productionFiles(): Promise { - return glob(['src/**/*.ts', 'src/**/*.tsx'], { - cwd: REPO_ROOT, - ignore: ['**/*.test.ts', '**/*.test.tsx', CENSUS_FILE] - }) -} - -describe('agent launch routing caller census', () => { - it('pins every production launchAgentInNewTab caller behind the shared funnel', async () => { - const callers = (await productionFiles()) - .filter((file) => file !== 'src/renderer/src/lib/launch-agent-in-new-tab.ts') - .filter((file) => - readFileSync(join(REPO_ROOT, file), 'utf8').includes('launchAgentInNewTab(') - ) - .sort() - expect(callers).toEqual([...LAUNCH_AGENT_IN_NEW_TAB_CALLERS].sort()) - }) - - it('lets only the planner decide a launch route', async () => { - const directCallers = (await productionFiles()) - .filter((file) => file !== ROUTE_RESOLVER_DEFINITION) - .filter((file) => - DIRECT_ROUTE_RESOLVER_CALL.test(readFileSync(join(REPO_ROOT, file), 'utf8')) - ) - .sort() - expect(directCallers).toEqual([ROUTE_PLANNER]) - }) - - it('pins every production adopter of a planned verdict', async () => { - const adopters = (await productionFiles()) - .filter((file) => file !== ROUTE_PLANNER) - .filter((file) => - readFileSync(join(REPO_ROOT, file), 'utf8').includes('adoptAgentSessionLaunchVerdict(') - ) - .sort() - expect(adopters).toEqual([...VERDICT_ADOPTERS].sort()) - }) - - it('keeps non-visible, resume, and floating launchers intentionally outside the route', () => { - for (const file of [ - 'src/renderer/src/lib/launch-agent-background-session.ts', - 'src/renderer/src/lib/launch-ai-vault-session.ts', - 'src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx' - ]) { - expect(readFileSync(join(REPO_ROOT, file), 'utf8')).not.toContain('resolveAgentLaunchRoute') - } - }) -}) diff --git a/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts b/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts index ce16d669f16..87da1737c79 100644 --- a/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts +++ b/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts @@ -20,19 +20,6 @@ afterEach(() => { }) describe('non-secure context (plain HTTP LAN web client)', () => { - it('crypto.randomUUID is undefined, like the browser reports', () => { - // oxlint-disable-next-line no-restricted-properties -- asserting the absence this suite exists for - expect((globalThis.crypto as Crypto).randomUUID).toBeUndefined() - // oxlint-disable-next-line no-restricted-properties -- asserting the absence this suite exists for - expect(() => (globalThis.crypto as Crypto).randomUUID()).toThrow() - }) - - it('hashOrcaHookScript does not throw when crypto.subtle is missing', async () => { - const { hashOrcaHookScript } = await import('./orca-hook-trust') - const hash = await hashOrcaHookScript('echo hi') - expect(hash).toMatch(/^[0-9a-f]+$/) - }) - // The fallback must match the secure-context hash, or the shared trust store // mismatches and the user is re-prompted to approve a hook they already // trusted on the desktop app. @@ -54,9 +41,7 @@ describe('non-secure context (plain HTTP LAN web client)', () => { it('loads the renderer agent-status authority and its store slice', async () => { vi.resetModules() const { rendererAgentStatusObservations } = await import('./renderer-agent-status-observations') - const { createAgentStatusAuthorityActions } = - await import('../store/slices/agent-status-authority-actions') - expect(typeof createAgentStatusAuthorityActions).toBe('function') + await import('../store/slices/agent-status-authority-actions') expect(rendererAgentStatusObservations.getAuthorityId()).toMatch( /^renderer:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ ) diff --git a/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts b/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts index 89e0456c2ce..d09337c8d0c 100644 --- a/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts +++ b/src/renderer/src/lib/react-commit-cascade-telemetry.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { sanitizeCrashReportDetails } from '../../../shared/crash-report-redaction' import { REACT_NESTED_UPDATE_LIMIT } from '../../../shared/react-update-depth-attribution' import { - REACT_CASCADING_LANES, setReactCommitCascadeRendererSurface, REACT_COMMIT_CASCADE_ARM_COMMITS, REACT_COMMIT_CASCADE_BREADCRUMB, @@ -67,7 +66,6 @@ describe('cascade thresholds', () => { it('derives arm and notice limits from React commit budget', () => { expect(REACT_COMMIT_CASCADE_NOTICE_LIMIT).toBeLessThan(REACT_NESTED_UPDATE_LIMIT) expect(REACT_COMMIT_CASCADE_ARM_COMMITS).toBeLessThan(REACT_COMMIT_CASCADE_NOTICE_LIMIT) - expect(REACT_CASCADING_LANES).toBe(42) }) }) diff --git a/src/renderer/src/lib/structured-agent-startup-label-census.test.ts b/src/renderer/src/lib/structured-agent-startup-label-census.test.ts deleted file mode 100644 index 888e17c46c1..00000000000 --- a/src/renderer/src/lib/structured-agent-startup-label-census.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' - -const REPO_ROOT = join(import.meta.dirname, '../../../..') - -describe('structured launch copy', () => { - it('never exposes the removed starting-chat phase or label', async () => { - const files = await glob( - ['src/renderer/src/**/*.ts', 'src/renderer/src/**/*.tsx', 'src/renderer/src/i18n/**/*.json'], - { - cwd: REPO_ROOT, - ignore: ['**/*.test.ts', '**/*.test.tsx'] - } - ) - const offenders = files.filter((file) => { - const source = readFileSync(join(REPO_ROOT, file), 'utf8') - return ( - source.includes('starting-chat') || - source.includes('Starting chat…') || - /Starting (?:\{\{value0\}\}|Claude|Codex) chat…/.test(source) - ) - }) - - expect(offenders).toEqual([]) - }) -}) diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts index eafc3e3b2b8..2e4dfac4a4a 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' describe('workspace emoji shortcode index laziness', () => { @@ -20,11 +18,4 @@ describe('workspace emoji shortcode index laziness', () => { expect(shortcodeIndex.searchWorkspaceEmojiShortcodes('tada')[0]?.emoji).toBe('🎉') expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true) }) - - it('keeps the exact-shortcode index out of module scope', () => { - const indexSource = readFileSync(join(__dirname, 'workspace-emoji-shortcodes.ts'), 'utf8') - - expect(indexSource).not.toMatch(/^const \w+ = new Map\(/m) - expect(indexSource).not.toContain('STANDARD_EMOJI_SHORTCODE_ENTRIES') - }) }) diff --git a/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts b/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts deleted file mode 100644 index 2a960761900..00000000000 --- a/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow-execute.ts'), 'utf8') -const PREFLIGHT_SOURCE = readFileSync(join(__dirname, 'agent-trust-preflight.ts'), 'utf8') - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('worktree creation flow agent trust preflight', () => { - it('forwards the repo SSH connection id when pre-marking terminal agent trust', () => { - const preflight = PREFLIGHT_SOURCE - const createFlow = sourceBetween( - FLOW_SOURCE, - 'const backendSpawned = result.startupTerminal?.spawned === true', - '// `createWorktree` already inserted the real worktree row' - ) - - expect(preflight).toContain('connectionId?: string | null') - expect(preflight).toContain('...(args.connectionId ? { connectionId: args.connectionId } : {})') - expect(createFlow).toContain('repoConnectionId') - expect(createFlow).toContain('repo.id === worktree.repoId') - expect(createFlow).toContain('await preflightAgentTrust({') - expect(createFlow).toContain('connectionId: repoConnectionId') - }) -}) diff --git a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts index 78b71ef25da..e964a9f28f1 100644 --- a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts +++ b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts @@ -55,18 +55,6 @@ describe('#9040 worktree dot attributes spinner titles to the launched agent', ( expect(status).toBe('working') }) - // Why: pins the #9647 gate — spinner attribution needs a launch identity, so a - // spinner in a tab no agent was launched in cannot spin the dot. - it('stays active for a spinner title with no launch identity', () => { - const status = getWorktreeStatus( - [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], - [], - livePtyMap('tab-1') - ) - - expect(status).toBe('active') - }) - it('does not manufacture activity from a non-spinner title with a launch identity', () => { const status = getWorktreeStatus( [{ id: 'tab-1', title: 'bash', launchAgent: 'claude' }], diff --git a/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts b/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts deleted file mode 100644 index 212e9d7afe7..00000000000 --- a/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why: who tears a browser workspace down is a policy, and every close site that decides it alone -// gets it wrong in a different way — the ambiguous multi-owner close fell through silently, the -// pageless host mirror was un-closable, and the menu's Close Tab fired at a host that had never -// heard of a staged tab. Testing planBrowserWorkspaceTabClose in isolation cannot catch the next -// site that skips it, so this census pins every renderer file that closes a browser workspace: -// how often it reaches for the store action, and whether it asks the shared plan first. Adding a -// close anywhere fails this test until it is classified here. -const BROWSER_WORKSPACE_CLOSE_SITES: { - path: string - /** - * Every mention of the identifier, not just call expressions: a renamed destructure or a - * `store['closeBrowserTab']` lookup is still a close site, and matching `closeBrowserTab(` - * walks straight past both. - */ - closeBrowserTabMentions: number - /** How many local teardowns forward the plan's cleanup reason; 0 for sites that skip the plan. */ - planReasonForwardings: number - /** How many closeBrowserTab calls actually pass that reason on; 0 for sites that skip the plan. */ - reasonCarryingCloseCalls: number - routesThroughPlan: boolean - why: string -}[] = [ - { - path: 'src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts', - closeBrowserTabMentions: 4, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Mirrors a page the host already retired; a plan-driven close would echo it back.' - }, - { - path: 'src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts', - closeBrowserTabMentions: 6, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Floating workspace tabs are local-only and have no remote owner to reconcile.' - }, - { - path: 'src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts', - closeBrowserTabMentions: 3, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Passes the local floating-panel close action through its controller.' - }, - { - path: 'src/renderer/src/components/use-terminal-bulk-close-actions.ts', - closeBrowserTabMentions: 3, - reasonCarryingCloseCalls: 1, - planReasonForwardings: 1, - routesThroughPlan: true, - why: 'Bulk terminal-tab teardown plans browser ownership before local cleanup.' - }, - { - path: 'src/renderer/src/components/use-terminal-workspace-store-bindings.ts', - closeBrowserTabMentions: 3, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Store binding exposes the close action to terminal controllers.' - }, - { - path: 'src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts', - closeBrowserTabMentions: 3, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Host-requested closes are applied locally without echoing a close request.' - }, - { - path: 'src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts', - closeBrowserTabMentions: 2, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Host session-tab requests are applied locally without echo.' - }, - { - path: 'src/renderer/src/lib/workspace-browser-tab-close.ts', - closeBrowserTabMentions: 2, - reasonCarryingCloseCalls: 1, - planReasonForwardings: 1, - routesThroughPlan: true, - why: 'All workspace browser teardown funnels through the ownership plan before local cleanup.' - }, - { - path: 'src/renderer/src/runtime/browser-workspace-tab-close.ts', - closeBrowserTabMentions: 1, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: true, - why: 'The funnel disavowal fallback completes teardown when every owning host denies the tab.' - }, - { - path: 'src/renderer/src/runtime/web-runtime-browser-tab-staging.ts', - closeBrowserTabMentions: 1, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Rolls back a client-staged row for a create that never reached a host page.' - }, - { - path: 'src/renderer/src/store/slices/browser/browser-close-actions.ts', - closeBrowserTabMentions: 3, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'Slice-owned shutdown tears down every browser row directly.' - }, - { - path: 'src/renderer/src/store/slices/browser/browser-slice-contract.ts', - closeBrowserTabMentions: 1, - reasonCarryingCloseCalls: 0, - planReasonForwardings: 0, - routesThroughPlan: false, - why: 'The extracted slice contract declares the close action consumed by browser controllers.' - } -] - -function listSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - return listSourceFiles(fullPath) - } - // Test harnesses and fixtures stub the store action rather than closing anything. - if ( - !/\.(ts|tsx)$/.test(entry.name) || - /\.test\.(ts|tsx)$/.test(entry.name) || - /-test-(harness|fixtures)\.(ts|tsx)$/.test(entry.name) - ) { - return [] - } - return [fullPath] - }) -} - -// Why: comments are stripped first, so commenting a close call out in place must fail the census -// rather than quietly shrink the count. -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') -} - -function countCloseBrowserTabMentions(source: string): number { - return stripComments(source).match(/\bcloseBrowserTab\b/g)?.length ?? 0 -} - -describe('browser workspace close census', () => { - it.each(BROWSER_WORKSPACE_CLOSE_SITES)( - '$path mentions closeBrowserTab $closeBrowserTabMentions times, plan-routed: $routesThroughPlan', - ({ path, closeBrowserTabMentions, routesThroughPlan }) => { - const source = stripComments(readFileSync(join(process.cwd(), path), 'utf8')) - expect(countCloseBrowserTabMentions(source)).toBe(closeBrowserTabMentions) - expect(/\bcloseBrowserWorkspaceTabOnHosts\(/.test(source)).toBe(routesThroughPlan) - } - ) - - it('lists every renderer file that closes a browser workspace', () => { - const root = join(process.cwd(), 'src/renderer') - const closers = listSourceFiles(root) - .filter((filePath) => countCloseBrowserTabMentions(readFileSync(filePath, 'utf8')) > 0) - .map((filePath) => relative(process.cwd(), filePath).split(sep).join('/')) - .sort() - expect(closers).toEqual(BROWSER_WORKSPACE_CLOSE_SITES.map((site) => site.path).sort()) - }) - - // Why: the plan is only an authority if its consumers actually run its local teardown. A site - // that reads the plan and then closes on its own terms is the same divergence with extra steps. - // Bound to the forwarding expression itself — merely mentioning localCloseReason is not wiring, - // and Terminal.tsx's two sites have no behavior test to catch it going missing. - it('every plan-routed site forwards the plan cleanup reason into its local teardown', () => { - const forwardsReason = - /plan\.localCloseReason\s*\?\s*\{\s*reason:\s*plan\.localCloseReason\s*\}\s*:\s*undefined/g - // Why: the expression existing somewhere in the file is not the wiring — a call that drops it on - // the floor reads identically. Both halves are pinned: the reason is computed, and the close - // calls carry it. - const carriesReason = - /closeBrowserTab\(\s*[^()]*?(?:[Cc]loseOptions|plan\.localCloseReason)[^()]*?\)/g - for (const site of BROWSER_WORKSPACE_CLOSE_SITES) { - const source = stripComments(readFileSync(join(process.cwd(), site.path), 'utf8')) - expect({ - path: site.path, - forwards: source.match(forwardsReason)?.length ?? 0, - carrying: source.match(carriesReason)?.length ?? 0 - }).toEqual({ - path: site.path, - forwards: site.planReasonForwardings, - carrying: site.reasonCarryingCloseCalls - }) - } - }) -}) diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts deleted file mode 100644 index 7d6a0eadc3e..00000000000 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Registry test for the host-session-mirror settle seam. Four review rounds - * each found a NEW call site settling the latch out of order with its store - * patch, so the seam is enforced structurally: a settle exists only as the - * receipt of a landed patch (or as the audited stale-frame exception), and a - * new patch or settle site fails this census until it adopts that contract. - */ - -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -const RENDERER_SRC = join(import.meta.dirname, '..') - -const SETTLE_RULE = [ - 'The mirror latch settles EXACTLY when evidence reached the store.', - 'applyWebSessionTabsStorePatch returns the settle receipt for its own patch:', - 'capture it and invoke it after the frame finishes recovery. A frame whose', - 'patch wrote nothing settles through hostSessionMirrorSettleForPatchlessFrame,', - 'and ONLY on a decision carrying settlesHostMirror — a rejection backed by the', - 'equal/newer view already accepted into the store. Never derive that from a', - 'bare boolean: "the mirror never writes this workspace" is also a rejection,', - 'and settling on it drains parked resume work against state nobody wrote.', - 'A captured receipt that is never invoked wedges its pane instead, so every', - 'receipt has to reach a call. Never call markHostSessionMirror*Hydrated from', - 'feature code, and never add a boolean that remembers "the patch landed" —', - 'that ordering bug shipped four times. If you added a legitimate new site,', - 'update this census deliberately.' -].join(' ') - -/** Receipt-minting calls. Every one owes its caller an invocation. */ -const RECEIPT_CALLS = [ - 'applyWebSessionTabsStorePatch(', - 'hostSessionMirrorSettleForPatchlessFrame(' -] as const - -/** - * The binding a receipt was captured into, read backwards from the call. - * TypeScript cannot express a must-call obligation and the invocation is - * deliberately deferred to after `finishRecovery`, so this stays textual: - * an unrecognised capture shape fails rather than passing unaudited. - */ -function receiptBindingName(before: string): string | null { - const lines = before.split('\n') - for (let line = lines.length - 1; line >= 0 && line > lines.length - 8; line -= 1) { - const match = /\b(?:const|let|var)?\s*([A-Za-z_$][\w$]*)\s*(?::[^=]*?)?=(?![=>])/.exec( - lines[line]! - ) - if (match) { - return match[1]! - } - } - return null -} - -function productionSources(): { path: string; source: string }[] { - const sources: { path: string; source: string }[] = [] - const walk = (dir: string): void => { - for (const entry of readdirSync(dir)) { - const path = join(dir, entry) - if (statSync(path).isDirectory()) { - walk(path) - continue - } - if (!/\.(ts|tsx)$/.test(entry) || /\.test\.|\.d\.ts$/.test(entry)) { - continue - } - sources.push({ - path: relative(RENDERER_SRC, path).split(sep).join('/'), - source: readFileSync(path, 'utf8') - }) - } - } - walk(RENDERER_SRC) - return sources -} - -function countOccurrences(source: string, needle: string): number { - let count = 0 - for ( - let index = source.indexOf(needle); - index !== -1; - index = source.indexOf(needle, index + 1) - ) { - count += 1 - } - return count -} - -describe('host-session-mirror settle census', () => { - const sources = productionSources() - - it('mirror marks are reachable only through the settle receipts', () => { - const markCounts: Record = {} - for (const { path, source } of sources) { - const hydrated = countOccurrences(source, 'markHostSessionMirrorHydrated(') - const worktreeHydrated = countOccurrences(source, 'markHostSessionMirrorWorktreeHydrated(') - if (hydrated + worktreeHydrated > 0) { - markCounts[path] = { hydrated, worktreeHydrated } - } - } - expect(markCounts, SETTLE_RULE).toEqual({ - // The definitions themselves. - 'runtime/host-session-mirror-hydration.ts': { hydrated: 1, worktreeHydrated: 1 }, - // Exactly the receipt constructors: the patch receipt (environment-wide - // and per-worktree) and the stale-frame receipt. The environment-wide - // mark has three audited arms, all inside the receipt: an authoritative - // empty inventory, a non-empty inventory, and an empty inventory that - // settles only after `terminal.list` says the host has no live PTY — - // `0 === 0` is not host evidence (STA-5377). - 'runtime/web-session-tabs-sync/mirror-settle.ts': { hydrated: 3, worktreeHydrated: 2 } - }) - }) - - it('every store patch call site captures its settle receipt', () => { - const needle = 'applyWebSessionTabsStorePatch(' - const callSites: Record = {} - for (const { path, source } of sources) { - let calls = 0 - for ( - let index = source.indexOf(needle); - index !== -1; - index = source.indexOf(needle, index + 1) - ) { - const before = source.slice(0, index).trimEnd() - if (before.endsWith('function')) { - continue // The definition. - } - calls += 1 - // A receipt is captured when the call is an expression consumed by an - // assignment, return, ternary, or argument — never a bare statement. - const capturedBy = before.slice(-1) - expect( - ['=', '(', ',', '?', ':', '{'].includes(capturedBy) || before.endsWith('return'), - `${path} discards the settle receipt of applyWebSessionTabsStorePatch. ${SETTLE_RULE}` - ).toBe(true) - } - if (calls > 0) { - callSites[path] = calls - } - } - expect(callSites, SETTLE_RULE).toEqual({ - // Initial listAll, visibility-resume repair, full inventory, global - // singular frame, and scoped active frame are owned by these extracted - // recovery/event modules. - 'runtime/web-session-tabs-sync/active-session-subscription.ts': 1, - 'runtime/web-session-tabs-sync/global-session-events.ts': 1, - 'runtime/web-session-tabs-sync/global-session-inventory-event.ts': 1, - 'runtime/web-session-tabs-sync/load-initial.ts': 1, - 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': 1, - // The eager post-create session.tabs.list refresh. - 'runtime/web-runtime-session-snapshot.ts': 1, - // The local structured-session mirror owns two: the inventory/subscription - // frame, and the toggle-off teardown that retracts the tabs it published. - 'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': 2 - }) - }) - - it('every captured settle receipt reaches an invocation', () => { - const receiptBindings: Record> = {} - for (const { path, source } of sources) { - for (const needle of RECEIPT_CALLS) { - for ( - let index = source.indexOf(needle); - index !== -1; - index = source.indexOf(needle, index + 1) - ) { - const before = source.slice(0, index).trimEnd() - if (before.endsWith('function')) { - continue // The definition. - } - const name = receiptBindingName(before) - expect( - name, - `${path} captures a settle receipt in a shape this census cannot follow. ${SETTLE_RULE}` - ).not.toBeNull() - // The obligation is discharged by the next call of the same binding — - // a fresh declaration of that name in between means the call belongs - // to another receipt, so this one was dropped. - const rest = source.slice(index + needle.length) - const invocation = new RegExp(`\\b${name}\\s*(?:\\?\\.)?\\(`).exec(rest) - const redeclared = - invocation !== null && - new RegExp(`\\b(?:let|const|var)\\s+${name}\\b`).test(rest.slice(0, invocation.index)) - expect( - invocation !== null && !redeclared, - `${path} never invokes the settle receipt it captured into \`${name}\`. ${SETTLE_RULE}` - ).toBe(true) - const bindings = (receiptBindings[path] ??= {}) - bindings[name!] = (bindings[name!] ?? 0) + 1 - } - } - } - // Pinning the resolved names keeps the rule from going vacuous: a binding - // this census misreads settles on some other identifier's invocation. - expect(receiptBindings, SETTLE_RULE).toEqual({ - // Hydration and mirror receipts remain pinned by their extracted owners: - // the global singular frame owns two hydration completions and the global - // inventory frame one, initial loading owns one, active subscription owns - // two mirror settles, and visibility resume repair owns one. The local - // structured-session apply module owns one settle per direction: the - // snapshot it mirrors in, and the teardown that retracts it. - 'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 }, - 'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 2 }, - 'runtime/web-session-tabs-sync/global-session-inventory-event.ts': { settleHydration: 1 }, - 'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 }, - 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': { settle: 1 }, - 'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 }, - 'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': { - settleStructuredSessionClear: 1, - settleStructuredSessionMirror: 1 - } - }) - }) - - it('the patchless settle appears only at its audited sites', () => { - const patchlessCounts: Record = {} - for (const { path, source } of sources) { - const count = countOccurrences(source, 'hostSessionMirrorSettleForPatchlessFrame(') - if (count > 0) { - patchlessCounts[path] = count - } - } - expect(patchlessCounts, SETTLE_RULE).toEqual({ - // The definition, the global singular frame, and the scoped active frame. - 'runtime/web-session-tabs-sync/active-session-subscription.ts': 1, - 'runtime/web-session-tabs-sync/global-session-events.ts': 1, - 'runtime/web-session-tabs-sync/mirror-settle.ts': 1 - }) - }) - - it('only the audited decisions grant a rejected frame a settle', () => { - const settlingCounts: Record = {} - const silentCounts: Record = {} - for (const { path, source } of sources) { - const settling = countOccurrences(source, 'settlesHostMirror: true') - const silent = countOccurrences(source, 'settlesHostMirror: false') - if (settling > 0) { - settlingCounts[path] = settling - } - if (silent > 0) { - silentCounts[path] = silent - } - } - // A new decision has to be minted here, and minting one forces its author - // to say whether the frame is host evidence — the whole point of the pair. - expect(settlingCounts, SETTLE_RULE).toEqual({ - // The applied frame (its own patch) and the outranked one (the accepted view). - 'runtime/web-session-tabs-sync/tracking-decisions.ts': 2 - }) - expect(silentCounts, SETTLE_RULE).toEqual({ - // The unmirrored frame: no accepted view of it ever reached the store. - 'runtime/web-session-tabs-sync/tracking-decisions.ts': 1 - }) - }) -}) diff --git a/src/renderer/src/runtime/remote-server-parity.test.ts b/src/renderer/src/runtime/remote-server-parity.test.ts index 7c1bcf9bb68..c9779332f66 100644 --- a/src/renderer/src/runtime/remote-server-parity.test.ts +++ b/src/renderer/src/runtime/remote-server-parity.test.ts @@ -210,29 +210,6 @@ describe('parity §3: remote terminal create appends rightmost (matches local)', toWebTerminalSurfaceTabId('host-tab-3') ]) }) - - it('preserves existing relative order when adding (no reshuffle)', () => { - const prior = stateWithLocalTerminals([ - localTerminal('host-tab-1', 0, false), - localTerminal('host-tab-2', 1, true) - ]) - recordWebSessionFocusIntent({ environmentId: ENV }, WT, `host-tab-3::${LEAF_C}`) - const patch = applyWebSessionTabsSnapshot( - prior, - makeSnapshot([ - { parentTab: 'host-tab-1', leaf: LEAF_A, active: false }, - { parentTab: 'host-tab-2', leaf: LEAF_B, active: false }, - { parentTab: 'host-tab-3', leaf: LEAF_C, active: true } - ]), - ENV, - NOW + 10 - ) as Partial - expect(groupOrder(patch)).toEqual([ - toWebTerminalSurfaceTabId('host-tab-1'), - toWebTerminalSurfaceTabId('host-tab-2'), - toWebTerminalSurfaceTabId('host-tab-3') - ]) - }) }) // ────────────────────────────────────────────────────────────────────────── @@ -422,24 +399,3 @@ describe('parity §11: remote browser create focuses the new browser tab', () => expect(nextType).toBe('terminal') }) }) - -// ────────────────────────────────────────────────────────────────────────── -// Plan §1 — Lifecycle: an emptied host snapshot does not bootstrap phantom tabs -// when local terminals already exist (sleep/disconnect must not invent state). -// ────────────────────────────────────────────────────────────────────────── -describe('parity §1: empty host snapshot does not fabricate tabs', () => { - it('an empty snapshot with existing local terminals does not add a phantom tab', () => { - const prior = stateWithLocalTerminals([localTerminal('host-tab-1', 0, true)]) - const patch = applyWebSessionTabsSnapshot( - prior, - makeSnapshot([], { activeTabId: null, activeTabType: null }), - ENV, - NOW + 10 - ) as Partial - // No new fabricated terminal ids beyond what existed. - const order = groupOrder(patch) - for (const id of order) { - expect(id).toBe(toWebTerminalSurfaceTabId('host-tab-1')) - } - }) -}) diff --git a/src/renderer/src/runtime/runtime-host-contact-parity.test.ts b/src/renderer/src/runtime/runtime-host-contact-parity.test.ts index a24e963a1d9..83e152cd899 100644 --- a/src/renderer/src/runtime/runtime-host-contact-parity.test.ts +++ b/src/renderer/src/runtime/runtime-host-contact-parity.test.ts @@ -10,16 +10,6 @@ import { runtimeHostContactFromSnapshot } from '../../../shared/runtime-host-contact' import type { RuntimeStatus } from '../../../shared/runtime-types' -import { - runtimeHostConnectionState, - runtimeHostConnectionStateForEntry, - type RuntimeHostConnectionState -} from './runtime-host-connection-state' - -// This file exists to prove the contact introduced here changes nothing. It carries a frozen copy -// of the derivation as it stood before, and asserts the shipping one agrees with it on every -// combination of the inputs it reads. A behaviour change would have to survive the whole -// cross-product to go unnoticed, which is a much harder thing to do by accident than to argue. type Entry = { status: RuntimeStatus | null @@ -27,33 +17,6 @@ type Entry = { snapshot?: RuntimeHostStatusSnapshot } -/** The derivation exactly as it read before `RuntimeHostContact` existed. Do not refactor. */ -function legacyRuntimeHostConnectionStateForEntry( - entry: Entry | null | undefined -): RuntimeHostConnectionState { - const snapshot = entry?.snapshot - if (snapshot) { - if (snapshot.retired || snapshot.verification === 'blocked') { - return 'disconnected' - } - if (snapshot.transport === 'disconnected') { - return 'reconnecting' - } - if (snapshot.verification === 'checking' && !entry?.status) { - return 'checking' - } - if (snapshot.transport === 'ready' && snapshot.verification !== 'verified') { - return 'runtime-unavailable' - } - } - return runtimeHostConnectionState({ - hasStatusEntry: Boolean(entry), - status: entry?.status ?? null, - ...(snapshot?.transport === 'connecting' ? { transportStatus: 'checking' as const } : {}), - remoteControl: entry?.remoteControl ?? entry?.status?.remoteControl ?? null - }) -} - const VERIFICATIONS = ['checking', 'verified', 'unavailable', 'blocked'] as const const TRANSPORTS = ['unknown', 'connecting', 'ready', 'disconnected'] as const const RETIRED = [false, true] as const @@ -138,38 +101,7 @@ function* everySnapshotEntry(): Generator<{ label: string; entry: Entry }> { } } -describe('the host contact changes no verdict', () => { - it('agrees with the frozen derivation on every snapshot combination', () => { - const cases = [...everySnapshotEntry()] - // Guard against the enumeration silently collapsing: 4 x 4 x 2 x 2 x 6. - expect(cases).toHaveLength(384) - const disagreements = cases - .map(({ label, entry }) => ({ - label, - now: runtimeHostConnectionStateForEntry(entry), - before: legacyRuntimeHostConnectionStateForEntry(entry) - })) - .filter(({ now, before }) => now !== before) - expect(disagreements).toEqual([]) - }) - - it('agrees for entries that carry no snapshot at all', () => { - const entries: (Entry | null | undefined)[] = [ - null, - undefined, - { status: null }, - { status: makeStatus() }, - { status: null, remoteControl: makeRemoteControl('closed') }, - { status: null, remoteControl: makeRemoteControl('ready') }, - { status: makeStatus({ remoteControl: makeRemoteControl('reconnecting') }) } - ] - for (const entry of entries) { - expect(runtimeHostConnectionStateForEntry(entry)).toBe( - legacyRuntimeHostConnectionStateForEntry(entry) - ) - } - }) - +describe('the revoked predicate and the contact verdict stay in step', () => { it('keeps the revoked predicate and the contact verdict in step', () => { for (const { label, entry } of everySnapshotEntry()) { expect( diff --git a/src/renderer/src/store/slices/github-pr-comments.test.ts b/src/renderer/src/store/slices/github-pr-comments.test.ts index 8432f944fd4..b78fe1589f6 100644 --- a/src/renderer/src/store/slices/github-pr-comments.test.ts +++ b/src/renderer/src/store/slices/github-pr-comments.test.ts @@ -234,62 +234,6 @@ describe('createGitHubSlice.fetchPRComments', () => { vi.useRealTimers() } }) - - it('preserves cached checks when the checks IPC fails', async () => { - const store = createTestStore() - const repoPath = '/repo' - const branch = 'feature/test' - const checksCacheKey = `${repoPath}::pr-checks::12` - const cachedChecks = [ - { name: 'build', status: 'completed', conclusion: 'failure', url: null } as const - ] - - store.setState({ - checksCache: { - [checksCacheKey]: { - data: cachedChecks, - fetchedAt: 1, - headSha: 'abc123head' - } - } - } as unknown as Partial) - mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited')) - - await expect( - store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true }) - ).resolves.toEqual(cachedChecks) - - expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks) - expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1) - }) - - it('does not return cached checks for a different requested head SHA after IPC failure', async () => { - const store = createTestStore() - const repoPath = '/repo' - const branch = 'feature/test' - const checksCacheKey = `${repoPath}::pr-checks::12` - const oldHeadChecks = [ - { name: 'build', status: 'completed', conclusion: 'success', url: null } as const - ] - - store.setState({ - checksCache: { - [checksCacheKey]: { - data: oldHeadChecks, - fetchedAt: 1, - headSha: 'old-head' - } - } - } as unknown as Partial) - mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited')) - - await expect( - store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true }) - ).resolves.toEqual([]) - - expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks) - expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head') - }) }) describe('createGitHubSlice PR comment mutations', () => { diff --git a/src/renderer/src/store/slices/github-work-items-query-bounds.test.ts b/src/renderer/src/store/slices/github-work-items-query-bounds.test.ts deleted file mode 100644 index 40b89fdd4d5..00000000000 --- a/src/renderer/src/store/slices/github-work-items-query-bounds.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - GITHUB_WORK_ITEMS_QUERY_MAX_BYTES, - isGitHubWorkItemsQueryTooLarge -} from './github-work-items-query-bounds' - -describe('GitHub work item query bounds', () => { - it('allows normal GitHub search syntax', () => { - expect(isGitHubWorkItemsQueryTooLarge('is:pr is:open label:bug')).toBe(false) - }) - - it('rejects oversized pasted work item queries', () => { - expect(isGitHubWorkItemsQueryTooLarge('x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES))).toBe( - false - ) - expect(isGitHubWorkItemsQueryTooLarge('x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES + 1))).toBe( - true - ) - }) - - it('measures UTF-8 bytes for non-ASCII query text', () => { - expect(isGitHubWorkItemsQueryTooLarge(String.fromCodePoint(0x1f600).repeat(3_000))).toBe(true) - }) -}) diff --git a/src/renderer/src/store/slices/linear-invalidation.test.ts b/src/renderer/src/store/slices/linear-invalidation.test.ts index febf6bc222f..59c0bd666c6 100644 --- a/src/renderer/src/store/slices/linear-invalidation.test.ts +++ b/src/renderer/src/store/slices/linear-invalidation.test.ts @@ -136,35 +136,6 @@ describe('createLinearSlice invalidation', () => { ).toMatchObject({ items: [{ id: 'LIST' }] }) }) - it('caches teams by workspace and dedupes fresh reads', async () => { - const store = createTestStore() - linearListTeams.mockResolvedValueOnce([team('team-1')]) - - await expect(store.getState().listLinearTeams('workspace-1')).resolves.toMatchObject([ - { id: 'team-1' } - ]) - await expect(store.getState().listLinearTeams('workspace-1')).resolves.toMatchObject([ - { id: 'team-1' } - ]) - - expect(linearListTeams).toHaveBeenCalledTimes(1) - expect(store.getState().getCachedLinearTeams('workspace-1')).toMatchObject([{ id: 'team-1' }]) - }) - - it('patches issue-cache entries keyed by workspace-qualified ids', () => { - const store = createTestStore() - store.setState({ - linearIssueCache: { - 'workspace-1::issue-id': { data: issue('issue-id'), fetchedAt: Date.now() } - } - }) - - store.getState().patchLinearIssue('issue-id', { title: 'Updated' }) - - expect(store.getState().linearIssueCache['workspace-1::issue-id'].data?.title).toBe('Updated') - expect(store.getState().linearIssueCache['workspace-1::issue-id'].fetchedAt).toBe(0) - }) - it('refreshing a linked Linear issue invalidates stale issue collection caches', async () => { const store = createTestStore() linearGetIssue.mockResolvedValueOnce(issue('issue-id')) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index 43afafe717f..703ff483306 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import fs from 'node:fs' import type { Repo } from '../../../../shared/repo-types' import { reconcileFetchedRepos, reuseEqualRecordMap } from './repo-identity-reconcile' @@ -119,8 +118,6 @@ describe('reconcileFetchedRepos', () => { expect(result).toBe(previous) expect(result.every((repo, index) => repo === previous[index])).toBe(true) - const source = fs.readFileSync(new URL('./repo-identity-reconcile.ts', import.meta.url), 'utf8') - expect(source).not.toMatch(/previous\.find|next\.find|findIndex/) }) }) diff --git a/src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts b/src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts deleted file mode 100644 index 63c3de2e708..00000000000 --- a/src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// The store's host-status entry is one shape, and every reader of the map has to accept exactly -// it. A narrower local copy still typechecks against the store — the fields it is missing are all -// optional — so it freezes at the vintage it was written at and never fails. #17710 added -// `remoteControl` and #20003 added `snapshot`; the copies these assertions cover were not updated -// either time. Like the other typed-contract tests, they fail at typecheck, not at runtime. -import { describe, expectTypeOf, it } from 'vitest' -import type { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' -import type { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' -import type { getRuntimeAutomationAvailability } from '@/components/automations/automation-target-availability' -import type { getRuntimeSourceHostAvailability } from '@/components/automations/automation-source-context' -import type { AutomationListLocalRowProps } from '@/components/automations/AutomationListLocalRow' -import type { AutomationsListPanelProps } from '@/components/automations/AutomationsListPanel' -import type { RuntimeEnvironmentStatus } from './runtime-status-types' - -// Compare the entry each reader accepts rather than the surrounding map, so a mismatch names the -// field that drifted instead of every ReadonlyMap method signature. -type AcceptedEntry = NonNullable extends ReadonlyMap ? TEntry : never - -type RegistryEntry = AcceptedEntry< - Parameters[0]['runtimeStatusByEnvironmentId'] -> -type SidebarEntry = AcceptedEntry< - Parameters[0]['runtimeStatusByEnvironmentId'] -> -type AutomationEntry = AcceptedEntry[1]> -type SourceContextEntry = AcceptedEntry[1]> -type LocalRowEntry = AcceptedEntry -type ListPanelEntry = AcceptedEntry - -describe('every reader of the runtime host status map accepts the store entry', () => { - it('holds for the execution-host registry', () => { - expectTypeOf().toEqualTypeOf() - }) - - it('holds for the sidebar host options', () => { - expectTypeOf().toEqualTypeOf() - }) - - it('holds for runtime automation availability', () => { - expectTypeOf().toEqualTypeOf() - }) - - // These three only forward the map, which is exactly how a stale copy survives: nothing they - // do with it can fail, so the drift surfaces in whatever they hand it to. - it('holds for the automation source-context reader', () => { - expectTypeOf().toEqualTypeOf() - }) - - it('holds for the automation list row', () => { - expectTypeOf().toEqualTypeOf() - }) - - it('holds for the automations list panel', () => { - expectTypeOf().toEqualTypeOf() - }) -}) diff --git a/src/renderer/src/web/web-runtime-client-export-parity.test.ts b/src/renderer/src/web/web-runtime-client-export-parity.test.ts deleted file mode 100644 index 6ba87eaad36..00000000000 --- a/src/renderer/src/web/web-runtime-client-export-parity.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, expectTypeOf, it } from 'vitest' -import type { WebPairingOffer } from './web-pairing' -import * as WebClient from './web-runtime-client' - -it('keeps the paired-web client public export surface exact', () => { - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf< - [ - pairing: WebPairingOffer, - options?: ConstructorParameters[1] - ] - >() - expectTypeOf().toEqualTypeOf< - 'call' | 'close' | 'subscribe' | 'statusOwner' - >() - expect(Object.keys(WebClient)).toEqual(['WebRuntimeClient']) -}) diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index e5c1d362112..f7d78c9f84b 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -23,14 +23,6 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')) -/** - * The true count of files importing child_process directly. - * - * May only ever be DECREASED, and only by migrating a file off - * `node:child_process`. Raising it is never the fix. - */ -const DIRECT_IMPORTER_PIN = 149 - const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ @@ -118,23 +110,4 @@ describe('child_process import boundary', () => { const stale = CHILD_PROCESS_IMPORT_ALLOWLIST.filter((path) => !offenders.includes(path)) expect(stale, 'Allowlist entry no longer imports child_process — delete the line.').toEqual([]) }) - - it('holds the offender count at the pin', () => { - // Bounding by the allowlist's own length proves nothing: the two move - // together, so a swap (one file migrated off, one new file added with its - // entry) kept the bound satisfied. The pin is a literal for that reason. - expect( - offenders.length, - `${offenders.length} files import child_process directly; the pin is ${DIRECT_IMPORTER_PIN}. ` + - 'Never raise the pin -- migrate the file to runProcess/spawnProcess from ' + - 'src/shared/child-process instead.' - ).toBeLessThanOrEqual(DIRECT_IMPORTER_PIN) - // A pin left above reality is how a ratchet rots: it re-opens room for the - // next direct import to land for free. - expect( - offenders.length, - `Only ${offenders.length} files import child_process directly. Lower DIRECT_IMPORTER_PIN to ` + - `${offenders.length} to keep the ground you just took.` - ).toBeGreaterThanOrEqual(DIRECT_IMPORTER_PIN) - }) }) diff --git a/src/shared/emoji-shortcode-catalog.lazy.test.ts b/src/shared/emoji-shortcode-catalog.lazy.test.ts index dc05160696b..f46e0e742aa 100644 --- a/src/shared/emoji-shortcode-catalog.lazy.test.ts +++ b/src/shared/emoji-shortcode-catalog.lazy.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' @@ -29,37 +27,4 @@ describe('emoji shortcode catalog laziness', () => { expect(catalog.replaceKnownEmojiWithShortcodes('ship \u{1F389}')).toBe('ship party ') expect(catalog.isEmojiShortcodeCatalogBuiltForTest()).toBe(true) }) - - it('leaves the main-process worktree namer importing only the deferred entry points', () => { - // A cross-project import would drag src/main into the shared tsconfig, so assert on source. - const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8') - const catalogImport = worktreeLogic.match( - /import \{([^}]*)\} from '[^']*emoji-shortcode-catalog'/ - ) - - expect(catalogImport?.[1].split(',').map((name) => name.trim())).toEqual([ - 'replaceKnownEmojiWithShortcodes', - 'setEmojiShortcodeDatasetLoader' - ]) - }) - - it('keeps the catalog build out of module scope', () => { - const sharedSource = readFileSync(join(__dirname, 'emoji-shortcode-catalog.ts'), 'utf8') - - // A module-scope `const X = ` is the regression this guards. - expect(sharedSource).not.toMatch(/^const \w+ = Object\.entries\(/m) - expect(sharedSource).not.toMatch(/^const \w+ = new (?:Map|Intl\.Segmenter)\(/m) - expect(sharedSource).toContain('function loadCatalog()') - }) - - it('keeps the 166 KB dataset off every module main statically imports', () => { - const sharedSource = readFileSync(join(__dirname, 'emoji-shortcode-catalog.ts'), 'utf8') - const worktreeLogic = readFileSync(join(__dirname, '../main/ipc/worktree-logic.ts'), 'utf8') - - // A static `emojibase-data` import anywhere main reaches inlines the dataset into - // out/main/index.js and JSON.parses it on every launch. - const staticDatasetImport = /\bfrom '[^']*emojibase-data[^']*'/ - expect(sharedSource).not.toMatch(staticDatasetImport) - expect(worktreeLogic).not.toMatch(staticDatasetImport) - }) }) diff --git a/src/shared/network/manual-address.test.ts b/src/shared/network/manual-address.test.ts index 6ef59536129..0260eeb7d72 100644 --- a/src/shared/network/manual-address.test.ts +++ b/src/shared/network/manual-address.test.ts @@ -1,16 +1,8 @@ import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' import { parseManualNetworkAddress } from './manual-address' import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits' describe('parseManualNetworkAddress', () => { - it('keeps renderer validation off the Zod schema import path', () => { - const source = readFileSync(resolve('src/shared/network/manual-address.ts'), 'utf8') - expect(source).not.toContain('mobile-relay-pairing-offer') - expect(source).not.toContain("from 'zod'") - }) - describe('IPv4', () => { it('accepts canonical IPv4', () => { expect(parseManualNetworkAddress('192.168.1.24')).toEqual({ diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts deleted file mode 100644 index 028207cdfb9..00000000000 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' -import { - blankStringContents, - blankStringContentsDesynced, - isTestFile, - stripComments -} from './source-scan/source-tree-scan' - -const HELPERS = [ - 'getAgentLabel', - 'isClaudeAgent', - 'titleHasAgentName', - 'buildAgentNameRe', - 'resolveTerminalTitleAgentType', - 'resolveExplicitTerminalTitleAgentType', - 'resolveCommittedTitleAgentType', - 'resolvePaneAgentOwner', - 'resolveCompatibleAgentTypeForOwner', - 'classifyTitleActivity', - 'detectAgentStatusFromTitle', - 'resolveAgentTypeFromTerminalTitle', - 'resolvePaneAgentIdentity', - 'resolveCanonicalPaneAgentIdentity', - 'resolvePublishedPaneAgentIdentity' -] as const - -const TEST_SUPPORT_PATHS = new Set([ - 'src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts' -]) - -type Helper = (typeof HELPERS)[number] -type InventoryPath = string | readonly [path: string, occurrences: number] -type Classification = - | 'parser-implementation' - | 'activity-only' - | 'enum-formatter' - | 'evidence-producer' - | 'identity-consumer' - | 'action-consumer' - -type InventoryGroup = { - helper: Helper - classification: Classification - paths: readonly InventoryPath[] -} - -const INVENTORY: readonly InventoryGroup[] = [ - { - helper: 'getAgentLabel', - classification: 'enum-formatter', - paths: [ - [ - 'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx', - 2 - ], - ['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2], - 'src/renderer/src/components/automations/automation-draft-model.ts', - ['src/renderer/src/components/automations/automation-list-search-rows.ts', 2], - ['src/renderer/src/components/settings/NativeChatSupportedAgents.tsx', 2], - ['src/renderer/src/components/settings/QuickCommandsList.tsx', 2], - ['src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx', 2], - ['src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx', 2], - 'src/renderer/src/lib/agent-catalog.tsx', - ['src/renderer/src/lib/launch-agent-session-continuation.ts', 3], - ['src/renderer/src/lib/orchestration-skill-coverage.ts', 2] - ] - }, - { - helper: 'getAgentLabel', - classification: 'activity-only', - paths: [['src/renderer/src/lib/pane-agent-evidence.ts', 2]] - }, - { - helper: 'getAgentLabel', - classification: 'parser-implementation', - paths: [ - 'src/renderer/src/lib/agent-status.ts', - 'src/shared/agent-detection.ts', - 'src/shared/agent-title-identity.ts', - ['src/shared/agent-title-owner.ts', 2], - ['src/shared/terminal-title-agent-type.ts', 2] - ] - }, - { - helper: 'isClaudeAgent', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/cache-timer-seeding.ts', 2], - ['src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts', 2], - ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2], - ['src/renderer/src/store/terminals/terminal-ephemeral-state.ts', 2] - ] - }, - { - helper: 'isClaudeAgent', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', 2] - ] - }, - { - helper: 'isClaudeAgent', - classification: 'parser-implementation', - paths: [ - 'src/renderer/src/lib/agent-status.ts', - 'src/shared/agent-detection.ts', - ['src/shared/agent-title-identity.ts', 2], - ['src/shared/terminal-title-agent-type.ts', 2] - ] - }, - { - helper: 'titleHasAgentName', - classification: 'parser-implementation', - paths: [ - 'src/shared/agent-detection.ts', - 'src/shared/agent-name-token-match.ts', - ['src/shared/agent-title-core.ts', 4], - ['src/shared/agent-title-evidence.ts', 2], - ['src/shared/agent-title-identity.ts', 11], - ['src/shared/terminal-title-agent-type.ts', 15] - ] - }, - { - helper: 'titleHasAgentName', - classification: 'evidence-producer', - paths: [['src/renderer/src/hooks/ipc-events/agent-status-routing.ts', 2]] - }, - { - helper: 'buildAgentNameRe', - classification: 'parser-implementation', - paths: [['src/shared/agent-name-token-match.ts', 2]] - }, - { - helper: 'resolveTerminalTitleAgentType', - classification: 'identity-consumer', - paths: [['src/renderer/src/lib/notes-send-agent-targets.ts', 2]] - }, - { - helper: 'resolveTerminalTitleAgentType', - classification: 'parser-implementation', - paths: [['src/shared/terminal-title-agent-type.ts', 2]] - }, - { - helper: 'resolveExplicitTerminalTitleAgentType', - classification: 'identity-consumer', - paths: [ - ['mobile/src/session/mobile-terminal-tab-agent.ts', 2], - ['src/main/runtime/tui-idle-evidence.ts', 2], - ['src/renderer/src/lib/open-tab-occupant-agent.ts', 2], - ['src/renderer/src/lib/use-tab-agent.ts', 3] - ] - }, - { - helper: 'resolveExplicitTerminalTitleAgentType', - classification: 'parser-implementation', - paths: [ - ['src/renderer/src/lib/pane-agent-evidence.ts', 2], - ['src/renderer/src/lib/tab-agent-from-signals.ts', 2], - 'src/shared/terminal-title-agent-type.ts' - ] - }, - { - helper: 'resolveCommittedTitleAgentType', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.ts', 3], - ['src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts', 2], - ['src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts', 2], - ['src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.ts', 2], - ['src/renderer/src/components/terminal-pane/use-notification-dispatch.ts', 2] - ] - }, - { - helper: 'resolveCommittedTitleAgentType', - classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 4], - ['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2] - ] - }, - { - helper: 'resolveCommittedTitleAgentType', - classification: 'parser-implementation', - paths: ['src/renderer/src/lib/pane-agent-evidence.ts'] - }, - { - helper: 'resolveCommittedTitleAgentType', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', 2] - ] - }, - { - helper: 'resolvePaneAgentOwner', - classification: 'parser-implementation', - paths: ['src/shared/pane-agent-owner.ts'] - }, - { - helper: 'resolvePaneAgentOwner', - classification: 'evidence-producer', - paths: [['src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts', 2]] - }, - { - helper: 'resolvePaneAgentOwner', - classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], - ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2], - ['src/renderer/src/lib/use-tab-agent.ts', 2] - ] - }, - { - helper: 'resolveCompatibleAgentTypeForOwner', - classification: 'parser-implementation', - paths: [['src/shared/agent-title-owner.ts', 2]] - }, - { - helper: 'resolveCompatibleAgentTypeForOwner', - classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2], - ['src/main/runtime/runtime-mobile-session-projection.ts', 3], - ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], - ['src/renderer/src/lib/tab-agent-from-signals.ts', 2], - ['src/renderer/src/lib/use-tab-agent.ts', 2] - ] - }, - { - helper: 'resolveCompatibleAgentTypeForOwner', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2], - [ - 'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', - 3 - ], - ['src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts', 3] - ] - }, - { - helper: 'resolveCompatibleAgentTypeForOwner', - classification: 'evidence-producer', - paths: [ - ['src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts', 2], - ['src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts', 2] - ] - }, - { - helper: 'classifyTitleActivity', - classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/sidebar/smart-attention.ts', 3], - ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], - ['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3], - ['src/renderer/src/lib/active-agent-note-target.ts', 2], - ['src/renderer/src/lib/worktree-status.ts', 3], - ['src/renderer/src/store/slices/terminal-helpers.ts', 2] - ] - }, - { - helper: 'classifyTitleActivity', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/cache-timer-seeding.ts', 2], - ['src/renderer/src/lib/agent-ready-wait.ts', 2], - ['src/renderer/src/store/terminals/terminal-ephemeral-state.ts', 2] - ] - }, - { - helper: 'classifyTitleActivity', - classification: 'activity-only', - paths: [ - ['src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts', 3], - ['src/renderer/src/store/terminals/terminal-tab-presentation.ts', 4] - ] - }, - { - helper: 'classifyTitleActivity', - classification: 'evidence-producer', - paths: [ - ['src/renderer/src/lib/agent-send-title-status.ts', 2], - ['src/renderer/src/lib/agent-status-terminal-title.ts', 2] - ] - }, - { - helper: 'classifyTitleActivity', - classification: 'parser-implementation', - paths: [ - ['src/renderer/src/lib/agent-status.ts', 5], - 'src/renderer/src/lib/pane-agent-evidence.ts' - ] - }, - { - helper: 'detectAgentStatusFromTitle', - classification: 'evidence-producer', - paths: [ - ['src/main/runtime/orca-runtime-apply-tracked-pty-title.ts', 2], - ['src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts', 2], - ['src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts', 2], - ['src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts', 2], - ['src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts', 2], - ['src/main/runtime/runtime-terminal-agent-status-query.ts', 3], - ['src/main/runtime/runtime-worktree-status-projection.ts', 4], - ['src/main/runtime/terminal-wait-detection.ts', 2], - ['src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts', 2], - ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 4], - ['src/renderer/src/components/terminal-pane/pty-output-title-observer.ts', 2], - ['src/shared/terminal-output-side-effects.ts', 3], - ['src/shared/tui-agent-rest-signal.ts', 2] - ] - }, - { - helper: 'detectAgentStatusFromTitle', - classification: 'action-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2], - [ - 'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts', - 3 - ], - ['src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts', 3] - ] - }, - { - helper: 'detectAgentStatusFromTitle', - classification: 'parser-implementation', - paths: [ - ['src/renderer/src/components/terminal-pane/title-agent-identity.ts', 2], - 'src/renderer/src/lib/agent-status.ts', - ['src/renderer/src/lib/pane-agent-evidence.ts', 3], - ['src/shared/agent-decorative-title-signature.ts', 2], - 'src/shared/agent-detection.ts', - ['src/shared/agent-title-owner.ts', 2], - ['src/shared/agent-title-status.ts', 6] - ] - }, - { - helper: 'resolveAgentTypeFromTerminalTitle', - classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2], - 'src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', - ['src/renderer/src/lib/worktree-status.ts', 2] - ] - }, - { - helper: 'resolvePaneAgentIdentity', - classification: 'parser-implementation', - paths: ['src/shared/pane-agent-identity-resolver.ts'] - }, - { - helper: 'resolvePaneAgentIdentity', - classification: 'identity-consumer', - paths: [['src/shared/published-pane-agent-identity.ts', 2]] - }, - { - helper: 'resolveCanonicalPaneAgentIdentity', - classification: 'parser-implementation', - paths: ['src/shared/pane-agent-identity-adapter.ts'] - }, - { - helper: 'resolveCanonicalPaneAgentIdentity', - classification: 'identity-consumer', - paths: [['src/shared/agent-status-identity.ts', 2]] - }, - { - helper: 'resolveCanonicalPaneAgentIdentity', - classification: 'identity-consumer', - paths: [['src/shared/terminal-title-agent-type.ts', 2]] - }, - { - helper: 'resolvePublishedPaneAgentIdentity', - classification: 'parser-implementation', - paths: [ - 'src/shared/published-pane-agent-identity.ts', - ['src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts', 2] - ] - } -] - -const DIRECT_SINGLE_SOURCE_SURFACES: readonly { - path: string - classification: Classification - marker: string -}[] = [ - { - path: 'src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts', - classification: 'identity-consumer', - marker: 'resolveGeminiCompatFallback' - }, - { - path: 'src/renderer/src/components/terminal-pane/terminal-title-evidence.ts', - classification: 'identity-consumer', - marker: 'resolvePaneTitleDecision' - }, - { - path: 'src/renderer/src/components/terminal/terminal-close-copy-kind.ts', - classification: 'identity-consumer', - marker: 'resolveLeafCloseCopyKind' - }, - { - path: 'src/main/runtime/orchestration/mailbox-pointer-stage.ts', - classification: 'action-consumer', - marker: 'isCursorAgentTitle' - }, - { - path: 'src/main/providers/local-pty-session-activation.ts', - classification: 'action-consumer', - marker: 'launchAgent' - }, - { - path: 'src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts', - classification: 'action-consumer', - marker: 'sendStartupDraftPaste' - }, - { - path: 'src/renderer/src/lib/active-agent-note-send.ts', - classification: 'action-consumer', - marker: 'sendNotesToActiveAgentSession' - }, - { - path: 'src/renderer/src/components/native-chat/native-chat-runtime-send.ts', - classification: 'action-consumer', - marker: 'sendNativeChatMessage' - }, - { - path: 'mobile/src/session/mobile-native-chat-send.ts', - classification: 'action-consumer', - marker: 'sendMobileNativeChatMessageWithOutcome' - }, - { - path: 'mobile/src/session/mobile-native-chat-image-send.ts', - classification: 'action-consumer', - marker: 'pasteMobileNativeChatImagePaths' - } -] - -describe('pane agent identity inventory ratchet', () => { - it('classifies every legacy helper definition, import, and callsite in src and mobile/src', async () => { - const files = await glob(['src/**/*.{ts,tsx}', 'mobile/src/**/*.{ts,tsx}'], { - ignore: ['**/*.test.*', '**/*.spec.*'] - }) - const actual: { helper: Helper; path: string; occurrences: number }[] = [] - for (const path of files) { - if (isTestFile(path) || TEST_SUPPORT_PATHS.has(path)) { - continue - } - const rawSource = readFileSync(join(process.cwd(), path), 'utf8') - if (!HELPERS.some((helper) => rawSource.includes(helper))) { - continue - } - const decommentedSource = stripComments(rawSource) - if (blankStringContentsDesynced(decommentedSource)) { - throw new Error(`String scanner desynchronized while inventorying ${path}`) - } - const source = blankStringContents(decommentedSource) - for (const helper of HELPERS) { - const occurrences = source.match(new RegExp(`\\b${helper}\\b`, 'g'))?.length ?? 0 - if (occurrences > 0) { - actual.push({ helper, path, occurrences }) - } - } - } - const expected = INVENTORY.flatMap(({ helper, paths }) => - paths.map((site) => { - const [path, occurrences] = typeof site === 'string' ? [site, 1] : site - return { helper, path, occurrences } - }) - ) - const byHelperAndPath = (left: (typeof actual)[number], right: (typeof actual)[number]) => - left.helper.localeCompare(right.helper) || left.path.localeCompare(right.path) - expect(actual.sort(byHelperAndPath)).toEqual(expected.sort(byHelperAndPath)) - }, 30_000) - - it('pins direct single-source identity and action branches outside named helpers', () => { - for (const site of DIRECT_SINGLE_SOURCE_SURFACES) { - const source = stripComments(readFileSync(join(process.cwd(), site.path), 'utf8')) - expect({ - path: site.path, - classification: site.classification, - hasMarker: source.includes(site.marker) - }).toEqual({ path: site.path, classification: site.classification, hasMarker: true }) - } - }) -}) diff --git a/src/shared/pane-agent-identity-surface-inventory.test.ts b/src/shared/pane-agent-identity-surface-inventory.test.ts deleted file mode 100644 index 77d9edb3b84..00000000000 --- a/src/shared/pane-agent-identity-surface-inventory.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { glob } from 'tinyglobby' -import { isTestFile, stripComments } from './source-scan/source-tree-scan' - -/** - * Surface half of the identity inventory ratchet: every consumer decision point from the closed - * 65-row inventory (rows 32–65 — the direct title/native-chat selectors, tab projections, mobile - * sync graph, lifecycle selectors, status/OSC ingress, worktree status, attention, and - * title-reset paths) is pinned to a marker symbol in its file. The helper-name census - * (`pane-agent-identity-inventory.test.ts`) is necessary but not sufficient — these files reach - * identity through direct reads a name census cannot see. Moving or renaming a marker means the - * inventory row must be re-classified, deliberately, before review. - */ - -type SurfaceRow = { - /** Row number in the closed consumer inventory. */ - row: number - path: string - marker: string -} - -const SURFACE_ROWS: readonly SurfaceRow[] = [ - { - row: 32, - path: 'src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', - marker: 'resolveNativeChatLeafTitleAgent' - }, - { - row: 32, - path: 'src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts', - marker: 'resolveNativeChatLeafTitleAgent' - }, - { - row: 33, - path: 'src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', - marker: 'installPaneAgentIdentity' - }, - { row: 34, path: 'src/main/runtime/orchestration/groups.ts', marker: 'terminalIsAgent' }, - { - row: 35, - path: 'src/renderer/src/lib/active-agent-note-target.ts', - marker: 'getActiveTerminalNoteTarget' - }, - { - row: 36, - path: 'src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts', - marker: 'resolveProtectedMultilinePasteOptionsForPane' - }, - { - row: 37, - path: 'src/renderer/src/components/terminal-pane/command-code-output-ownership.ts', - marker: 'canCommandCodeOutputOwnPane' - }, - { row: 38, path: 'src/renderer/src/lib/agent-ready-wait.ts', marker: 'waitForAgentReady' }, - { - row: 39, - path: 'src/renderer/src/lib/agent-paste-draft.ts', - marker: 'getSettingsForAgentTabRuntimeOwner' - }, - { - row: 40, - path: 'src/renderer/src/lib/agent-followup-delivery.ts', - marker: 'sendFollowupPromptWhenAgentReady' - }, - { - row: 41, - path: 'src/renderer/src/lib/codex-session-restart.ts', - marker: 'markLiveCodexSessionsForRestart' - }, - { - row: 41, - path: 'src/renderer/src/lib/codex-pane-restart-eligibility.ts', - marker: 'isCodexForegroundProcess' - }, - { - row: 42, - path: 'src/renderer/src/components/native-chat/native-chat-availability.ts', - marker: 'canToggleNativeChat' - }, - { - row: 43, - path: 'src/renderer/src/components/native-chat/native-chat-pane-resolution.ts', - marker: 'resolveNativeChatSession' - }, - { - row: 44, - path: 'src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts', - marker: 'canContinueAgentSessionInNewSession' - }, - { - row: 45, - path: 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', - marker: 'prepareAgentSessionForkFromPane' - }, - { - row: 46, - path: 'src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts', - marker: 'isPlainEscapeKeyEvent' - }, - { - row: 46, - path: 'src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts', - marker: 'inferQuestionAnsweredFromCurrentStatus' - }, - { - row: 47, - path: 'src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts', - marker: 'resolvePaneKeyboardProtocolAgent' - }, - { - row: 47, - path: 'src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts', - marker: 'resolvePaneKeyboardProtocolAgent' - }, - { - row: 48, - path: 'src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts', - marker: 'selectTabAgentTypesByTabId' - }, - { - row: 49, - path: 'src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts', - marker: 'createTerminalTabAgentTypeSelector' - }, - { - row: 50, - path: 'src/renderer/src/lib/tab-agent-status-index.ts', - marker: 'selectLiveTabAgentPanes' - }, - { - row: 51, - path: 'src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts', - marker: 'resolveTerminalTabActivityStatus' - }, - { - row: 52, - path: 'src/renderer/src/lib/workspace-tab-agent-metadata.ts', - marker: 'maxAgentActivityAt' - }, - { - row: 52, - path: 'src/renderer/src/lib/workspace-tab-palette-entry-builder.ts', - marker: 'buildSearchableWorkspaceTabEntries' - }, - { - row: 53, - path: 'src/renderer/src/lib/running-agent-targets.ts', - marker: 'deriveRunningAgentSendTargets' - }, - { - row: 54, - path: 'src/renderer/src/runtime/sync-runtime-graph.ts', - marker: 'buildMobileSessionTabSnapshots' - }, - { - row: 55, - path: 'src/renderer/src/lib/agent-hibernation-pane-eligibility.ts', - marker: 'toRuntimePtyId' - }, - { - row: 56, - path: 'src/renderer/src/lib/resume-sleeping-agent-session.ts', - marker: 'resumeSleepingAgentSessionsForWorktree' - }, - { - row: 57, - path: 'src/renderer/src/lib/automation-session-reuse.ts', - marker: 'findReusableAutomationSession' - }, - { - row: 58, - path: 'src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts', - marker: 'bindBuildColdRestoreAgentResumeStartup' - }, - { - row: 59, - path: 'src/main/agent-hooks/server/server-authority-evidence.ts', - marker: 'recordCurrentAuthorityObservation' - }, - { - row: 59, - path: 'src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts', - marker: 'resolvePaneAgentIdentityField' - }, - { - row: 59, - path: 'src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts', - marker: 'createAgentStatusEventApplicator' - }, - { - row: 59, - path: 'src/renderer/src/store/slices/agent-status-authority-actions.ts', - marker: 'transferAgentPaneAuthority' - }, - { - row: 59, - path: 'src/renderer/src/store/slices/pane-foreground-agent.ts', - marker: 'createPaneForegroundAgentSlice' - }, - { - row: 59, - path: 'src/renderer/src/hooks/ipc-events/agent-status-routing.ts', - marker: 'isAgentStatusForRecentlyClosedTab' - }, - { - row: 60, - path: 'src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts', - marker: 'installTitleSpawnBell' - }, - { row: 61, path: 'src/renderer/src/lib/worktree-status.ts', marker: 'getWorktreeStatus' }, - { - row: 62, - path: 'src/main/runtime/runtime-worktree-status-projection.ts', - marker: 'getLeafWorktreeStatus' - }, - { - row: 63, - path: 'src/renderer/src/components/sidebar/smart-attention.ts', - marker: 'buildAttentionByWorktree' - }, - { - row: 64, - path: 'src/renderer/src/components/status-bar/workspace-space-presentation.ts', - marker: 'countWorkspaceSpaceActiveAgents' - }, - { row: 65, path: 'src/renderer/src/store/slices/terminal-helpers.ts', marker: 'getResetTitle' }, - { - row: 6, - path: 'src/renderer/src/runtime/web-session-tabs-sync.ts', - marker: 'applyWebSessionTabs' - } -] - -describe('pane agent identity surface inventory (rows 6, 32–65)', () => { - it('every pinned surface still carries its marker symbol', () => { - for (const row of SURFACE_ROWS) { - const source = stripComments(readFileSync(join(process.cwd(), row.path), 'utf8')) - expect({ row: row.row, path: row.path, hasMarker: source.includes(row.marker) }).toEqual({ - row: row.row, - path: row.path, - hasMarker: true - }) - } - }) -}) - -/** - * Identity-observation rebind audit. Advancing a pane incarnation without a positive replacement - * proof is how a legitimate reclaim and a stale-hook bug get conflated (see - * `PaneReplacementProof` in pane-agent-identity-adapter.ts). Every existing sequencer `rebind` - * call is pinned here by file and count: today they are the retired-pane `restart` disposition - * (three ingress paths) and the renderer pane-key transfer. Adding a rebind call, or changing - * these, requires updating this audit — and per the migration plan, a `replacementProof`. - */ -const IDENTITY_SEQUENCER_REBIND_RE = /\b(?:observations|rendererAgentStatusObservations)\.rebind\(/g - -const EXPECTED_REBIND_SITES: readonly (readonly [path: string, occurrences: number])[] = [ - ['src/main/agent-hooks/server/server-ingest-normalization.ts', 1], - ['src/main/agent-hooks/server/server-ingest-remote.ts', 1], - ['src/main/agent-hooks/server/server-lifecycle.ts', 1], - ['src/renderer/src/store/slices/agent-status-authority-actions.ts', 1] -] - -describe('identity observation rebind audit', () => { - it('pins every identity-sequencer rebind call site by file and count', async () => { - const files = await glob(['src/**/*.{ts,tsx}', 'mobile/src/**/*.{ts,tsx}'], { - ignore: ['**/*.test.*', '**/*.spec.*'] - }) - const actual: [string, number][] = [] - for (const path of files.sort()) { - if (isTestFile(path)) { - continue - } - const source = stripComments(readFileSync(join(process.cwd(), path), 'utf8')) - const occurrences = source.match(IDENTITY_SEQUENCER_REBIND_RE)?.length ?? 0 - if (occurrences > 0) { - actual.push([path, occurrences]) - } - } - expect(actual).toEqual(EXPECTED_REBIND_SITES.map((site) => [...site])) - }, 30_000) -}) diff --git a/src/shared/priority-semaphore.test.ts b/src/shared/priority-semaphore.test.ts index aeeacda94ef..5cfbe898bdc 100644 --- a/src/shared/priority-semaphore.test.ts +++ b/src/shared/priority-semaphore.test.ts @@ -2,19 +2,6 @@ import { describe, expect, it } from 'vitest' import { PrioritySemaphore } from './priority-semaphore' describe('PrioritySemaphore', () => { - it('allows up to N concurrent acquires', async () => { - const sem = new PrioritySemaphore(2) - const r1 = await sem.acquire(0) - const r2 = await sem.acquire(0) - - // Both acquired immediately - expect(typeof r1).toBe('function') - expect(typeof r2).toBe('function') - - r1() - r2() - }) - it('blocks when concurrency limit is reached', async () => { const sem = new PrioritySemaphore(1) const r1 = await sem.acquire(0) @@ -129,23 +116,6 @@ describe('PrioritySemaphore', () => { expect(order[0]).toBe('B-high') }) - it('works with zero waiters', async () => { - const sem = new PrioritySemaphore(3) - const r1 = await sem.acquire(0) - r1() - // No crash, no hanging - }) - - it('release is idempotent', async () => { - const sem = new PrioritySemaphore(1) - const r1 = await sem.acquire(0) - r1() - r1() // double release should not throw or corrupt state - - const r2 = await sem.acquire(0) - r2() - }) - it('removes an aborted waiter without consuming the next permit', async () => { const sem = new PrioritySemaphore(1) const release = await sem.acquire(0) diff --git a/src/shared/runtime-client-export-parity.test.ts b/src/shared/runtime-client-export-parity.test.ts deleted file mode 100644 index 2b86d2e6e99..00000000000 --- a/src/shared/runtime-client-export-parity.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import type { PairingOffer } from './pairing' -import type { RuntimeCapability } from './protocol-version' -import * as RemoteClient from './remote-runtime-client' -import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse } from './runtime-rpc-envelope' -import * as Runtime from './runtime-types' - -type RuntimeTypeInventory = [ - Runtime.BrowserBackResult, - Runtime.BrowserCaptureStartResult, - Runtime.BrowserCaptureStopResult, - Runtime.BrowserCheckResult, - Runtime.BrowserClearResult, - Runtime.BrowserClickResult, - Runtime.BrowserConsoleEntry, - Runtime.BrowserConsoleResult, - Runtime.BrowserCookie, - Runtime.BrowserCookieDeleteResult, - Runtime.BrowserCookieGetResult, - Runtime.BrowserCookieSetResult, - Runtime.BrowserDetectedInfo, - Runtime.BrowserDetectedProfileInfo, - Runtime.BrowserDetectProfilesResult, - Runtime.BrowserDragResult, - Runtime.BrowserErrorCode, - Runtime.BrowserEvalResult, - Runtime.BrowserFillResult, - Runtime.BrowserFocusResult, - Runtime.BrowserGeolocationResult, - Runtime.BrowserGotoResult, - Runtime.BrowserHoverResult, - Runtime.BrowserInterceptDisableResult, - Runtime.BrowserInterceptedRequest, - Runtime.BrowserInterceptEnableResult, - Runtime.BrowserKeypressResult, - Runtime.BrowserNetworkEntry, - Runtime.BrowserNetworkLogResult, - Runtime.BrowserPdfResult, - Runtime.BrowserProfileClearDefaultCookiesResult, - Runtime.BrowserProfileCreateResult, - Runtime.BrowserProfileDeleteResult, - Runtime.BrowserProfileImportFromBrowserResult, - Runtime.BrowserProfileListResult, - Runtime.BrowserReloadResult, - Runtime.BrowserScreencastDialogClosedResult, - Runtime.BrowserScreencastDialogResult, - Runtime.BrowserScreencastEndResult, - Runtime.BrowserScreencastErrorResult, - Runtime.BrowserScreencastReadyResult, - Runtime.BrowserScreencastResult, - Runtime.BrowserScreenshotResult, - Runtime.BrowserScrollResult, - Runtime.BrowserSelectAllResult, - Runtime.BrowserSelectResult, - Runtime.BrowserSnapshotRef, - Runtime.BrowserSnapshotResult, - Runtime.BrowserTabCreateResult, - Runtime.BrowserTabCurrentResult, - Runtime.BrowserTabInfo, - Runtime.BrowserTabListResult, - Runtime.BrowserTabProfileCloneResult, - Runtime.BrowserTabProfileShowResult, - Runtime.BrowserTabSetProfileResult, - Runtime.BrowserTabShowResult, - Runtime.BrowserTabSwitchResult, - Runtime.BrowserTypeResult, - Runtime.BrowserUploadResult, - Runtime.BrowserViewportResult, - Runtime.BrowserWaitResult, - Runtime.CliRuntimeState, - Runtime.CliStatusResult, - Runtime.ComputerActionMetadata, - Runtime.ComputerActionResult, - Runtime.ComputerActionVerification, - Runtime.ComputerAppInfo, - Runtime.ComputerAppQuery, - Runtime.ComputerErrorCode, - Runtime.ComputerListAppsResult, - Runtime.ComputerListWindowsResult, - Runtime.ComputerProviderCapabilities, - Runtime.ComputerScreenshotData, - Runtime.ComputerScreenshotMetadata, - Runtime.ComputerScreenshotStatus, - Runtime.ComputerSnapshotData, - Runtime.ComputerSnapshotResult, - Runtime.ComputerWindowInfo, - Runtime.ComputerWindowListWindow, - Runtime.DeviceScope, - Runtime.RuntimeBrowserDriverState, - Runtime.RuntimeBrowserUnavailableReason, - Runtime.RuntimeDegradation, - Runtime.RuntimeDesktopWindowStatus, - Runtime.RuntimeFileListEntry, - Runtime.RuntimeFileListResult, - Runtime.RuntimeFileOpenResult, - Runtime.RuntimeFilePreviewResult, - Runtime.RuntimeFileReadChunkResult, - Runtime.RuntimeFileReadResult, - Runtime.RuntimeGitCheckoutResult, - Runtime.RuntimeGitLocalBranches, - Runtime.RuntimeGraphStatus, - Runtime.RuntimeMarkdownReadTabResult, - Runtime.RuntimeMarkdownSaveTabResult, - Runtime.RuntimeMobileSessionBrowserTab, - Runtime.RuntimeMobileSessionClientTab, - Runtime.RuntimeMobileSessionCreateTerminalResult, - Runtime.RuntimeMobileSessionFileTab, - Runtime.RuntimeMobileSessionMarkdownTab, - Runtime.RuntimeMobileSessionSnapshotTab, - Runtime.RuntimeMobileSessionTabCloseResult, - Runtime.RuntimeMobileSessionTabGroup, - Runtime.RuntimeMobileSessionTabMove, - Runtime.RuntimeMobileSessionTabMoveResult, - Runtime.RuntimeMobileSessionTabsRemovedResult, - Runtime.RuntimeMobileSessionTabsResult, - Runtime.RuntimeMobileSessionTabsSnapshot, - Runtime.RuntimeMobileSessionTerminalClientTab, - Runtime.RuntimeMobileSessionTerminalTab, - Runtime.RuntimeMobileTerminalTheme, - Runtime.RuntimeNativeChatFileContext, - Runtime.RuntimeNativeChatLaunchDraftResolution, - Runtime.RuntimeRendererSyncWindowGraph, - Runtime.RuntimeRepoList, - Runtime.RuntimeRepoSearchRefs, - Runtime.RuntimeSessionTabCloseReason, - Runtime.RuntimeSpeechModelSummary, - Runtime.RuntimeSpeechSetupState, - Runtime.RuntimeStatus, - Runtime.RuntimeSyncedLeaf, - Runtime.RuntimeSyncedTab, - Runtime.RuntimeSyncWindowGraph, - Runtime.RuntimeSyncWindowGraphResult, - Runtime.RuntimeTerminalAgentStatus, - Runtime.RuntimeTerminalAgentStatusState, - Runtime.RuntimeTerminalClose, - Runtime.RuntimeTerminalCreate, - Runtime.RuntimeTerminalCreateRequestPayload, - Runtime.RuntimeTerminalDriverState, - Runtime.RuntimeTerminalFocus, - Runtime.RuntimeTerminalInteractiveWait, - Runtime.RuntimeTerminalInteractiveWaitSource, - Runtime.RuntimeTerminalListHostScope, - Runtime.RuntimeTerminalListResult, - Runtime.RuntimeTerminalOrphanAdoptionClaim, - Runtime.RuntimeTerminalOrphanAdoptionRequest, - Runtime.RuntimeTerminalOrphanAdoptionResult, - Runtime.RuntimeTerminalOrphanTopology, - Runtime.RuntimeTerminalOrphanTopologyGroup, - Runtime.RuntimeTerminalOrphanTopologyTab, - Runtime.RuntimeTerminalPathOpenTarget, - Runtime.RuntimeTerminalPathResolution, - Runtime.RuntimeTerminalPresentation, - Runtime.RuntimeTerminalRead, - Runtime.RuntimeTerminalRename, - Runtime.RuntimeTerminalResolvePane, - Runtime.RuntimeTerminalSend, - Runtime.RuntimeTerminalShow, - Runtime.RuntimeTerminalSplit, - Runtime.RuntimeTerminalState, - Runtime.RuntimeTerminalSummary, - Runtime.RuntimeTerminalUnavailableReason, - Runtime.RuntimeTerminalVisualGroupNode, - Runtime.RuntimeTerminalVisualLayout, - Runtime.RuntimeTerminalVisualLayoutNode, - Runtime.RuntimeTerminalVisualPaneNode, - Runtime.RuntimeTerminalVisualTab, - Runtime.RuntimeTerminalVisualTerminalNode, - Runtime.RuntimeTerminalWait, - Runtime.RuntimeTerminalWaitBlockedReason, - Runtime.RuntimeTerminalWaitCondition, - Runtime.RuntimeWorktreeAgentRow, - Runtime.RuntimeWorktreeCreateResult, - Runtime.RuntimeWorktreeListResult, - Runtime.RuntimeWorktreePsConditionalResult, - Runtime.RuntimeWorktreePsResult, - Runtime.RuntimeWorktreePsSnapshotResult, - Runtime.RuntimeWorktreePsSummary, - Runtime.RuntimeWorktreePsUnchangedResult, - Runtime.RuntimeWorktreeRecord, - Runtime.RuntimeWorktreeRemoveResult, - Runtime.RuntimeWorktreeStatus, - Runtime.RuntimeWorktreeTerminalSleepResult -] - -describe('runtime client public export parity', () => { - it('keeps the runtime type inventory compile-visible', () => { - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf(RemoteClient.sendRemoteRuntimeRequest).toEqualTypeOf< - ( - pairing: PairingOffer, - method: string, - params: unknown, - timeoutMs: number, - envelope?: RuntimeOrchestrationEnvelope, - signal?: AbortSignal, - clientCapabilities?: readonly RuntimeCapability[] - ) => Promise> - >() - expectTypeOf(RemoteClient.sendRemoteRuntimeRequestWithStatusPreflight).toEqualTypeOf< - ( - pairing: PairingOffer, - method: string, - params: unknown, - timeoutMs: number, - validateStatus: (response: RuntimeRpcResponse) => void, - envelope?: RuntimeOrchestrationEnvelope, - clientCapabilities?: readonly RuntimeCapability[] - ) => Promise> - >() - expectTypeOf(RemoteClient.subscribeRemoteRuntimeRequest).toEqualTypeOf< - ( - pairing: PairingOffer, - method: string, - params: unknown, - timeoutMs: number, - callbacks: RemoteClient.RemoteRuntimeSubscriptionCallbacks, - options?: RemoteClient.RemoteRuntimeSubscriptionOptions - ) => Promise - >() - }) - - it('keeps the runtime and transport value exports exact', () => { - expect(Object.keys(Runtime).sort()).toEqual([ - 'BROWSER_UNAVAILABLE_ERROR_CODE', - 'COMPUTER_ERROR_CODES', - 'HEADLESS_RUNTIME_WINDOW_ID', - 'TERMINAL_PTY_DEGRADATION_CAPABILITY', - 'TERMINAL_UNAVAILABLE_ERROR_CODE', - 'UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH', - 'browserUnavailableMessage', - 'terminalUnavailableMessage' - ]) - expect(Object.keys(RemoteClient).sort()).toEqual([ - 'RemoteRuntimeClientError', - 'sendRemoteRuntimeRequest', - 'sendRemoteRuntimeRequestWithStatusPreflight', - 'subscribeRemoteRuntimeRequest' - ]) - }) -}) diff --git a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs index bb8ab525da6..27439cb498e 100644 --- a/tests/tools/windows-pty-native-capability-smoke/run.test.mjs +++ b/tests/tools/windows-pty-native-capability-smoke/run.test.mjs @@ -1,10 +1,7 @@ -import { readFileSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import { checkoutRunProcessPath, formatProbeFailure, packagedProbeInvocation } from './run.mjs' -const runnerSource = readFileSync(new URL('./run.mjs', import.meta.url), 'utf8') - describe('packaged Windows native smoke runner boundary', () => { it('uses one checkout-owned runner for current and affected package paths', () => { const current = packagedProbeInvocation('/ci/current/dist/win-unpacked/Orca.exe') @@ -36,12 +33,4 @@ describe('packaged Windows native smoke runner boundary', () => { expect(failure).toContain('stdout-tail') expect(failure).toContain('stage=target-spawn:start') }) - - it('does not import child_process or resolve the runner from the artifact', () => { - expect(runnerSource).not.toContain('node:child_process') - expect(runnerSource).not.toMatch( - /path\.join\(resourcesDir[\s\S]*?app\.asar\.unpacked[\s\S]*?run-process\.js/ - ) - expect(runnerSource).toContain("'../../../out/shared/child-process/run-process.js'") - }) }) From 4da485cac1c308243fc22f5ee9c9c15058eee7ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:30:54 -0700 Subject: [PATCH 360/387] fix(native-chat): render the terminal-backed chat through the structured chat's turn status (#22090) * fix(native-chat): render the terminal-backed chat through the structured chat's turn status A terminal-backed chat (grok and omp always; Claude and Codex whenever the structured lane is refused) could not say what its agent was doing. A working agent drew three bouncing dots with no elapsed time, and an agent stopped on a prompt that only its terminal showed drew nothing, so it looked idle. A pending AskUserQuestion row read "Asked:" while the agent was still waiting, because the pane treated the wait as the end of the turn. The pane already held the facts: the host-stamped working epoch and the hook's waiting/blocked state. It now feeds them to the same message list props and components the structured lane uses, instead of opting out with showTurnStatus={false}: - The turn runs on while the agent waits on the reader, as a structured turn does behind its prompt card, so the "Working for" bar keeps counting and the live line yields to the wait. - A wait the pane can draw as a card (approval or question) is shown by the card; a wait only the terminal shows is reported by the tail row the structured lane already uses for a pending question (NativeChatAwaitingInputRow). - The hook wait is reconciled against the transcript's terminal marker, like hook 'working' is, because an interrupt at the prompt fires no hook. showTurnStatus meant nothing once both lanes passed it, so it is gone, along with showLiveTurnActivity (replaced by the awaitingInput fact), the structuredActivityUi flag it fed to tool rows, and the transcript-guessing typing indicator (native-chat-typing-indicator.ts, NativeChatTypingIndicatorRow.tsx), which had no other consumer. The interactive card's derivation moved into useNativeChatInteractivePromptCard so the view can tell a wait the card answers from one it cannot. * test(native-chat): seed the hook wait through the store instead of a cast * fix(native-chat): say "Awaiting user input" as a whole phrase when no question is named A wait the pane cannot draw as a card, and a question whose text could not be parsed, both drew "Awaiting user input:" with nothing after the colon. The row now reads "Awaiting user input" when it has no question to name, in both chat lanes. The pending-question view test now counts awaiting rows by attribute so a second, unnamed row cannot slip past it. * perf(native-chat): read the prompt's tool name only while a prompt is pending Deriving the prompt card moved from the card into the pane's view, and with it a store read of the status row's tool name, which changes on every tool call. Every tool call therefore re-rendered the whole terminal-backed pane and its transcript list, where before only the card re-rendered. The tool name only matters beside a prompt, so read it only then. * fix(native-chat): keep the terminal lane's turn clock and folds across a remount Answering a prompt only the terminal shows means switching the pane to its terminal, which unmounts the chat. On return the "Working for" clock restarted from the agent's current state (seconds after the answer), and every turn the pane had folded behind "Worked for N" came back unfolded with no bar. - The running turn now counts from the start of the hook's unbroken run of mid-turn states under the current prompt (read from the status row's state history), not the current state's own start. - Finished turns take their duration from the transcript's own timestamps (prompt to the agent's last row or its interruption), so history turns fold like a structured chat's. The latest turn still relies on what the pane saw. * fix(native-chat): date the terminal lane's turns by the main agent and skip harness notices Two ways the terminal-backed chat showed a wrong turn status: - A background task or subagent keeps the status row 'working' after the main agent finishes, so the row's own start time carries into the next turn. A prompt sent while a background dev server ran showed "Working for" counted from the earlier turn. The clock now reads the main agent's own state and start time where the row carries them. - Harness notices (task notifications, reminders) are user-role rows in the transcript that the chat does not draw. The finished-turn derivation treated them as new prompts, so a running turn was reported settled and folded its steps while it still ran, and a finished turn's "Worked for" stopped at the notice. They no longer start or end a turn. * feat(agent-status): stamp each turn's start on the host The hook server now records turnStartedAt on a status row when the main agent's own turn-opening event arrives (the per-provider new-turn classifier the observation boundary already uses; not a replay, a child event or an identity-only row). Other events carry it; a session boundary clears it, and a settled main agent running again with no opening event drops it rather than count from the finished turn. It rides the snapshot and the live push as an optional field, persists with the row, lands on the renderer entry (kept within one state for writers with no turn clock), and is part of the paired-client projection key and equality. Old hosts send nothing; readers fall back to stateStartedAt. * fix(native-chat): time the terminal lane's turns from the host's turn stamp The running clock reads the host's turnStartedAt, so a remount, a reload (which starts with no state history) and child work holding the row open no longer move it, and the same prompt sent again is a new turn. The stateHistory walk and its prompt-equality rule are gone; an old host falls back to the current state's start. The latest turn is settled from host facts: once the main agent is done (outside a session boundary) its duration is the done stamp minus the turn start, so it folds after a remount. When the host went quiet mid-turn (the store's staleness dropped the row) the pane passes null, so a still-waiting turn no longer reads "Worked for 30m" and folds. Older turns keep their transcript durations. * test(agent-status): pin the host's turn start on the live status push * fix(native-chat): keep omp's turn working while its latest row is a tool call The terminal-backed chat settles a hook 'working' turn when the transcript's last row is an assistant row written after the turn began, a recovery for a Stop hook the host can miss. omp writes a timestamp on every row and has no transcript turn markers, so every tool call mid-turn tripped it: the pane dropped its "Working for" bar and showed Send instead of Stop while the command ran. omp's own runtime delivers its turn end (agent_end, retried until the host takes it and held back while the run continues), so its hook 'working' is never a dropped Stop. The prose recovery now skips it. * test(native-chat): read the clock once in the host-ended remount test The test stamped the row's done time and the turn start from two separate Date.now() reads, so whenever a millisecond passed between them the expected "Worked for 1m 30s" came out as 1m 29s. It failed that way once under load. * test(sync-runtime-graph): carry the turn stamp in the hot-path projection reference The hot-path suite compares the agent-status projection against its own copy of the serializer; that copy lacked the new turnStartedAt key. --- .../server-turn-started-at.test.ts | 185 ++++++++ .../server/server-persistence-validation.ts | 6 +- .../server/server-status-application.ts | 41 +- .../server/server-status-identity.ts | 1 + src/main/agent-hooks/server/server-types.ts | 3 + src/main/startup/main-window-agent-status.ts | 2 + ...in-window-structured-status-filter.test.ts | 8 + .../NativeChatAwaitingInputRow.tsx | 12 +- .../NativeChatInteractiveCard.test.tsx | 32 +- .../native-chat/NativeChatInteractiveCard.tsx | 50 +-- ...eChatMessageList.task-list-frames.test.tsx | 1 - ...eChatMessageList.tool-stream-cost.test.tsx | 10 +- .../native-chat/NativeChatMessageList.tsx | 51 +-- ...iveChatMessageList.turn-indicator.test.tsx | 72 ++-- .../native-chat/NativeChatMessageRow.tsx | 3 - .../native-chat/NativeChatResolvedView.tsx | 30 +- ...ativeChatResolvedView.turn-status.test.tsx | 401 ++++++++++++++++++ ...tiveChatStructuredSession.test-harness.tsx | 3 +- .../NativeChatStructuredSession.test.tsx | 15 +- .../NativeChatStructuredSession.tsx | 3 +- ...tiveChatStructuredSessionDelivery.test.tsx | 1 - .../NativeChatToolRun.ask-row.test.tsx | 2 +- .../native-chat/NativeChatToolRun.tsx | 12 +- .../native-chat/NativeChatTranscriptRow.tsx | 2 - .../NativeChatTypingIndicatorRow.tsx | 21 - .../native-chat-live-session-contract.ts | 3 + .../native-chat-live-status.test.ts | 79 +++- .../native-chat/native-chat-live-status.ts | 32 +- .../native-chat-message-rail-items.test.ts | 1 - .../native-chat-rail-outline-parity.test.ts | 1 - .../native-chat-terminal-turn.test.ts | 243 +++++++++++ .../native-chat/native-chat-terminal-turn.ts | 132 ++++++ .../native-chat-transcript-slots.test.ts | 1 - .../native-chat-transcript-slots.ts | 12 +- .../native-chat-typing-indicator.test.ts | 154 ------- .../native-chat-typing-indicator.ts | 34 -- ...ative-chat-interactive-prompt-card.test.ts | 68 +++ ...use-native-chat-interactive-prompt-card.ts | 48 +++ .../use-native-chat-live-session.test.ts | 36 ++ .../use-native-chat-live-session.ts | 7 +- .../use-native-chat-message-rail.test.ts | 1 - .../use-native-chat-terminal-turn-timing.ts | 45 ++ ...use-native-chat-transcript-scroll.test.tsx | 2 +- .../use-native-chat-transcript-scroll.ts | 7 +- .../use-native-chat-turn-status.ts | 2 +- ...events-agent-status-store-test-fixtures.ts | 1 + .../agent-status-event-applicator.ts | 3 +- ...vents-agent-status-snapshot-replay.test.ts | 10 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...time-graph-agent-status-projection.test.ts | 3 + ...-runtime-graph-projection-hot-path.test.ts | 1 + .../agent-status-projection.ts | 1 + .../state-equality-core.test.ts | 24 +- .../state-equality-core.ts | 1 + .../src/store/slices/agent-status-contract.ts | 2 + .../slices/agent-status-live-entry-builder.ts | 6 + .../agent-status-turn-started-at.test.ts | 50 +++ src/shared/agent-status-ipc-payload.ts | 4 + src/shared/agent-status-types.ts | 3 + src/shared/native-chat-ask-row.ts | 1 + 65 files changed, 1593 insertions(+), 398 deletions(-) create mode 100644 src/main/agent-hooks/server-turn-started-at.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-terminal-turn.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-typing-indicator.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts create mode 100644 src/renderer/src/store/slices/agent-status-turn-started-at.test.ts diff --git a/src/main/agent-hooks/server-turn-started-at.test.ts b/src/main/agent-hooks/server-turn-started-at.test.ts new file mode 100644 index 00000000000..63ebd136be6 --- /dev/null +++ b/src/main/agent-hooks/server-turn-started-at.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + getCohortAtEmitMock.mockReturnValue({}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// Distinct host stamps: each event lands on a later millisecond than the one before it. +async function nextMillisecond(): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)) +} + +// The execution host stamps when the main agent's turn began, so every reader (and a reload, which +// starts the renderer with no state history) gets the turn start rather than rebuilding it. +describe('the host-stamped turn start', () => { + const servers: AgentHookServer[] = [] + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-turn-started-at-')) + }) + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + rmSync(userDataPath, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', userDataPath }) + return server + } + + async function post(server: AgentHookServer, payload: Record): Promise { + const response = await postHookEvent(server, buildBody(payload)) + expect(response.status).toBe(204) + await nextMillisecond() + } + + function turnStartedAt(server: AgentHookServer): number | undefined { + return server.getStatusSnapshot()[0]?.turnStartedAt + } + + it('stamps a new main-agent turn and carries it across tools, a wait and its answer', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + const started = turnStartedAt(server) + expect(started).toBe(server.getStatusSnapshot()[0]?.stateStartedAt) + + const bash = { tool_name: 'Bash', tool_input: { command: 'pnpm build' }, tool_use_id: 'tu-1' } + await post(server, { hook_event_name: 'PreToolUse', ...bash }) + await post(server, { hook_event_name: 'PermissionRequest', ...bash }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting' }) + await post(server, { hook_event_name: 'PostToolUse', ...bash }) + // The answer restarted the state clock, not the turn's. + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]?.stateStartedAt).toBeGreaterThan(started!) + expect(turnStartedAt(server)).toBe(started) + + await post(server, { hook_event_name: 'Stop' }) + // Kept on the finished turn, so a reader can time it from the host's own facts. + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', turnStartedAt: started }) + }) + + it('is not stamped by a child agent, and the same prompt sent again is a new turn', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + const started = turnStartedAt(server) + + await post(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'child task', + agent_id: 'child-1' + }) + expect(turnStartedAt(server)).toBe(started) + + // Esc at a permission prompt fires no hook, so the resend follows the stale wait directly. + await post(server, { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: {} }) + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + expect(turnStartedAt(server)).toBeGreaterThan(started!) + }) + + it('is not stamped by a relayed replay', async () => { + const server = await startServer() + const envelope = { + paneKey: PANE, + tabId: 'tab-1', + source: 'claude' as const, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working' as const, prompt: 'relayed', agentType: 'claude' as const } + } + server.ingestRemote(envelope, 'ssh-turn') + const started = turnStartedAt(server) + expect(started).toEqual(expect.any(Number)) + await nextMillisecond() + + server.ingestRemote({ ...envelope, isReplay: true }, 'ssh-turn') + expect(turnStartedAt(server)).toBe(started) + }) + + // A session start lands an idle boundary row: no turn is open until the next prompt. + it('clears on a session boundary', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + expect(turnStartedAt(server)).toEqual(expect.any(Number)) + + await post(server, { hook_event_name: 'SessionStart', source: 'clear' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt') + }) + + // An OSC repaint can land before the prompt hook; it must not count from the finished turn. + it('drops the stamp when a finished main agent runs again with no turn-opening event', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + await post(server, { hook_event_name: 'Stop' }) + expect(turnStartedAt(server)).toEqual(expect.any(Number)) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'Rename the module', agentType: 'claude' } + }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt') + }) + + it('cannot be declared by a producer', async () => { + const server = await startServer() + await post(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'Rename the module', + turnStartedAt: 1 + }) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', turnStartedAt: 1 }) + expect(turnStartedAt(server)).toBeGreaterThan(1) + }) + + it('reaches the live push and survives a restart through the status file', async () => { + const server = await startServer() + const pushed: (number | undefined)[] = [] + server.setListener((status) => pushed.push(status.turnStartedAt)) + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {} }) + const started = turnStartedAt(server) + expect(pushed.at(-1)).toBe(started) + server.flushStatusPersistSync() + server.stop() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.entries[PANE].turnStartedAt).toBe(started) + + const restarted = await startServer() + expect(restarted.getStatusSnapshot()[0]).toMatchObject({ + restoredUnconfirmed: true, + turnStartedAt: started + }) + // The turn goes on after the restart: the next event carries the restored stamp. + await post(restarted, { hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} }) + expect(turnStartedAt(restarted)).toBe(started) + }) +}) diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index f88766217c1..7203f174a0e 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -149,6 +149,7 @@ export function sanitizeHydratedEntry( source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') ? record.compactTrigger : undefined + const turnStartedAt = record.turnStartedAt return { paneKey, source, @@ -170,7 +171,10 @@ export function sanitizeHydratedEntry( retainedForLiveness: retainedForLiveness ? true : undefined, payload, receivedAt, - stateStartedAt + stateStartedAt, + ...(typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0 + ? { turnStartedAt } + : {}) } } diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 8863dc178b9..674fa8a0760 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -10,6 +10,7 @@ import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' +import type { AgentStatusState, ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state' import type { EnrichedAgentHookEventPayload } from './server-types' import { agentTypeToPromptSentAgentKind } from './server-status-identity' @@ -18,6 +19,41 @@ import { AgentHookServerStatusDisposition } from './server-status-disposition' /** Bounds the retained observation clock; eviction only degrades a replay to `now`. */ const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024 +function mainAgentState(payload: ParsedAgentStatusPayload): AgentStatusState { + return payload.mainAgent?.state ?? payload.state +} + +/** + * When the main agent's current turn began. Its own turn-opening event stamps it (the same + * classifier the observation boundary uses), except a session boundary, which lands idle with no + * turn open. Replays, child events and identity-only rows restate a turn rather than open one. + */ +function resolveTurnStartedAt( + previous: EnrichedAgentHookEventPayload | undefined, + payload: AgentHookEventPayload, + at: number +): number | undefined { + if ( + payload.source !== undefined && + payload.toolAgentId === undefined && + payload.isReplay !== true && + payload.providerSessionOnly !== true && + isNewTurnEvent(payload.source, payload.hookEventName) + ) { + return payload.payload.sessionBoundary === true ? undefined : at + } + // Why: a settled main agent running again with no opening event (an OSC repaint racing the + // prompt hook) is a turn edge nothing dated; carrying the old stamp would count from that turn. + if ( + previous && + mainAgentState(previous.payload) === 'done' && + mainAgentState(payload.payload) !== 'done' + ) { + return undefined + } + return previous?.turnStartedAt +} + export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { protected refreshTerminalStatusEvidence( previous: EnrichedAgentHookEventPayload, @@ -113,12 +149,15 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn ? previous.stateStartedAt : (observedAt ?? now) + const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now) // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. return { ...payload, receivedAt: now, evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now), - stateStartedAt + stateStartedAt, + // Always written here, so a producer can never declare it. + turnStartedAt } } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 1694c4b1b67..4400b43efee 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -63,6 +63,7 @@ export function toAgentStatusIpcPayload( ? { evidenceObservedAt: entry.evidenceObservedAt } : {}), stateStartedAt: entry.stateStartedAt, + ...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index d1466797798..79b4e569cdd 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -19,6 +19,9 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { * main restart; absent means "never separately observed" and consumers use `receivedAt`. */ evidenceObservedAt?: number stateStartedAt: number + /** When the main agent's current turn began, on this server's clock. Stamped only here, from the + * main agent's own turn-opening event; absent when no such event was seen. */ + turnStartedAt?: number /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ observation?: AgentStatusObservation /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 5899b2fe6bd..38bb380d724 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt receivedAt, evidenceObservedAt, stateStartedAt, + turnStartedAt, launchToken, providerSession, providerSessionOnly, @@ -89,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt receivedAt, ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, + ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index c7fe55da626..da26a593d00 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -94,3 +94,11 @@ it('forwards retirement acknowledgement only on live status delivery', () => { expect(sent[0].event).toHaveProperty('authorityRestartId', 'retirement-id') expect(sent[1].event).not.toHaveProperty('authorityRestartId') }) + +// The live push picks fields one by one; the host's turn start must be one of them. +it("forwards the host's turn start, and nothing when the host stamped none", () => { + hooks.listener!(statusPayload({ turnStartedAt: 1 })) + hooks.listener!(statusPayload({})) + expect(sent[0].event).toHaveProperty('turnStartedAt', 1) + expect(sent[1].event).not.toHaveProperty('turnStartedAt') +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx index 8e621a8a441..9549fc933e7 100644 --- a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx @@ -72,9 +72,15 @@ export function NativeChatAwaitingInputRow({ const questions = subject?.kind === 'questions' && listsQuestions ? subject.questions : null // A count always hides its questions, so the list needs no clipping measurement. const toggles = questions !== null || (question !== null && (open || clipped)) - const label = pending - ? translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting) - : translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked) + // With nothing to name, the label must read as a whole phrase, not end on a colon. + const label = !pending + ? translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked) + : subject === null + ? translate( + 'components.native-chat.ask.awaitingUnnamed', + NATIVE_CHAT_ASK_ROW_COPY.awaitingUnnamed + ) + : translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting) const text = subject === null ? null diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index cd08f779e71..303ab2472c7 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -33,6 +33,7 @@ vi.mock('../../store', () => ({ })) import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' +import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card' const mocks = { sendAnswer: vi.fn(), @@ -45,6 +46,8 @@ function renderCard(canSend = true): ReturnType { return render(cardElement(canSend)) } +const NO_MESSAGES: readonly NativeChatMessage[] = [] + function cardElement( canSend = true, messages?: readonly NativeChatMessage[], @@ -52,11 +55,36 @@ function cardElement( transcriptSettled = true ): React.JSX.Element { return ( - + ) +} + +// The view derives the card and hands it over; this stands in for that view. +function CardHarness({ + canSend, + messages, + onShowingQuestionChange, + transcriptSettled +}: { + canSend: boolean + messages?: readonly NativeChatMessage[] + onShowingQuestionChange?: (showing: boolean) => void + transcriptSettled: boolean +}): React.JSX.Element | null { + const card = useNativeChatInteractivePromptCard({ + paneKey: 'tab-1:leaf-1', + messages: messages ?? NO_MESSAGES, + transcriptSettled: transcriptSettled && messages !== undefined + }) + return ( + void @@ -51,26 +36,7 @@ export function NativeChatInteractiveCard({ * a target while the composer is unmounted. */ answerInputRef?: React.RefObject }): React.JSX.Element | null { - const interactivePrompt = useAppStore( - (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null - ) - // Thread the sibling `toolName` from the same status entry so the question - // parser can dispatch through the tool's registered parser (mobile parity). - const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null) const { sendAnswer, sendRaw, cancelPending, cancel } = send - - const card = useMemo(() => { - const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined) - if (statusCard?.kind === 'approval') { - return statusCard - } - const prompt = resolveNativeChatAsk({ - liveAsk: statusCard?.prompt ?? null, - messages: messages ?? [], - transcriptSettled: transcriptSettled && messages != null - }) - return prompt ? { kind: 'question' as const, prompt } : null - }, [interactivePrompt, interactiveToolName, messages, transcriptSettled]) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) // A question answer is a paced multi-step write (body→Enter per question); keep diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx index 4be1ea7cdb3..82b2c43d468 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx @@ -70,7 +70,6 @@ function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { isWorking={false} expandSignal fontScale={1} - showTurnStatus={false} /> ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx index 2a2401bea52..a77d2907b6a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -44,15 +44,7 @@ function Transcript({ items }: { items: AgentJournalRenderItem[] }) { loadEarlier, readPhase: 'ready' } - return ( - - ) + return } function row(index: number, body: AgentJournalRenderItem['body']): AgentJournalRenderItem { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index ee697732671..6e0c29d9d71 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -10,9 +10,8 @@ import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' import { NativeChatTaskList } from './NativeChatTaskList' import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' -import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' import { useNativeChatTurnStatus } from './use-native-chat-turn-status' -import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow' +import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' @@ -59,6 +58,10 @@ export { ProviderFrameRow } from './NativeChatTranscriptChrome' const MAX_EXPANDED_TURNS = 128 +/** The turn is blocked on the reader. `shown`: the pane draws the prompt itself, as a card; + * `unshown`: it cannot (the prompt is only in the agent's terminal). */ +export type NativeChatAwaitingInput = 'shown' | 'unshown' + type NativeChatNavigationRequest = | { kind: 'diff'; target: NativeChatDiffReveal } | { kind: 'rail'; messageId: string; requestId: number } @@ -78,8 +81,7 @@ export function NativeChatMessageList({ activeTurnOpenedBy, turnKeysByItemId = null, deliveryNotices, - showTurnStatus = true, - showLiveTurnActivity = true, + awaitingInput = null, turnActivity, runtimeContext }: { @@ -94,7 +96,8 @@ export function NativeChatMessageList({ /** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */ fontScale: number workingStartedAt?: number | null - /** Host-recorded turn durations keyed by user message id (structured lane). */ + /** Recorded turn durations keyed by user message id (the host's, or the transcript's). + * A turn missing here shows the duration this list observed, if it saw the turn run. */ settledTurns?: NativeChatSettledTurns /** The key the host says anchors the running turn's bar (structured lane). */ activeTurnOpenedBy?: string | null @@ -104,10 +107,8 @@ export function NativeChatMessageList({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap - /** Turn timing and disclosure are available on structured agent sessions. */ - showTurnStatus?: boolean - /** Whether the active turn's foreground activity row should be visible. */ - showLiveTurnActivity?: boolean + /** Set while the turn waits on the reader; the live activity line yields to it. */ + awaitingInput?: NativeChatAwaitingInput | null turnActivity?: NativeChatTurnActivity | null runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { @@ -162,14 +163,11 @@ export function NativeChatMessageList({ const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages]) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) - const showTypingIndicator = showTurnStatus - ? isWorking - : shouldShowNativeChatTypingIndicator({ messages, isWorking }) // Resolve each row's owning turn once. Prefix slice/findLast in the render // loop becomes quadratic for long transcripts. const turnKeys = useMemo( - () => nativeChatRowTurnKeys(messages, showTurnStatus ? turnKeysByItemId : null), - [messages, showTurnStatus, turnKeysByItemId] + () => nativeChatRowTurnKeys(messages, turnKeysByItemId), + [messages, turnKeysByItemId] ) const turnDiffs = useMemo( () => @@ -188,11 +186,19 @@ export function NativeChatMessageList({ const turnStatuses = useNativeChatTurnStatus({ messages, activeTurnKey, - isWorking: showTurnStatus && isWorking, - workingStartedAt: showTurnStatus ? workingStartedAt : null, - settledTurns: showTurnStatus ? settledTurns : null, + isWorking, + workingStartedAt, + settledTurns, thinking }) + // The transcript tail: what the running turn is doing, or that it waits on a + // prompt nothing else on screen shows. A prompt card says so itself. + const tailRow = + awaitingInput === 'unshown' + ? 'awaiting-input' + : isWorking && awaitingInput === null + ? 'activity' + : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' const slots = useMemo( () => @@ -203,7 +209,6 @@ export function NativeChatMessageList({ receipts, turnStatuses, turnDiffs, - showTurnStatus, expandedTurnKeys: expandedTurnIds, isWorking, lifecycleWorking, @@ -216,7 +221,6 @@ export function NativeChatMessageList({ lifecycleWorking, messages, receipts, - showTurnStatus, subagentLabels, turnDiffs, turnKeys, @@ -236,7 +240,7 @@ export function NativeChatMessageList({ contentRef, itemCount: slots.length, isWorking, - showTypingIndicator, + showsTailRow: tailRow !== null, isVisible, alignToViewportTop: transcriptWindow.alignToViewportTop, scrollToEnd: transcriptWindow.scrollToEnd, @@ -331,7 +335,6 @@ export function NativeChatMessageList({ const rowContext = useMemo( () => ({ expandSignal, - showTurnStatus, revealedDiff, taskListPredecessors, expandedTurnIds, @@ -353,7 +356,6 @@ export function NativeChatMessageList({ revealedDiff, runtimeContext, scrollMessageToTop, - showTurnStatus, taskListPredecessors, toggleExpandedTurn ] @@ -398,13 +400,14 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {showTurnStatus && showLiveTurnActivity && isWorking ? ( + {tailRow === 'activity' ? ( + ) : tailRow === 'awaiting-input' ? ( + ) : null} - {!showTurnStatus && showTypingIndicator ? : null}
    diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index 8400b592e7d..e8d714fad1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -166,7 +166,7 @@ describe('NativeChatMessageList turn indicator', () => { }} journalItems={[journalItem(1, turnItem), journalItem(2, reasoningRow)]} isWorking - showLiveTurnActivity={false} + awaitingInput="shown" expandSignal={false} fontScale={1} /> @@ -178,6 +178,38 @@ describe('NativeChatMessageList turn indicator', () => { expect(screen.getByText('Running 1 command')).toHaveClass('animate-pulse') }) + // A terminal-backed pane can learn its agent is waiting without being handed + // the prompt; the tail then says so, in the row a question uses. + it('says the turn waits on the reader when no card shows the prompt', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-native-chat-ask-row="awaiting"]')).toHaveTextContent( + /^Awaiting user input$/ + ) + expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + }) + it('keeps the live row up after a tool settles', () => { render( { expect(liveRun?.querySelector('.lucide-check')).toBeInTheDocument() }) - it('keeps bridge chats on the legacy activity chrome', () => { - render( - - ) - - expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() - expect(screen.queryByText('Running sleep 5')).toBeNull() - expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) - }) - it('replaces a bridge ask row and settles it from the FIFO tool result', () => { const user = { id: 'bridge-user', @@ -355,7 +351,6 @@ describe('NativeChatMessageList turn indicator', () => { isWorking={false} expandSignal={false} fontScale={1} - showTurnStatus={false} /> ) @@ -382,7 +377,6 @@ describe('NativeChatMessageList turn indicator', () => { isWorking={false} expandSignal={false} fontScale={1} - showTurnStatus={false} /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 59e70cfc7e1..e43a758051b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -47,7 +47,6 @@ export const MessageRow = memo(function MessageRow({ onLinkClick, allowFileUriLinks = false, deliveryNotice, - structuredActivityUi = true, folded = false, subagentLabel, runtimeContext @@ -65,7 +64,6 @@ export const MessageRow = memo(function MessageRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotice?: NativeChatDeliveryNotice - structuredActivityUi?: boolean /** Behind a folded turn: the row keeps only what outlives the turn. */ folded?: boolean /** The roster's name for the subagent that wrote this row, when one names it. */ @@ -269,7 +267,6 @@ export const MessageRow = memo(function MessageRow({ expandSignal={expandSignal} activeTurnIsWorking={activeTurnIsWorking} trailing={trailingRun} - structuredActivityUi={structuredActivityUi} disclosureId={message.id} /> ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index fb432e1e5b4..786ade25ebc 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -11,12 +11,12 @@ import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatC import { useNativeChatFontScale } from './use-native-chat-font-scale' import { useNativeChatCanSend } from './use-native-chat-can-send' import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' +import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card' import { NativeChatEmptyState } from './NativeChatEmptyState' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { - shouldClearNativeChatWorkingSuppression, - shouldShowNativeChatWorking -} from './native-chat-working-suppression' +import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-suppression' +import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn' +import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing' import { appendPendingSendCache, launchPromptAsMessage, @@ -251,6 +251,11 @@ export function NativeChatResolvedView({ paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, sessionAfterCommandBoundaries.messages ) + const promptCard = useNativeChatInteractivePromptCard({ + paneKey, + messages: sessionAfterCommandBoundaries.messages, + transcriptSettled: session.readPhase === 'ready' + }) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -313,11 +318,14 @@ export function NativeChatResolvedView({ previousWorkingEpochRef.current = null } }, [liveWorking, workingInterrupted, hookWorkingEpoch]) - const isWorking = shouldShowNativeChatWorking({ + const { isWorking, turnActive, awaitingInput } = resolveNativeChatTerminalTurn({ isConversation, working: liveWorking, - interrupted: workingInterrupted + hookAwaitingInput: session.hookAwaitingInput === true, + interrupted: workingInterrupted, + hasPromptCard: promptCard !== null }) + const turnTiming = useNativeChatTerminalTurnTiming(paneKey, session.messages, turnActive) const stopAgent = useCallback(() => { setWorkingInterrupted(true) @@ -399,11 +407,11 @@ export function NativeChatResolvedView({ diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx new file mode 100644 index 00000000000..ba2a7c00de2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx @@ -0,0 +1,401 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import type { AgentStatusEntry, AgentStatusPayload } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' + +// The transcript source and the composer are stubbed; the wire under test is +// store status -> resolved view -> the shared message list and prompt card. +const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null })) +vi.mock('./use-native-chat-retained-session', () => ({ + useNativeChatRetainedSession: () => retained.session +})) +vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: () => null })) + +const { NativeChatResolvedView } = await import('./NativeChatResolvedView') +const { useAppStore } = await import('../../store') +const { installNativeChatMessageListTestViewport } = + await import('./native-chat-message-list-test-viewport') + +const paneKey = 'tab-state:leaf-state' +let restoreViewport = (): void => {} + +const userTurn: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Rename the module' }], + timestamp: 1, + source: 'transcript' +} + +const askCall: NativeChatMessage = { + id: 'ask-1', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Which name?', + multiSelect: false, + options: [{ label: 'core' }, { label: 'base' }] + } + ] + } + } + ], + timestamp: 2, + source: 'transcript' +} + +const stepCall: NativeChatMessage = { + id: 'step-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm build' } }], + timestamp: 3, + source: 'transcript' +} + +// `status` and `hookAwaitingInput` are what the live session reconciles from the +// same hook row; the test states both so the view is fed a consistent session. +function transcript( + status: NativeChatLiveSession['status'], + hookAwaitingInput: boolean, + messages: NativeChatMessage[] = [userTurn] +): NativeChatLiveSession { + return { + messages, + status, + sessionId: 'session-state', + agent: 'claude', + hookAwaitingInput, + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } +} + +function setStatus(payload: Omit, age = 0): void { + useAppStore + .getState() + .setAgentStatus(paneKey, { prompt: 'Rename the module', agentType: 'claude', ...payload }) + const live = useAppStore.getState().agentStatusByPaneKey[paneKey] + if (!live) { + throw new Error('the store dropped the status row this test depends on') + } + useAppStore.setState((store) => ({ + agentStatusByPaneKey: { + ...store.agentStatusByPaneKey, + [paneKey]: { ...live, stateStartedAt: Date.now() - age } + } + })) +} + +function patchStatus(fields: Partial): void { + useAppStore.setState((store) => ({ + agentStatusByPaneKey: { + ...store.agentStatusByPaneKey, + [paneKey]: { ...store.agentStatusByPaneKey[paneKey]!, ...fields } + } + })) +} + +function renderPane(): void { + render( + + ) +} + +function liveActivityLine(): Element | null { + return document.querySelector('[data-native-chat-turn-activity]') +} + +beforeEach(() => { + restoreViewport = installNativeChatMessageListTestViewport() + useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} }) +}) + +afterEach(() => { + cleanup() + restoreViewport() + useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} }) +}) + +// A terminal-backed pane renders through the structured chat's own turn-status +// UI: the clock bar under the prompt, the live line at the tail, and the row a +// question uses when the agent waits on the reader. +describe('NativeChatResolvedView turn status', () => { + it('counts the working turn from the host-stamped epoch, with the live line at the tail', () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }, 75_000) + + renderPane() + + expect(screen.getByText('Working for 1m 15s')).toBeInTheDocument() + expect(liveActivityLine()).toHaveTextContent('Working…') + expect(document.querySelectorAll('.animate-bounce')).toHaveLength(0) + }) + + it.each(['waiting', 'blocked'] as const)( + 'says a %s agent waits on the reader when only its terminal shows the prompt', + (state) => { + retained.session = transcript('ready', true) + setStatus({ state }, 20_000) + + renderPane() + + // A whole phrase: there is no question to name after a colon. + expect(screen.getByText('Awaiting user input')).toBeInTheDocument() + expect(liveActivityLine()).toBeNull() + // The turn runs on behind the wait, as a structured turn does behind its card. + expect(screen.getByText('Working for 20s')).toBeInTheDocument() + } + ) + + it('lets the approval card speak for the wait', () => { + retained.session = transcript('ready', true) + setStatus({ + state: 'waiting', + interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf dist' } }) + }) + + renderPane() + + expect(screen.getByText('Allow Bash?')).toBeInTheDocument() + expect(screen.queryByText(/Awaiting user input/)).toBeNull() + expect(liveActivityLine()).toBeNull() + expect(screen.getByText('Working for 0s')).toBeInTheDocument() + }) + + it("keeps a pending question's transcript row awaiting while the agent waits", () => { + retained.session = transcript('ready', true, [userTurn, askCall]) + setStatus({ state: 'waiting' }) + + renderPane() + + // One row says it: the question's own, not a second one at the tail. + expect(document.querySelectorAll('[data-native-chat-ask-row="awaiting"]')).toHaveLength(1) + expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.getAllByText('Which name?').length).toBeGreaterThan(0) + expect(screen.queryByText('Asked:')).toBeNull() + }) + + // Answering a terminal-only prompt means leaving the chat, which remounts it on return; a reload + // also starts the store with no state history. The host's stamp covers both. + it("counts a turn it first sees after a wait from the host's turn start, not the last state", () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }, 5_000) + patchStatus({ turnStartedAt: Date.now() - 90_000, stateHistory: [] }) + + renderPane() + + expect(screen.getByText('Working for 1m 30s')).toBeInTheDocument() + }) + + // A background task holds the row 'working' past the last turn's end, so the row's own epoch is + // that turn's; the host stamps the new turn from the main agent's own prompt. + it('counts a new turn from its stamp, not a row held open by background work', () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }) + const now = Date.now() + patchStatus({ + stateStartedAt: now - 45 * 60_000, + turnStartedAt: now - 5_000, + mainAgent: { state: 'working', stateStartedAt: now - 5_000 }, + stateHistory: [] + }) + + renderPane() + + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + }) + + it("falls back to the main agent's own clock on a host that stamps no turn", () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }) + const now = Date.now() + patchStatus({ + stateStartedAt: now - 45 * 60_000, + mainAgent: { state: 'working', stateStartedAt: now - 5_000 }, + stateHistory: [] + }) + + renderPane() + + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + }) + + // An Esc at a permission prompt fires no hook, then the same text is sent again: a new turn. + it('counts the same prompt sent again as a new turn', () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }, 5_000) + const now = Date.now() + patchStatus({ + turnStartedAt: now - 5_000, + stateHistory: [ + { state: 'working', prompt: 'Rename the module', startedAt: now - 600_000 }, + { state: 'waiting', prompt: 'Rename the module', startedAt: now - 500_000 } + ] + }) + + renderPane() + + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + }) + + // The reader no longer rebuilds a turn from the history, so an old host reads the state's start. + it('falls back to the current state start on a host that stamps no turn', () => { + retained.session = transcript('working', false) + setStatus({ state: 'working' }, 20_000) + const now = Date.now() + patchStatus({ + stateHistory: [ + { state: 'working', prompt: 'Rename the module', startedAt: now - 90_000 }, + { state: 'waiting', prompt: 'Rename the module', startedAt: now - 40_000 } + ] + }) + + renderPane() + + expect(screen.getByText('Working for 20s')).toBeInTheDocument() + }) + + // The host recorded the turn's end, so a pane mounted after it (a switch to the terminal and + // back) folds it behind its duration instead of drawing it unfinished. + it('shows the host-ended latest turn with its host duration after a remount', () => { + retained.session = transcript('ready', false, [userTurn, stepCall]) + setStatus({ state: 'done' }) + // One clock read: the duration is the difference of these two stamps. + const now = Date.now() + patchStatus({ stateStartedAt: now - 10_000, turnStartedAt: now - 100_000 }) + + renderPane() + + expect(screen.getByText('Worked for 1m 30s')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toBeInTheDocument() + }) + + // The store's staleness drops a silent row after 30 minutes. A wait left unanswered that long + // must not read as a finished turn: nothing says it ended. + it('does not settle a turn the host went quiet on', () => { + retained.session = transcript('ready', true, [userTurn, stepCall]) + setStatus({ state: 'waiting' }, 60_000) + patchStatus({ turnStartedAt: Date.now() - 60_000 }) + + renderPane() + expect(screen.getByText('Working for 1m 0s')).toBeInTheDocument() + + // The live session reads no hook state once the row is stale, so the wait no longer holds the turn. + retained.session = transcript('ready', false, [userTurn, stepCall]) + act(() => { + patchStatus({ updatedAt: Date.now() - 31 * 60_000 }) + }) + + expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + expect(screen.getByText('pnpm build')).toBeInTheDocument() + }) + + it('folds finished turns from history behind their transcript duration', () => { + const at = Date.parse('2026-09-28T10:00:00.000Z') + retained.session = transcript('ready', false, [ + { ...userTurn, timestamp: at }, + { + id: 'answer-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Renamed.' }], + timestamp: at + 45_000, + source: 'transcript' + }, + { + id: 'user-2', + role: 'user', + blocks: [{ type: 'text', text: 'Now add tests' }], + timestamp: at + 100_000, + source: 'transcript' + } + ]) + setStatus({ state: 'done' }, 60_000) + + renderPane() + + expect(screen.getByText('Worked for 45s')).toBeInTheDocument() + // The latest turn has no recorded end, and this pane never watched it. + expect(screen.getAllByText(/Work(ing|ed) for/)).toHaveLength(1) + }) + + // The transcript draws no row for harness notices, so they cannot end a turn either. + it('keeps a running turn open across a harness notice injected mid-turn', () => { + const at = Date.parse('2026-09-28T10:00:00.000Z') + retained.session = transcript('working', false, [ + { ...userTurn, timestamp: at }, + { + id: 'progress-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Starting the build.' }], + timestamp: at + 10_000, + source: 'transcript' + }, + { + id: 'notice-1', + role: 'user', + blocks: [{ type: 'text', text: '\nb1' }], + timestamp: at + 20_000, + source: 'transcript' + }, + { + id: 'progress-2', + role: 'assistant', + blocks: [{ type: 'text', text: 'Build finished, checking output.' }], + timestamp: at + 25_000, + source: 'transcript' + } + ]) + setStatus({ state: 'working' }, 30_000) + + renderPane() + + expect(screen.getByText('Working for 30s')).toBeInTheDocument() + expect(screen.getByText('Starting the build.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + }) + + it('stays quiet on a settled turn it never watched', () => { + retained.session = transcript('ready', false) + setStatus({ state: 'done' }, 60_000) + + renderPane() + + expect(screen.queryByText(/Awaiting user input/)).toBeNull() + expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull() + expect(liveActivityLine()).toBeNull() + }) + + it('stays quiet when the pane has no status at all', () => { + retained.session = transcript('ready', false) + + renderPane() + + expect(screen.queryByText(/Awaiting user input/)).toBeNull() + expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull() + expect(liveActivityLine()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 5689cf9689f..1f97046363c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -52,8 +52,7 @@ type StructuredSessionMessageListProps = { allowFileUriLinks?: boolean isVisible?: boolean onLinkClick?: (...args: unknown[]) => void - showTurnStatus?: boolean - showLiveTurnActivity?: boolean + awaitingInput?: 'shown' | 'unshown' | null isWorking?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e766955b83e..9a40f63530d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -177,8 +177,8 @@ describe('NativeChatStructuredSession', () => { expect(mocks.loadOlder).toHaveBeenCalledOnce() }) - // Turn status and transcript image previews shipped Codex-first. Every - // structured session renders through the same list, so neither is agent-gated. + // Transcript image previews shipped Codex-first. Every structured session + // renders through the same list, so they are not agent-gated. it.each(['codex', 'claude'] as const)( 'renders the same structured transcript chrome for %s', (agent) => { @@ -193,7 +193,6 @@ describe('NativeChatStructuredSession', () => { /> ) - expect(mocks.messageListProps?.showTurnStatus).toBe(true) expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined() } ) @@ -216,7 +215,7 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps).toMatchObject({ isWorking: true, - showLiveTurnActivity: false + awaitingInput: 'shown' }) expect( document @@ -231,13 +230,13 @@ describe('NativeChatStructuredSession', () => { itemId: 'legacy-question-item', expectedRevision: 1 }) - expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) + expect(mocks.messageListProps?.awaitingInput).toBe('shown') mocks.promptItems = [] rerender(view()) expect(mocks.messageListProps).toMatchObject({ isWorking: true, - showLiveTurnActivity: true + awaitingInput: null }) expect(screen.getByTestId('structured-composer')).toBeTruthy() expect(mocks.composerProps?.isWorking).toBe(true) @@ -285,7 +284,7 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps).toMatchObject({ isWorking: true, - showLiveTurnActivity: false + awaitingInput: 'shown' }) expect(mocks.approvalCardProps?.approval.title).toBe('Allow command?') expect(screen.queryByTestId('structured-composer')).toBeNull() @@ -296,7 +295,7 @@ describe('NativeChatStructuredSession', () => { kind: 'option', optionId: 'allow' }) - expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) + expect(mocks.messageListProps?.awaitingInput).toBe('shown') act(() => mocks.approvalCardProps?.onCancel?.()) expect(mocks.cancel).toHaveBeenCalledWith('turn-approval', { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 26f04ae2e3c..a6bb8ad8d28 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -281,8 +281,7 @@ export function NativeChatStructuredSession( settledTurns={controller.settledTurns} activeTurnOpenedBy={controller.activeTurnOpenedBy} turnKeysByItemId={controller.turnKeysByItemId} - showTurnStatus - showLiveTurnActivity={prompt === null} + awaitingInput={prompt === null ? null : 'shown'} turnActivity={controller.turnActivity} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 0e6994a7b8b..81a8f53300f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({ messageListProps: null as null | { allowFileUriLinks?: boolean onLinkClick?: (...args: unknown[]) => void - showTurnStatus?: boolean runtimeContext?: unknown }, composerProps: null as null | { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx index 870bf39a409..b4257648efa 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx @@ -231,7 +231,7 @@ describe('NativeChatToolRun awaiting-input row', () => { /> ) - expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() + expect(screen.getByText('Awaiting user input')).toBeInTheDocument() expect(screen.queryByText(/request_user_input/)).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 141ae739886..a6cb100ff58 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -58,7 +58,6 @@ export function NativeChatToolRun({ activeTurnIsWorking, trailing, expandOverride, - structuredActivityUi = true, disclosureId, onLinkClick }: { @@ -81,7 +80,6 @@ export function NativeChatToolRun({ * the agent has already moved past reads as settled even mid-call. Left * unset, a working turn's run is taken to be its last. */ trailing?: boolean - structuredActivityUi?: boolean /** Message this run belongs to. Windowing unmounts rows, so a run the reader * opened has to be remembered somewhere that outlives the row. */ disclosureId?: string @@ -130,10 +128,9 @@ export function NativeChatToolRun({ // caller with no turn state, or a turn blocked on the reader's answer, falls // back to the calls themselves. const live = - structuredActivityUi && - (activeTurnIsWorking === true && !askIsActive + activeTurnIsWorking === true && !askIsActive ? trailing !== false - : selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null) + : selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null // One sentence for the whole run, or the command itself when the run is one // call — the reader recognizes `git push` faster than "Ran 1 command". const runSentence = nativeChatToolRunSentence(headerBlocks, { live }) @@ -199,7 +196,6 @@ export function NativeChatToolRun({ // the grouped row visible here made a failed child command look like the // whole response was still running (or had failed) even while collapsed. if ( - structuredActivityUi && expandOverride === false && !(revealedDiff && open) && !live && @@ -237,7 +233,7 @@ export function NativeChatToolRun({ aria-live="polite" data-native-chat-tool-run-state={live ? 'live' : 'settled'} > - {structuredActivityUi && settledHeaderIcon ? ( + {settledHeaderIcon ? ( ) : null} {/* The run in words, in the transcript's own type. Present tense while @@ -278,7 +274,7 @@ export function NativeChatToolRun({ {/* Only a stated success is marked done — see nativeChatToolRunOutcome — and never while live: between two calls nothing is running, and a mark that appeared then would flash on every call. */} - {structuredActivityUi && !live && runSucceeded ? ( + {!live && runSucceeded ? ( ) : null} {latestCallLabel ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 54499e3dc31..91e71df2140 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -13,7 +13,6 @@ import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-t * object so a row's props change only when that row's own slot does. */ export type NativeChatTranscriptRowContext = { expandSignal: boolean - showTurnStatus: boolean revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap expandedTurnIds: ReadonlySet @@ -74,7 +73,6 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ onLinkClick={context.onLinkClick} allowFileUriLinks={context.allowFileUriLinks} deliveryNotice={context.deliveryNotices?.get(message.id)} - structuredActivityUi={context.showTurnStatus} folded={slot.folded} subagentLabel={slot.subagentLabel} runtimeContext={context.runtimeContext} diff --git a/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx b/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx deleted file mode 100644 index 59969c1dece..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { translate } from '@/i18n/i18n' - -export function NativeChatTypingIndicatorRow(): React.JSX.Element { - return ( -
    -
    - {[0, 1, 2].map((i) => ( - - ))} -
    -
    - ) -} diff --git a/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts b/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts index e3097ca085f..d61d11b9ac1 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts @@ -27,6 +27,9 @@ export function isNativeChatTranscriptUnsettled(phase: ReadState['phase']): bool export type NativeChatLiveSession = NativeChatSession & { /** Latest provider turn boundary, used to settle orphaned running tool rows. */ transcriptLifecycle?: NativeChatTurnLifecycle + /** The pane's hook says the agent stopped mid-turn for the reader, and the transcript + * has not since ended that turn. Absent on lanes with no hook status. */ + hookAwaitingInput?: boolean /** True when an older page may still exist (the last read filled the window). */ hasMore: boolean /** Whether an older-history page is currently loading. */ diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts b/src/renderer/src/components/native-chat/native-chat-live-status.test.ts index ce8a2875df0..9dcd683dcd9 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status' import { selectNativeChatViewState } from './native-chat-view-state' import { shouldShowNativeChatWorking } from './native-chat-working-suppression' @@ -154,6 +154,36 @@ describe('mergeNativeChatLiveSession', () => { expect(session.status).toBe('ready') }) + it("keeps omp working while its latest row is a tool call written after the turn's start", () => { + // Shaped as decodeOmpTranscriptLine emits them: every omp row carries its timestamp. + const turnStartedAt = Date.parse('2026-09-28T10:00:00.000Z') + const toolCall: NativeChatMessage = { + id: 'rec-2', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Running the suite.' }, + { type: 'tool-call', name: 'bash', input: { command: 'pnpm test' } } + ], + timestamp: turnStartedAt + 4_000, + source: 'transcript' + } + const session = mergeNativeChatLiveSession({ + messages: [{ ...user('rec-1', 'run the tests'), timestamp: turnStartedAt }, toolCall], + sessionId: 'sess', + agent: 'omp', + hookState: 'working', + stateStartedAt: turnStartedAt + }) + expect(session.status).toBe('working') + expect( + shouldShowNativeChatWorking({ + isConversation: true, + working: session.status === 'working', + interrupted: false + }) + ).toBe(true) + }) + it('keeps working while the hook reports a live background child', () => { const session = mergeNativeChatLiveSession({ messages: [assistant('a-1', 'lead done')], @@ -201,7 +231,7 @@ describe('mergeNativeChatLiveSession', () => { }).status ).toBe('working') // Regression: a non-null sessionId used to force 'loading' over live work, - // so the pane rendered idle mid-turn — Send instead of Stop, no typing + // so the pane rendered idle mid-turn — Send instead of Stop, no working // indicator, no streaming preview. The empty-transcript loading SURFACE is // selectNativeChatViewState's job; the status must stay 'working'. expect( @@ -249,7 +279,7 @@ describe('mergeNativeChatLiveSession', () => { // The whole chain the defect broke: a fresh Claude session reports its id // before the transcript flushes, so the pane rendered Send (not Stop) with no - // typing indicator while the agent was working. + // working indicator while the agent was working. it('keeps the Stop affordance for a working known session mid-flush', () => { const session = mergeNativeChatLiveSession({ messages: [user('u-1', 'run it')], @@ -271,3 +301,46 @@ describe('mergeNativeChatLiveSession', () => { ).toBe(true) }) }) + +describe('nativeChatHookAwaitsInput', () => { + it.each(['waiting', 'blocked'] as const)('reads a %s hook as a wait on the reader', (state) => { + expect(nativeChatHookAwaitsInput(state, 5, undefined)).toBe(true) + expect( + nativeChatHookAwaitsInput(state, 5, { state: 'working', turnId: 'turn-1', timestamp: 1 }) + ).toBe(true) + }) + + it('reads no other hook state as a wait', () => { + expect(nativeChatHookAwaitsInput('working', 5, undefined)).toBe(false) + expect(nativeChatHookAwaitsInput('done', 5, undefined)).toBe(false) + expect(nativeChatHookAwaitsInput(null, 5, undefined)).toBe(false) + }) + + // An interrupt at the prompt fires no hook; the transcript's marker is the only end. + it('ends a wait the transcript closed after it began', () => { + expect( + nativeChatHookAwaitsInput('waiting', 5, { + state: 'interrupted', + turnId: 'turn-1', + timestamp: 6 + }) + ).toBe(false) + expect( + nativeChatHookAwaitsInput('waiting', 5, { + state: 'completed', + turnId: 'turn-1', + timestamp: 6 + }) + ).toBe(false) + }) + + it('keeps a wait that began after the last closed turn', () => { + expect( + nativeChatHookAwaitsInput('waiting', 5, { + state: 'interrupted', + turnId: 'turn-1', + timestamp: 2 + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index eefd7fcbeca..f3d2bb58be9 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -63,14 +63,15 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat } const status = liveStatusOverride( + agent, hookState, statusTailMessage ?? messages.at(-1), stateStartedAt, transcriptLifecycle, hookHasWorkingSubagents ?? false ) - // Why live work still wins: 'working' is what drives Stop-vs-Send, the typing - // indicator and the streaming preview, so forcing 'loading' over it renders an + // Why live work still wins: 'working' is what drives Stop-vs-Send, the turn + // status and the streaming preview, so forcing 'loading' over it renders an // idle pane while the agent works. A known session with nothing to show yet is // held on the loading surface by selectNativeChatViewState instead. if (loading && status !== 'working') { @@ -84,10 +85,36 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat } } +/** + * Whether the agent stopped mid-turn for the reader (`blocked` and `waiting` both mean a human + * must decide). Reconciled like 'working': an interrupt at the prompt fires no hook, so only the + * transcript's terminal marker can end a wait the hook never cleared. + */ +export function nativeChatHookAwaitsInput( + hookState: AgentStatusState | null, + stateStartedAt: number | null | undefined, + transcriptLifecycle: NativeChatTurnLifecycle | undefined +): boolean { + return ( + (hookState === 'blocked' || hookState === 'waiting') && + !lifecycleTerminatesCurrentTurn(transcriptLifecycle, stateStartedAt) + ) +} + /** Slack for comparing transcript timestamps to hook receipt times across hosts. */ export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 +/** + * Agents whose own runtime reports the turn end: omp posts `agent_end` from inside the agent, + * retried until the host takes it and held back while the run continues. Their hook 'working' + * is never a dropped Stop, so a trailing assistant row (a tool call mid-run) cannot end it. + */ +function hookDeliversTurnEnd(agent: AgentType): boolean { + return agent === 'omp' +} + function liveStatusOverride( + agent: AgentType, hookState: AgentStatusState | null, statusTailMessage: NativeChatMessage | undefined, stateStartedAt: number | null | undefined, @@ -121,6 +148,7 @@ function liveStatusOverride( // do not settle early on capable providers. if ( transcriptLifecycle?.state !== 'working' && + !hookDeliversTurnEnd(agent) && trailingAssistantPostDates(statusTailMessage, stateStartedAt) ) { return undefined diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index fa4995b4755..3a08c6a503e 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -47,7 +47,6 @@ function slotsOf(messages: NativeChatMessage[]) { receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), - showTurnStatus: false, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index ba8e6fa8f21..b1efe034acd 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -86,7 +86,6 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), - showTurnStatus: true, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts b/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts new file mode 100644 index 00000000000..4e77c18ace6 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatMessage +} from '../../../../shared/native-chat-types' +import { + nativeChatHookLatestTurnWorkedSeconds, + nativeChatHookTurnStartedAt, + nativeChatLatestTurnId, + nativeChatTranscriptSettledTurns, + resolveNativeChatTerminalTurn +} from './native-chat-terminal-turn' + +const idle = { + isConversation: true, + working: false, + hookAwaitingInput: false, + interrupted: false, + hasPromptCard: false +} + +describe('resolveNativeChatTerminalTurn', () => { + it('keeps the turn running while the agent waits, without calling it generating', () => { + expect(resolveNativeChatTerminalTurn({ ...idle, hookAwaitingInput: true })).toEqual({ + isWorking: false, + turnActive: true, + awaitingInput: 'unshown' + }) + }) + + it('lets a prompt card speak for the wait', () => { + expect( + resolveNativeChatTerminalTurn({ ...idle, hookAwaitingInput: true, hasPromptCard: true }) + ).toEqual({ isWorking: false, turnActive: true, awaitingInput: 'shown' }) + }) + + it('reports a generating turn with nothing awaited', () => { + expect(resolveNativeChatTerminalTurn({ ...idle, working: true })).toEqual({ + isWorking: true, + turnActive: true, + awaitingInput: null + }) + }) + + // Stop is the reader's word that the turn is over, whatever the hook still says. + it('ends the turn on local Stop, wait included', () => { + expect( + resolveNativeChatTerminalTurn({ + ...idle, + working: true, + hookAwaitingInput: true, + interrupted: true + }) + ).toEqual({ isWorking: false, turnActive: false, awaitingInput: null }) + }) + + it('stays quiet with no turn at all', () => { + expect(resolveNativeChatTerminalTurn(idle)).toEqual({ + isWorking: false, + turnActive: false, + awaitingInput: null + }) + }) +}) + +describe('nativeChatHookTurnStartedAt', () => { + const entry = { state: 'working' as const, stateStartedAt: 3_000 } + + // Answered in the terminal: the current state began at 3s, the turn at 1s. + it("dates the turn by the host's stamp, not the state it came back from", () => { + expect(nativeChatHookTurnStartedAt({ ...entry, turnStartedAt: 1_000 })).toBe(1_000) + }) + + // A background task held the row 'working' since 500; the main agent's turn began at 8s. + it('keeps the stamp when child work holds the row open', () => { + expect( + nativeChatHookTurnStartedAt({ + ...entry, + stateStartedAt: 500, + turnStartedAt: 8_000, + mainAgent: { state: 'working', stateStartedAt: 8_000 } + }) + ).toBe(8_000) + }) + + it("falls back to the main agent's own state start on a host that stamps no turn", () => { + expect( + nativeChatHookTurnStartedAt({ + ...entry, + stateStartedAt: 500, + mainAgent: { state: 'working', stateStartedAt: 8_000 } + }) + ).toBe(8_000) + }) + + it('falls back to the row once the main agent is done, or where it has none', () => { + expect( + nativeChatHookTurnStartedAt({ ...entry, mainAgent: { state: 'done', stateStartedAt: 2_500 } }) + ).toBe(3_000) + expect(nativeChatHookTurnStartedAt(entry)).toBe(3_000) + }) + + it('knows nothing without a status row', () => { + expect(nativeChatHookTurnStartedAt(undefined)).toBeNull() + }) +}) + +describe('nativeChatHookLatestTurnWorkedSeconds', () => { + const done = { state: 'done' as const, stateStartedAt: 91_000, turnStartedAt: 1_000 } + + it("times a turn the host ended from its turn start to the main agent's done", () => { + expect(nativeChatHookLatestTurnWorkedSeconds(done, false)).toBe(90) + // Child work still holds the row; the main agent's own end closes the turn. + expect( + nativeChatHookLatestTurnWorkedSeconds( + { ...done, state: 'working', mainAgent: { state: 'done', stateStartedAt: 31_000 } }, + false + ) + ).toBe(30) + }) + + // Staleness ages the row out of the pane, not the host's record that the turn finished. + it('keeps a host-ended duration once the row is stale', () => { + expect(nativeChatHookLatestTurnWorkedSeconds(done, true)).toBe(90) + }) + + it('hides a local end while the host went quiet mid-turn', () => { + expect( + nativeChatHookLatestTurnWorkedSeconds( + { ...done, state: 'waiting', stateStartedAt: 2_000 }, + true + ) + ).toBeNull() + }) + + // A pane-side end (Stop, the transcript's end marker) is the pane's own word; keep what it saw. + it('keeps the local reading while the host is fresh and the turn not host-ended', () => { + expect( + nativeChatHookLatestTurnWorkedSeconds( + { ...done, state: 'waiting', stateStartedAt: 2_000 }, + false + ) + ).toBeUndefined() + }) + + it('invents no duration without the host turn stamp, or across a session boundary', () => { + expect( + nativeChatHookLatestTurnWorkedSeconds({ state: 'done', stateStartedAt: 91_000 }, false) + ).toBeUndefined() + expect( + nativeChatHookLatestTurnWorkedSeconds({ ...done, sessionBoundary: true }, false) + ).toBeUndefined() + expect(nativeChatHookLatestTurnWorkedSeconds(undefined, true)).toBeUndefined() + }) +}) + +function row( + id: string, + role: NativeChatMessage['role'], + timestamp: number | null +): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text: id }], timestamp, source: 'transcript' } +} + +describe('nativeChatTranscriptSettledTurns', () => { + it('times each finished turn from its prompt to its last row, leaving the latest out', () => { + const settled = nativeChatTranscriptSettledTurns([ + row('u1', 'user', 10_000), + row('a1', 'assistant', 20_000), + row('a2', 'assistant', 55_900), + row('u2', 'user', 70_000), + row('a3', 'assistant', 80_000) + ]) + expect([...settled]).toEqual([['u1', { startedAt: 10_000, workedSeconds: 45 }]]) + }) + + it('ends an interrupted turn at its interruption', () => { + const settled = nativeChatTranscriptSettledTurns([ + row('u1', 'user', 0), + row('a1', 'assistant', 5_000), + { + ...row('stop', 'system', 12_000), + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }] + }, + row('u2', 'user', 60_000) + ]) + expect(settled.get('u1')).toEqual({ startedAt: 0, workedSeconds: 12 }) + }) + + // An attachment row sits beside the next prompt; it does not stretch the turn before it. + it('does not end a turn at a system row the agent did not write', () => { + const settled = nativeChatTranscriptSettledTurns([ + row('u1', 'user', 0), + row('a1', 'assistant', 5_000), + row('@src/index.ts', 'system', 3_600_000), + row('u2', 'user', 3_601_000) + ]) + expect(settled.get('u1')).toEqual({ startedAt: 0, workedSeconds: 5 }) + }) + + // A harness notice (task notification, reminder) is user-role in the transcript but not a prompt. + it('times a turn across a harness notice injected mid-turn', () => { + const settled = nativeChatTranscriptSettledTurns([ + row('u1', 'user', 0), + row('a1', 'assistant', 5_000), + { + ...row('notice', 'user', 10_000), + blocks: [{ type: 'text', text: '\nb1' }] + }, + row('a2', 'assistant', 30_000), + row('u2', 'user', 60_000) + ]) + expect([...settled]).toEqual([['u1', { startedAt: 0, workedSeconds: 30 }]]) + }) + + // Absent, not null: null would also hide the duration the pane measured itself. + it('leaves out a turn missing either end', () => { + const settled = nativeChatTranscriptSettledTurns([ + row('u1', 'user', null), + row('a1', 'assistant', 5_000), + row('u2', 'user', 10_000), + row('a2', 'assistant', null), + row('u3', 'user', 20_000) + ]) + expect(settled.size).toBe(0) + }) +}) + +describe('nativeChatLatestTurnId', () => { + it('names the last prompt, not a harness notice after it', () => { + expect( + nativeChatLatestTurnId([ + row('u1', 'user', 0), + row('a1', 'assistant', 5_000), + { + ...row('notice', 'user', 10_000), + blocks: [{ type: 'text', text: '\nb1' }] + } + ]) + ).toBe('u1') + expect(nativeChatLatestTurnId([row('a1', 'assistant', 0)])).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts b/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts new file mode 100644 index 00000000000..2926c5d75f2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts @@ -0,0 +1,132 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + isInterruptedStatusMessage, + type NativeChatMessage +} from '../../../../shared/native-chat-types' +import type { + NativeChatSettledTurn, + NativeChatSettledTurns +} from '../../../../shared/native-chat-turn-status' +import { isNoiseMessage } from '../../../../shared/native-chat-noise' +import type { NativeChatAwaitingInput } from './NativeChatMessageList' +import { shouldShowNativeChatWorking } from './native-chat-working-suppression' + +export type NativeChatTerminalTurn = { + /** The agent is generating: drives Stop-vs-Send and the streaming preview. */ + isWorking: boolean + /** The turn is running, including while its agent waits on the reader, as a + * structured turn runs on behind its prompt card: its clock keeps counting. */ + turnActive: boolean + awaitingInput: NativeChatAwaitingInput | null +} + +/** The turn facts a terminal-backed pane feeds the shared turn-status UI. */ +export function resolveNativeChatTerminalTurn(args: { + isConversation: boolean + /** Hook 'working', reconciled with the transcript's turn boundaries. */ + working: boolean + /** Hook 'waiting'/'blocked', reconciled the same way. */ + hookAwaitingInput: boolean + /** Local Stop suppression. */ + interrupted: boolean + /** The pane draws the agent's prompt as a card. */ + hasPromptCard: boolean +}): NativeChatTerminalTurn { + const { isConversation, working, hookAwaitingInput, interrupted, hasPromptCard } = args + const turnActive = shouldShowNativeChatWorking({ + isConversation, + working: working || hookAwaitingInput, + interrupted + }) + return { + isWorking: shouldShowNativeChatWorking({ isConversation, working, interrupted }), + turnActive, + // A prompt only the terminal shows is the one wait the transcript has to report. + awaitingInput: hasPromptCard ? 'shown' : turnActive && hookAwaitingInput ? 'unshown' : null + } +} + +type NativeChatHookTurnEntry = Pick< + AgentStatusEntry, + 'state' | 'stateStartedAt' | 'turnStartedAt' | 'mainAgent' | 'sessionBoundary' +> + +/** + * When the pane's current turn began: the host's stamp from the main agent's own turn-opening + * event, which survives a remount, a reload and child work holding the row open. A host too old to + * stamp it leaves the current state's start, the main agent's while it is mid-turn. + */ +export function nativeChatHookTurnStartedAt( + entry: NativeChatHookTurnEntry | undefined +): number | null { + if (!entry) { + return null + } + if (entry.turnStartedAt !== undefined) { + return entry.turnStartedAt + } + return entry.mainAgent && entry.mainAgent.state !== 'done' + ? entry.mainAgent.stateStartedAt + : entry.stateStartedAt +} + +/** + * The latest turn's duration by the host, for a turn the pane is not running: a number once the + * main agent is done (its done stamp minus the host's turn start), null when the host went quiet + * mid-turn (nothing says the turn ended, so a locally measured end would be a false claim), and + * undefined to keep what the pane observed (an old host, a session boundary, a pane-side end). + */ +export function nativeChatHookLatestTurnWorkedSeconds( + entry: NativeChatHookTurnEntry | undefined, + hookSilent: boolean +): number | null | undefined { + if (!entry) { + return undefined + } + const mainAgent = entry.mainAgent ?? entry + if (mainAgent.state === 'done') { + return entry.sessionBoundary === true || entry.turnStartedAt === undefined + ? undefined + : Math.max(0, Math.floor((mainAgent.stateStartedAt - entry.turnStartedAt) / 1000)) + } + return hookSilent ? null : undefined +} + +/** The transcript's latest turn: its last prompt, harness notices aside. */ +export function nativeChatLatestTurnId(messages: readonly NativeChatMessage[]): string | null { + const latest = messages.findLast((message) => message.role === 'user' && !isNoiseMessage(message)) + return latest?.id ?? null +} + +/** + * Durations of the transcript's finished turns, from its own timestamps (one clock): a turn runs + * from its prompt to the agent's last timestamped row or its interruption. Other system rows (file + * mentions, extension notes) can land long after, next to the following prompt. The latest turn is + * left out, since nothing here says it has ended, and so is a turn missing either end, which keeps + * what the pane observed. + */ +export function nativeChatTranscriptSettledTurns( + messages: readonly NativeChatMessage[] +): NativeChatSettledTurns { + const settled = new Map() + let turn: { id: string; startedAt: number | null; endedAt: number | null } | null = null + for (const message of messages) { + // A harness notice is user-role but draws no row, so it neither starts nor extends a turn. + if (message.role !== 'user' || isNoiseMessage(message)) { + const agentRow = + message.role === 'system' ? isInterruptedStatusMessage(message) : message.role !== 'user' + if (turn && agentRow && message.timestamp != null) { + turn.endedAt = Math.max(turn.endedAt ?? message.timestamp, message.timestamp) + } + continue + } + if (turn?.startedAt != null && turn.endedAt != null) { + settled.set(turn.id, { + startedAt: turn.startedAt, + workedSeconds: Math.max(0, Math.floor((turn.endedAt - turn.startedAt) / 1000)) + }) + } + turn = { id: message.id, startedAt: message.timestamp, endedAt: null } + } + return settled +} diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 21e48166e9c..4c908375576 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -43,7 +43,6 @@ function build( receipts: new Map(), turnStatuses: NO_STATUSES, turnDiffs: new Map(), - showTurnStatus: true, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 1b20671ce24..b3631fb8012 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -75,7 +75,6 @@ export type NativeChatTranscriptSlotsInput = { completedByTurn: Readonly> } turnDiffs: ReadonlyMap - showTurnStatus: boolean /** Turns the reader opened. Everything else with a duration stays folded. */ expandedTurnKeys: ReadonlySet isWorking: boolean @@ -95,7 +94,6 @@ export function buildNativeChatTranscriptSlots( receipts, turnStatuses, turnDiffs, - showTurnStatus, expandedTurnKeys, isWorking, lifecycleWorking, @@ -141,11 +139,9 @@ export function buildNativeChatTranscriptSlots( } } const settledTurnKeys = new Set( - showTurnStatus - ? Object.entries(turnStatuses.completedByTurn) - .filter(([, status]) => status.workedSeconds != null) - .map(([turnKey]) => turnKey) - : [] + Object.entries(turnStatuses.completedByTurn) + .filter(([, status]) => status.workedSeconds != null) + .map(([turnKey]) => turnKey) ) const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({ rows: foldRows, @@ -180,7 +176,7 @@ export function buildNativeChatTranscriptSlots( : turnStatuses.completedByTurn[turnKey] : undefined // The live turn's bar carries its running clock; it settles in place. - const status = showTurnStatus ? (candidateStatus ?? undefined) : undefined + const status = candidateStatus ?? undefined const turnDiff = turnKey && lastRowByTurn.get(turnKey) === index ? turnDiffs.get(turnKey) : undefined const folded = foldedRows.has(index) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts deleted file mode 100644 index b53b217eb15..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' -import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' - -function message(id: string, role: NativeChatMessage['role'], text = id): NativeChatMessage { - return { id, role, blocks: [{ type: 'text', text }], timestamp: null, source: 'transcript' } -} - -describe('shouldShowNativeChatTypingIndicator', () => { - it('stays hidden when the session is idle', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [message('u1', 'user')], - isWorking: false - }) - ).toBe(false) - }) - - it('shows once a send lands and no assistant row exists yet', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [message('a0', 'assistant'), message('u1', 'user')], - isWorking: true - }) - ).toBe(true) - }) - - it('hides as soon as the structured reply row arrives, before working clears', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [message('u1', 'user'), message('orca-item', 'assistant')], - isWorking: true - }) - ).toBe(false) - }) - - it('hides behind the PTY streaming bubble', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [message('u1', 'user'), message(NATIVE_CHAT_STREAMING_ID, 'assistant')], - isWorking: true - }) - ).toBe(false) - }) - - it('does not flicker back on when a system row interleaves mid-turn', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [ - message('u1', 'user'), - message('a1', 'assistant'), - message('s1', 'system', 'Ran /status') - ], - isWorking: true - }) - ).toBe(false) - }) - - it('shows again for the next send even though an earlier turn replied', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [message('u1', 'user'), message('a1', 'assistant'), message('u2', 'user')], - isWorking: true - }) - ).toBe(true) - }) - - it('does not let an unresolved tool from an earlier turn hide the next send indicator', () => { - const earlierRunningTool: NativeChatMessage = { - id: 'tool-old', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' - } - expect( - shouldShowNativeChatTypingIndicator({ - messages: [earlierRunningTool, message('a1', 'assistant'), message('u2', 'user')], - isWorking: true - }) - ).toBe(true) - }) - - it('shows after a slash-command marker even though an earlier turn replied', () => { - expect( - shouldShowNativeChatTypingIndicator({ - messages: [ - message('a1', 'assistant'), - message('command:compact', 'system', 'Ran /compact') - ], - isWorking: true - }) - ).toBe(true) - }) - - it('shows on a session whose transcript is still empty', () => { - expect(shouldShowNativeChatTypingIndicator({ messages: [], isWorking: true })).toBe(true) - }) -}) - -// These build rows through the REAL structured projection instead of hand-made -// `command:` marker ids. The hand-made ids only exist on the PTY transport, so -// tests using them were blind to how the shipping transport actually looks. -describe('with rows projected from the structured journal', () => { - function toolCallItem(sequence: number): AgentJournalRenderItem { - return { - itemId: `codex:thread-1:turn-1:${sequence}`, - revision: 1, - sequence, - observedAt: 1_800_000_000_000, - body: { - kind: 'tool-call', - name: 'shell', - state: 'running', - input: { command: 'sed -n 1,240p README.md' } - } - } as AgentJournalRenderItem - } - - function assistantTextItem(sequence: number): AgentJournalRenderItem { - return { - itemId: `codex:thread-1:turn-1:${sequence}`, - revision: 1, - sequence, - observedAt: 1_800_000_000_000, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: "I'm checking PR 14696's metadata." }] - } - } as AgentJournalRenderItem - } - - it('stays visible beside the structured live tool row while a command runs', () => { - const messages = projectStructuredItemsToNativeChat([assistantTextItem(1), toolCallItem(2)]) - expect(messages.at(-1)?.role).toBe('assistant') - expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(true) - }) - - it('hides once prose is the newest row', () => { - const messages = projectStructuredItemsToNativeChat([toolCallItem(1), assistantTextItem(2)]) - expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(false) - }) - - it('stays hidden when the turn is not working, command row or not', () => { - const messages = projectStructuredItemsToNativeChat([toolCallItem(1)]) - expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: false })).toBe(false) - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts deleted file mode 100644 index 144560278a2..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' -import { isCommandMarkerId } from './native-chat-command-marker' - -function isToolActivityOnlyRow(message: NativeChatMessage): boolean { - const blocks = message.blocks - if (!blocks || blocks.length === 0) { - return false - } - return blocks.every((block) => block.type === 'tool-call' || block.type === 'tool-result') -} - -export function shouldShowNativeChatTypingIndicator(args: { - messages: readonly NativeChatMessage[] - isWorking: boolean -}): boolean { - if (!args.isWorking) { - return false - } - const { messages } = args - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index] - if (!message || message.role === 'user' || isCommandMarkerId(message.id)) { - return true - } - if (isToolActivityOnlyRow(message)) { - return true - } - if (message.role === 'assistant' || message.id === NATIVE_CHAT_STREAMING_ID) { - return false - } - } - return true -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts new file mode 100644 index 00000000000..a13df87c3e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import type { AgentStatusPayload } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const { useAppStore } = await import('../../store') +const { useNativeChatInteractivePromptCard } = + await import('./use-native-chat-interactive-prompt-card') + +const paneKey = 'tab-card:leaf-card' +const NO_MESSAGES: readonly NativeChatMessage[] = [] + +function setStatus(payload: Omit): void { + useAppStore + .getState() + .setAgentStatus(paneKey, { prompt: 'Rename', agentType: 'claude', ...payload }) +} + +afterEach(() => { + cleanup() + useAppStore.setState({ agentStatusByPaneKey: {} }) +}) + +describe('useNativeChatInteractivePromptCard', () => { + // The hook runs in the pane's view, so each render here re-renders the whole transcript. + it('does not re-render for a tool call that carries no prompt', () => { + setStatus({ state: 'working', toolName: 'Read' }) + let renders = 0 + renderHook(() => { + renders += 1 + return useNativeChatInteractivePromptCard({ + paneKey, + messages: NO_MESSAGES, + transcriptSettled: true + }) + }) + const settled = renders + + act(() => setStatus({ state: 'working', toolName: 'Bash' })) + act(() => setStatus({ state: 'working', toolName: 'Edit' })) + + expect(renders).toBe(settled) + }) + + it('still reads the prompt that arrives with a tool call', () => { + setStatus({ state: 'working', toolName: 'Read' }) + const { result } = renderHook(() => + useNativeChatInteractivePromptCard({ + paneKey, + messages: NO_MESSAGES, + transcriptSettled: true + }) + ) + expect(result.current).toBeNull() + + act(() => + setStatus({ + state: 'waiting', + toolName: 'Bash', + interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf dist' } }) + }) + ) + + expect(result.current).toMatchObject({ kind: 'approval' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts new file mode 100644 index 00000000000..243fea0ac57 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react' +import { useAppStore } from '../../store' +import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + parseInteractivePrompt, + type InteractivePromptCard +} from './native-chat-interactive-prompt' + +/** + * The prompt a terminal-backed pane can draw as a card: a tool approval from the + * live status, else a question from the live status or, failing that, from the + * transcript's unresolved ask (headless host, relay gap, replay, reconnect — a + * pane parked on a selector must not take the next message as its answer, #11761). + */ +export function useNativeChatInteractivePromptCard({ + paneKey, + messages, + transcriptSettled +}: { + paneKey: string + /** Pass the command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */ + messages: readonly NativeChatMessage[] + transcriptSettled: boolean +}): InteractivePromptCard { + const interactivePrompt = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null + ) + // The sibling `toolName` lets the question parser dispatch through the tool's + // registered parser (mobile parity). Read only beside a prompt: it changes on + // every tool call, and this hook re-renders the whole pane. + const interactiveToolName = useAppStore((s) => { + const entry = s.agentStatusByPaneKey[paneKey] + return entry?.interactivePrompt ? (entry.toolName ?? null) : null + }) + return useMemo(() => { + const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined) + if (statusCard?.kind === 'approval') { + return statusCard + } + const prompt = resolveNativeChatAsk({ + liveAsk: statusCard?.prompt ?? null, + messages, + transcriptSettled + }) + return prompt ? { kind: 'question' as const, prompt } : null + }, [interactivePrompt, interactiveToolName, messages, transcriptSettled]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index cd4b701e270..4c7f784eb31 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -547,6 +547,42 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.status).toBe('ready') }) + // Declining a prompt with an interrupt fires no hook, so the row keeps saying + // `waiting`; the transcript's marker is what ends the wait. + it('ends a hook wait once the transcript records the turn interrupted', async () => { + const waitStartedAt = Date.now() - 1_000 + useAppStore.getState().setAgentStatus(PANE, { state: 'waiting', prompt: '', agentType: AGENT }) + const row = useAppStore.getState().agentStatusByPaneKey[PANE] + if (!row) { + throw new Error('the store dropped the status row this test depends on') + } + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { ...row, stateStartedAt: waitStartedAt } } + }) + const transport = getMockTransport('env-1') + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [user('u-1', 'go')], + hasMore: false, + lifecycle: { state: 'working', turnId: 'turn-1', timestamp: waitStartedAt - 500 } + }) + ) + expect(latest?.hookAwaitingInput).toBe(true) + expect(latest?.status).toBe('ready') + + await act(async () => + transport.emit({ + type: 'appended', + messages: [], + lifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: Date.now() } + }) + ) + + expect(latest?.hookAwaitingInput).toBe(false) + }) + it('does not let an older pagination read rewind a live completion', async () => { useAppStore.setState({ agentStatusByPaneKey: { diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index a62237f1b35..0f4a4058155 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -9,7 +9,7 @@ import { createNativeChatMerger, replaceList } from '../../../../shared/native-chat-merge' -import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status' import { hasMoreNativeChatHistory, NATIVE_CHAT_INITIAL_LIMIT, @@ -342,6 +342,11 @@ export function useNativeChatLiveSession( }) return { ...session, + hookAwaitingInput: nativeChatHookAwaitsInput( + hookState, + hookStateStartedAt, + transcriptLifecycle + ), hasMore, loadingEarlier, olderHistoryGeneration, diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index cbbd819ce22..6772eec7a47 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -36,7 +36,6 @@ function slotsOf(messages: NativeChatMessage[]) { receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), - showTurnStatus: false, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false diff --git a/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts new file mode 100644 index 00000000000..13aa5a93977 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react' +import { useAppStore } from '../../store' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' +import { + nativeChatHookLatestTurnWorkedSeconds, + nativeChatHookTurnStartedAt, + nativeChatLatestTurnId, + nativeChatTranscriptSettledTurns +} from './native-chat-terminal-turn' +import { resolveNativeChatHookState } from './use-native-chat-hook-status' + +/** The terminal-backed pane's turn timing for the message list: when its running turn began, by + * the host, and each finished turn's duration (the host's for the latest, the transcript's for + * history). All of it survives a remount of the pane. */ +export function useNativeChatTerminalTurnTiming( + paneKey: string, + messages: readonly NativeChatMessage[], + turnActive: boolean +): { workingStartedAt: number | null; settledTurns: NativeChatSettledTurns } { + // Freshness is time-based: re-read when the scheduler says a silent row aged out. + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + void agentStatusEpoch + const turnStartedAt = useAppStore((s) => + nativeChatHookTurnStartedAt(s.agentStatusByPaneKey[paneKey]) + ) + const latestWorkedSeconds = useAppStore((s) => { + const entry = s.agentStatusByPaneKey[paneKey] + return nativeChatHookLatestTurnWorkedSeconds(entry, resolveNativeChatHookState(entry) === null) + }) + const transcriptSettled = useMemo(() => nativeChatTranscriptSettledTurns(messages), [messages]) + const latestTurnId = useMemo(() => nativeChatLatestTurnId(messages), [messages]) + const settledTurns = useMemo(() => { + if (turnActive || latestWorkedSeconds === undefined || latestTurnId === null) { + return transcriptSettled + } + return new Map(transcriptSettled).set( + latestTurnId, + latestWorkedSeconds === null || turnStartedAt === null + ? null + : { startedAt: turnStartedAt, workedSeconds: latestWorkedSeconds } + ) + }, [latestTurnId, latestWorkedSeconds, transcriptSettled, turnActive, turnStartedAt]) + return { workingStartedAt: turnActive ? turnStartedAt : null, settledTurns } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx index 1f08a51299a..948b83c5dbb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx @@ -23,7 +23,7 @@ function TranscriptHarness({ contentRef, itemCount, isWorking: false, - showTypingIndicator: false, + showsTailRow: false, isVisible, alignToViewportTop: vi.fn(), scrollToEnd, diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts index 23bebf0f1e8..6df6d3eb562 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts @@ -52,7 +52,7 @@ export function useNativeChatTranscriptScroll({ contentRef, itemCount, isWorking, - showTypingIndicator, + showsTailRow, isVisible, alignToViewportTop, scrollToEnd, @@ -64,7 +64,8 @@ export function useNativeChatTranscriptScroll({ contentRef: React.RefObject itemCount: number isWorking: boolean - showTypingIndicator: boolean + /** Whether the list draws a row after the transcript (live activity or a wait). */ + showsTailRow: boolean isVisible: boolean alignToViewportTop: (element: HTMLElement) => void scrollToEnd: () => void @@ -156,7 +157,7 @@ export function useNativeChatTranscriptScroll({ itemCount, isWorking, restoreScrollOffset, - showTypingIndicator, + showsTailRow, scrollToEndWhenMeasurable ]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts index 95db05141d2..33a795b8b9d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts @@ -23,7 +23,7 @@ export function useNativeChatTurnStatus({ activeTurnKey: string isWorking: boolean workingStartedAt?: number | null - /** Host-recorded durations; they outrank whatever this client observed. */ + /** Recorded durations (the host's journal or the transcript); they outrank what this client observed. */ settledTurns?: NativeChatSettledTurns | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 7cabf081db9..8f676cada26 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -47,6 +47,7 @@ export type AgentStatusSetData = { connectionId?: string | null receivedAt: number stateStartedAt: number + turnStartedAt?: number } export type StoreLike = Record export type StoreSubscribeListener = (state: StoreLike, previousState: StoreLike) => void diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 47aa5521a26..41957078f41 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -227,7 +227,8 @@ export function createAgentStatusEventApplicator(args: { ...(data.evidenceObservedAt !== undefined ? { evidenceObservedAt: data.evidenceObservedAt } : {}), - stateStartedAt: data.stateStartedAt + stateStartedAt: data.stateStartedAt, + ...(Number.isFinite(data.turnStartedAt) ? { turnStartedAt: data.turnStartedAt } : {}) }, routing: { tabId: ownerTabId, diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts index e26448f0b63..f8f77eced61 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts @@ -79,7 +79,9 @@ describe('useIpcEvents agent status snapshot integration', () => { toolInput: 'pnpm test', terminalHandle: 'term-future', receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000 + stateStartedAt: 1_699_999_999_000, + // A reload starts with no state history; the host's turn start must arrive with the row. + turnStartedAt: 1_699_999_990_000 } ]) await Promise.resolve() @@ -113,7 +115,11 @@ describe('useIpcEvents agent status snapshot integration', () => { toolInput: 'pnpm test' }), 'SSH Tab', - { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, + { + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + turnStartedAt: 1_699_999_990_000 + }, expectWorktreeRouting('wt-1'), undefined ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 90fc6192a11..4f72d98bb17 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2674,6 +2674,7 @@ "ask": { "asked": "Asked:", "awaiting": "Awaiting user input:", + "awaitingUnnamed": "Awaiting user input", "questionCount": "{{value0}} questions" }, "composer": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5db365466e6..30ac3af4db9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17800,6 +17800,7 @@ }, "ask": { "awaiting": "Awaiting user input:", + "awaitingUnnamed": "Awaiting user input", "asked": "Asked:", "questionCount": "{{value0}} questions" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 04e9bb284ab..f378465a943 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17745,6 +17745,7 @@ "railEmptyMessage": "Message", "ask": { "awaiting": "En attente de la saisie de l'utilisateur :", + "awaitingUnnamed": "En attente de la saisie de l'utilisateur", "asked": "Demandé:", "questionCount": "{{value0}} questions" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 90f55805342..bcb68593d90 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17681,6 +17681,7 @@ "railEmptyMessage": "メッセージ", "ask": { "awaiting": "ユーザー入力を待っています:", + "awaitingUnnamed": "ユーザー入力を待っています", "asked": "質問:", "questionCount": "{{value0}} の質問" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dd0dbdfa8a7..8aa0335d891 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17681,6 +17681,7 @@ "railEmptyMessage": "메시지", "ask": { "awaiting": "사용자 입력을 기다리는 중:", + "awaitingUnnamed": "사용자 입력을 기다리는 중", "asked": "질문:", "questionCount": "{{value0}} 질문" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1514dd25697..70cbc25bdc5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17646,6 +17646,7 @@ "railEmptyMessage": "信息", "ask": { "awaiting": "等待用户输入:", + "awaitingUnnamed": "等待用户输入", "asked": "问:", "questionCount": "{{value0}} 问题" }, diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 5bd9460041a..b78bb317585 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -29,6 +29,8 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string { prompt: entry.prompt, updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), stateStartedAt: entry.stateStartedAt, + // A new turn can keep the state (child work held it open); its stamp alone must republish. + turnStartedAt: entry.turnStartedAt ?? null, agentType: entry.agentType ?? null, terminalTitle: entry.terminalTitle ?? null, stateHistory: entry.stateHistory.map((history) => ({ @@ -77,6 +79,7 @@ describe('mobile agent-status projection equivalence', () => { statusMaps.push({}) statusMaps.push({ 'tab-0:leaf-0': makeEntry(0) }) statusMaps.push({ 'tab-0:leaf-0': makeEntry(0, { workingMode: 'monitoring' }) }) + statusMaps.push({ 'tab-0:leaf-0': makeEntry(0, { turnStartedAt: 1740000000500 }) }) const many: AppState['agentStatusByPaneKey'] = {} for (let index = 0; index < 12; index += 1) { many[`tab-${index}:leaf-0`] = makeEntry(index) diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts index 0adbd768fe5..587e30a769e 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -111,6 +111,7 @@ function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']): prompt: entry.prompt, updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), stateStartedAt: entry.stateStartedAt, + turnStartedAt: entry.turnStartedAt ?? null, agentType: entry.agentType ?? null, terminalTitle: entry.terminalTitle ?? null, stateHistory: entry.stateHistory.map((history) => ({ diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index ba41d169e8c..25b65573839 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -19,6 +19,7 @@ function serializeAgentStatusEntry( prompt: entry.prompt, updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), stateStartedAt: entry.stateStartedAt, + turnStartedAt: entry.turnStartedAt ?? null, agentType: entry.agentType ?? null, terminalTitle: entry.terminalTitle ?? null, stateHistory: entry.stateHistory.map((history) => ({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts index ae51553ea7b..aa466c0f9b5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' -import type { AgentStateHistoryEntry } from '../../../../shared/agent-status-types' -import { sameAgentStateHistory } from './state-equality-core' +import type { + AgentStateHistoryEntry, + AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { agentStatusEntryEqual, sameAgentStateHistory } from './state-equality-core' function doneEntry(mainAgent?: AgentStateHistoryEntry['mainAgent']): AgentStateHistoryEntry { return { state: 'done', prompt: 'ship it', startedAt: 1_000, ...(mainAgent ? { mainAgent } : {}) } @@ -25,3 +28,20 @@ describe('sameAgentStateHistory', () => { ).toBe(false) }) }) + +describe('agentStatusEntryEqual', () => { + // A paired client must take a host row whose only change is a new turn under the same state. + it('sees a new host turn start under an unchanged state', () => { + const entry: AgentStatusEntry = { + state: 'working', + prompt: 'ship it', + updatedAt: 2_000, + stateStartedAt: 1_000, + turnStartedAt: 1_000, + paneKey: 'tab-1:leaf-1', + stateHistory: [] + } + expect(agentStatusEntryEqual(entry, { ...entry })).toBe(true) + expect(agentStatusEntryEqual(entry, { ...entry, turnStartedAt: 1_500 })).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index 197fb698137..407b7f6c2f3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -48,6 +48,7 @@ export function agentStatusEntryEqual( a.prompt === b.prompt && a.updatedAt === b.updatedAt && a.stateStartedAt === b.stateStartedAt && + a.turnStartedAt === b.turnStartedAt && a.agentType === b.agentType && a.paneKey === b.paneKey && a.worktreeId === b.worktreeId && diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index afff64d3303..64ff3e10dcf 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -88,6 +88,8 @@ export type AgentStatusTiming = { /** Observation clock for staleness; see `AgentStatusEntry.evidenceObservedAt`. */ evidenceObservedAt?: number stateStartedAt?: number + /** The host's turn start; see `AgentStatusEntry.turnStartedAt`. */ + turnStartedAt?: number } export type AgentStatusRouting = { diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 28bf952460c..ec80692f95e 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -113,6 +113,11 @@ export function buildAgentStatusLiveEntry( : existing && existing.state === payload.state ? existing.stateStartedAt : updatedAt) + // Why: a writer with no turn clock (an OSC repaint) keeps the host's stamp only within one state; + // a state change it cannot date must not inherit another turn's start. + const turnStartedAt = + timing?.turnStartedAt ?? + (existing && existing.state === payload.state ? existing.turnStartedAt : undefined) if ( existing && shouldSuppressInheritedTerminalStatus({ @@ -205,6 +210,7 @@ export function buildAgentStatusLiveEntry( ...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}), stateStartedAt, stateObservedAt, + ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), agentType: identity.agentType, model: payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined), diff --git a/src/renderer/src/store/slices/agent-status-turn-started-at.test.ts b/src/renderer/src/store/slices/agent-status-turn-started-at.test.ts new file mode 100644 index 00000000000..324b70e075f --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-turn-started-at.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { createTestStore } from './store-test-helpers' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +describe("the host's turn start on a renderer status entry", () => { + it('lands on the entry from the host timing and follows each host write', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude' }, undefined, { + updatedAt: 1_000, + stateStartedAt: 1_000, + turnStartedAt: 900 + }) + expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBe(900) + + // The same prompt sent again: the state never left working, the host stamped a new turn. + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude' }, undefined, { + updatedAt: 2_000, + stateStartedAt: 1_000, + turnStartedAt: 1_900 + }) + expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBe(1_900) + }) + + it('keeps the stamp behind an unchanged state when a writer carries none, and drops it on a state edge', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude' }, undefined, { + updatedAt: 1_000, + stateStartedAt: 1_000, + turnStartedAt: 900 + }) + // A renderer-side OSC repaint has no turn clock of its own. + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Bash' + }) + expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBe(900) + + store.getState().setAgentStatus(PANE, { state: 'done', prompt: 'go', agentType: 'claude' }) + expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBeUndefined() + }) +}) diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index a15282b0018..68398a6d0de 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -55,6 +55,10 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { evidenceObservedAt?: number /** Timestamp (ms) when the current state first appeared for this pane. */ stateStartedAt: number + /** When the main agent's current turn began, stamped by the hook server from the main agent's + * own turn-opening event. Optional: old hosts and turns opened unseen omit it, and readers fall + * back to `stateStartedAt`. */ + turnStartedAt?: number orchestration?: AgentStatusOrchestrationContext providerSession?: AgentProviderSessionMetadata /** Resume identity update only; the status-shaped fields are transport placeholders. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index acc7e4f4255..5a9d34ae7d3 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -95,6 +95,9 @@ export type AgentStatusEntry = { stateStartedAt: number /** `updatedAt` of the write that switched into `state`; see AgentStateHistoryEntry.observedAt. */ stateObservedAt?: number + /** When the main agent's current turn began, as the hook server stamped it. Absent from old hosts + * and writers without a turn clock; readers fall back to `stateStartedAt`. */ + turnStartedAt?: number agentType?: AgentType /** Provider model currently used by this session. */ model?: string diff --git a/src/shared/native-chat-ask-row.ts b/src/shared/native-chat-ask-row.ts index 11900c5c4ea..6d92f244f8e 100644 --- a/src/shared/native-chat-ask-row.ts +++ b/src/shared/native-chat-ask-row.ts @@ -9,6 +9,7 @@ import { pairToolBlocks } from './native-chat-tool-fold' export const NATIVE_CHAT_ASK_ROW_COPY = { awaiting: 'Awaiting user input:', + awaitingUnnamed: 'Awaiting user input', asked: 'Asked:', questionCount: '{{value0}} questions' } as const From c49388cd33917a828b63997537c9710b4f1aec3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:39:49 -0700 Subject: [PATCH 361/387] fix(native-chat): Stop is there from the moment a message is sent (#23026) * refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff ()." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * fix(native-chat): a Stop that names no turn stops what the conversation has in flight Between handing a message to the agent and the agent opening its turn, there is no turn id a client could name, so a Stop in that gap was refused as "already finished" while the agent went on to answer. A cancel's turn id is now an optional precondition instead of its target: with none, the host withdraws what is queued and, when the journal still reads working, asks the adapter to stop whatever the child has in flight. Claude's interrupt is session-scoped, so it is guarded by fence and acquisition generation rather than a turn identity. Codex interrupts the turn its latest turn/start answered with until the journal shows one. A cancel that names its turn behaves exactly as before. * fix(native-chat): Stop is there from the moment a message is sent The composer showed Stop only once the agent had opened a turn, so for the second or two after a send the chat read "thinking" with no way to stop it. Against a host that takes a Stop naming no turn, Stop now shows whenever the chat reads working (a turn, a queued message, or a handed-over one still unanswered) or this client still has a message on its way. Pressing it, or Escape, first drops every outbox entry the journal does not hold yet, so nothing goes out after the Stop, then sends the conversation-wide cancel. A send already on its way reaches the host ahead of the cancel, which withdraws it there. Against an older host Stop still needs a running turn. The unconfirmed-send probe moves into its own hook so the outbox hook stays in budget. * fix(native-chat): Stop before a turn is gated on its own host capability A host that accepts sends first (agent-session.accepted-send.v1) can still predate the cancel that names no turn and would refuse it as invalid, since clients and hosts ship independently. Hosts that take that cancel now advertise agent-session.conversation-stop.v1, and the renderer shows Stop before a turn opens, and sends the no-turn cancel, only to a host advertising it. Every other host keeps a Stop that needs, and names, a running turn. The host capability probe the accepted-send hook used is generalized so both read one path. * test(native-chat): a build advertises conversation stop exactly where its cancel may name no turn * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): Stop reads the one working rule every session list reads While Claude retries a rate-limited request it never echoes the message, so no turn opens: the sidebar read Working from the unanswered send while the composer showed Send. The chat's working state, the host's session-list status and the host's no-turn Stop check now call one shared rule instead of three copies. * test(native-chat): a rate-limit retry pins only that no turn opens, not how its rows are kept * fix(native-chat): Stop leaves a message waiting on its Retry, and does not show for one A send that failed holds the queue until the user retries it, and one the host restarted under is parked the same way. Stop counted both as still on their way, so it showed in an idle chat and could never go away, and pressing it dropped the failed message along with its Retry. * test(native-chat): the chat's Stop and a session list read the main agent alike over their own copies The chat reduces its stream and a list reads the status feed. Driven through the real host for a rate-limit retry with no turn, a subagent still running after the main turn, and the handed-over child exiting. * refactor(mobile): the chat reads the main agent's working state through the shared rule Behaviour is unchanged: the same two terms, now from the one function the host projection and the desktop chat read. * fix(codex): a Stop naming no turn never interrupts an earlier turn It fell back to the id an earlier turn/start answered with when the latest start went unanswered, or when the journal showed a compaction Codex had not started, and reported that as stopped. * fix(native-chat): a Stop naming no turn never says a turn had already finished When the provider found nothing left to stop, for instance a turn that ended between the host's check and the interrupt, the chat got "The provider had already finished this turn." for a turn the Stop never named. It now ends quietly, as a Stop with nothing in flight does. * fix(native-chat): one Stop the host could not settle no longer refuses every later one A Stop naming no turn has one operation key per session. When the host could not settle one, it answered every later Stop under the same id as unknown until the id expired. Once the host says so, the next press is a new Stop; transport doubt still replays the same id. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * test(native-chat): read Stop operation ids without a cast * fix(native-chat): a Stop whose answer was lost no longer swallows the next one A Stop that names no turn has one operation key per chat. When its answer was lost in transit, the chat kept the id, so every later Stop replayed it; the host answers a replay as already handled, so for up to a day Stop stopped nothing. The id is now dropped once the call settles, however it settles. A second press while the first is still on its way still shares its id. * refactor(native-chat): a Stop naming no target keeps its operation id only for its own call The chat kept each write's operation id per payload across calls, and dropped it only on some settle paths. That is right for a write naming what it acts on, but a Stop naming no turn, and a stop of every background task, share one payload with every later one, so any path that kept the id made the next Stop replay as already handled and stop nothing. One path was still open: an answer that arrived after the chat moved to a new fence. Whether a write names its target is now decided once, before its id is picked. One that names none keeps its id only while its call is in flight, so a press made meanwhile joins it, and releases it when the call settles, however it settles. The release runs only while the key still holds that call's id, so a joined call settling late cannot drop a newer one's. This replaces the per-path exceptions for a thrown call. * test(native-chat): read the Stop fences without a cast * test(native-chat): pin the new id for a named cancel the host could not settle After the Stop naming no turn moved to a per-call id, the only test of the unknown-refusal release was gone, and the half that stays, for a cancel naming its turn, could be removed with every test green. * fix(native-chat): a Stop pressed after a new message stops it, even while the last Stop is unanswered A Stop naming no turn shared its operation id with any press made while it was still in flight. The host runs a chat's writes in order, so a message sent between two presses was accepted after the first Stop ran, and the second press replayed that Stop as already handled and left the message running, although the chat had already withdrawn it from the outbox. A write naming no target now gets a new id on every press and is never kept, so each Stop acts on whatever is running when the host reaches it. A write naming its target keeps its id exactly as before. A double press can ask the provider to stop the same turn twice, which it tolerates. * fix(native-chat): Stop no longer blinks off as Claude opens the turn for a message Claude's echo of a sent message both answers the send and opens its turn. The echo settled the send first, so the host published the message as answered one frame before the turn it opened, and for that frame the chat read nothing running: Stop turned back into Send, and Working blinked off in every session list, for tens of milliseconds on each turn. The echo now settles the send after the turn it opens has been emitted, so the running turn is published first. * fix(native-chat): a message a Stop withdrew comes back to its sender's composer A Stop withdraws every message the host holds but has not run, and S also drops the ones this client had not handed over yet. Either way the message left the chat and its text survived only in a hidden journal row and the in-memory ArrowUp history. The sending client now puts the withdrawn text and images back in that pane's composer, after whatever is typed there. Withdrawn is read from the rejection reason through one shared check, which the outbox reconcile now uses too. The composer is written before the entry leaves storage, so a failure between the two repeats the text instead of losing it, and an entry storage no longer holds is never given back again, so a replay, a second view or a remount restores it once. Only this client's outbox holds the entry, so other viewers still see the message disappear. A failed Stop withdraws nothing on the host and gives nothing back. * fix(native-chat): withdrawn text put back during an IME composition is not lost While the IME owns the field, the composer ignores a programmatic draft, and the next composed keystroke wrote the draft without the restored text, after its outbox entry had already been dropped. The composer now holds text appended mid-composition, keeps it in the cache after each composed write, and shows it once the composition settles, the way attachments that land mid-composition already wait for it. * test(native-chat): pin that only a withdrawn message comes back to the composer * test(native-chat): set up the composer's window API for every describe in the composition-race file * docs(native-chat): note that the withdrawn check reads the legacy reason until a typed category lands * test(native-chat): pin that text put back mid-composition shows once, even beside a mid-composition clear * fix(native-chat): land a late settlement from a streamed turn's end after that turn's rows A settlement that says a streamed turn ended waits for the session's event sink to drain before writing its dispatch row. The journal reducer still refuses to overwrite an accepted or rejected send. * fix(codex): settle a send from the end of the turn Codex answered it into The turn/start answer names the turn that holds a send. The send's echo entry now keeps that binding, in memory only. If the bound turn is interrupted without echoing the send, the send is withdrawn: Codex clears a turn's pending input on interrupt, so the model never saw it. If the turn fails first, the send is rejected in Codex's words. A completed turn settles nothing, since Codex records pending input when it finishes and the echo is still due. An answer read after its turn already ended is settled by that end. The echo is still the acceptance and carries the item key. * test(codex): a send settles from the end of the turn Codex answered it into The fake Codex keeps 0.157's turn bookkeeping, and can deliver the turn/start answer after turn/started or after turn/completed. The tests cover: - a Stop before any echo withdraws the send, and the working rule reads idle; - a steered follow-up is withdrawn when the turn is interrupted; - a failed turn rejects the send in Codex's words; - a completed turn leaves the send to its echo; - a normal echo and a late echo; - two steered sends in one turn; - an answer read after the turn ended; - a timed-out answer; - child-thread turns; - how a binding dies. * refactor(native-chat): drop the stream flush before a late turn-end settlement Nothing reads the order of a dispatch row against the turn's terminal row: the reducer keeps a settled send terminal and the working state is derived from both. The echo acceptance on the same path never waited either, and the wait could drop the settlement on a failed sink barrier. * fix(codex): settle a failed turn's sends at its end, not at its error Codex keeps a failed turn's pending input and records it after the error frame, before turn/completed. Settling at the error rejected a steered follow-up the model had in fact received, so a Retry would send it twice. * docs(codex): say a completed turn echoes what it took before it ends Codex records a completed turn's pending input before `turn/completed`, so a bound send that turn never echoed is left for recovery, not awaiting an echo. The comments and one test title said the echo was still due. * test(codex): settle a send whose answer is read after its turn failed or completed A failed turn that ended before the answer rejects the send in Codex's words, once; a completed one leaves it admitted and still armed for its echo. * refactor(codex): read a failed turn's reason with the typed thread-fact reader * fix(native-chat): a Stop that names no turn and ends nothing says why The host sends a Stop naming no turn to the agent only while the chat reads working. When the agent ended nothing, the Stop wrote no row, so it looked ignored. It now writes one: Stop could not reach the agent, in the agent's own words when it refused the interrupt. * fix(codex): hold a cold send until Codex opens its turn, and stop the turn it opened Codex answers turn/start before it opens the turn, and refuses an interrupt until then. A Stop queued behind a cold send ran in that gap, named the answered turn, and was refused. The send's handover now lasts until Codex opens that turn, or provably will not: the turn ended, the primary thread stopped running, or the child ended, bounded by the turn/start deadline. A steered send, whose turn is already open, does not wait. A Stop naming no turn now interrupts the turn the journal shows, else the primary-thread turn Codex reported started and not yet ended. The per-start answered id is gone: it was never cleared at a turn's end. * fix(native-chat): give back a send already on its way only when the host withdraws it Stop took the in-flight send out of the outbox and put its text back in the composer at once. The send still landed ahead of the Stop, so the chat read working for a moment before the host withdrew it, and a send the agent had already taken came back as well. The in-flight send now stays until the host answers it, and the withdrawn-message restore gives it back from that answer. * refactor(native-chat): read a Stop's withdrawal through the rejection classifier dispatchWasWithdrawn matched the legacy reason string. It now asks the classifier, which reads the typed fact first and keeps that string only as its own fallback, so a withdrawal written as a fact with a sentence is still given back to the composer. * fix(native-chat): a Stop Codex took but Orca could not confirm is not reported as reaching nothing When Codex acknowledged the interrupt but Orca could not verify the turn's processes ended, a Stop naming no turn wrote "it had no turn running to stop", though the turn then ended as interrupted. The adapter now says the Stop was taken but unconfirmed, and the row says Cancellation was not confirmed. * fix(codex): a held cold send never delays closing the chat or quitting A cold send's handover waits for Codex to open its turn, and that wait sat in the session queue ahead of the close and quit eviction, so either could wait out the 30 s request deadline. The host now releases those waits before it queues a close or starts quit teardown, and the adapter releases them when it is asked to close the child and on every exit, including one whose end publication is backpressured. * fix(native-chat): a refused Stop says the agent declined, and names it "Stop could not reach the agent" was wrong: the agent was reached and declined. The row now reads "Codex had no turn running to stop." or "Codex didn't stop: .", naming the chat's agent. * fix(codex): a Stop waits for the turn Codex answered to open; the send no longer does Codex answers turn/start before it opens the turn and refuses turn/interrupt until then, so a Stop naming no turn in that window was lost. The send's handover used to wait for the turn to open, and a close or quit needed its own release to get past that wait. Now only the Stop waits. A Stop naming no turn, finding no journal turn and no open one, reads the turn Codex answered the latest pending send into and has neither opened nor ended, and waits for it: bounded at 5 s, under the quit eviction budget, and ended by that turn opening or ending, the thread going idle or failing, or the child exiting. If the turn opened it is stopped; otherwise the Stop reports that Codex had no turn running. The send returns at Codex's answer, so a close or quit with no Stop pending is never delayed, and the release plumbing through the adapter, router, close and quit is gone. * fix(codex): the Stop's wait reads a stopped thread from the thread-facts reader main kept --------- Co-authored-by: Claude --- .../use-mobile-structured-agent-session.ts | 6 +- ...laude-structured-conversation-stop.test.ts | 101 ++++ .../claude-structured-prompt-ownership.ts | 37 +- ...claude-structured-rate-limit-retry.test.ts | 50 ++ .../claude/claude-structured-real-cli.test.ts | 74 +++ .../claude-structured-session-acquisition.ts | 8 +- .../codex-requested-close-turn-timing.test.ts | 2 + ...codex-structured-conversation-stop.test.ts | 94 +++ ...odex-structured-dispatch-admission.test.ts | 18 +- .../codex-structured-dispatch-echo.test.ts | 41 ++ .../codex/codex-structured-dispatch-echo.ts | 27 +- .../codex-structured-dispatch-test-support.ts | 51 +- .../codex/codex-structured-fast-mode.test.ts | 14 +- .../codex-structured-prompt-ownership.test.ts | 2 +- .../codex-structured-prompt-ownership.ts | 62 +- .../codex/codex-structured-provider-events.ts | 1 + ...odex-structured-session-adapter-fixture.ts | 13 + .../codex-structured-session-adapter.test.ts | 8 +- .../codex-structured-session-cancel.test.ts | 16 +- .../codex-structured-session-close.test.ts | 37 +- .../codex/codex-structured-session-close.ts | 2 + ...structured-session-options-catalog.test.ts | 2 + .../codex-structured-session-options.test.ts | 2 + .../codex/codex-structured-session-state.ts | 21 +- .../codex-structured-turn-cancellation.ts | 13 +- ...dex-structured-turn-end-settlement.test.ts | 33 +- .../codex-structured-turn-open-wait.test.ts | 147 +++++ .../codex/codex-structured-turn-open-wait.ts | 54 ++ .../structured-agent-session-adapter.ts | 20 +- ...-agent-session-claude-echo-working.test.ts | 146 +++++ ...ed-agent-session-conversation-stop.test.ts | 277 +++++++++ ...structured-agent-session-host-mutations.ts | 26 +- ...ssion-main-agent-working-agreement.test.ts | 229 ++++++++ ...structured-agent-session-mutation-plans.ts | 11 +- .../structured-agent-session-turns.ts | 76 ++- .../methods/structured-agent-session.test.ts | 3 + ...-session-codex-turn-end-settlement.test.ts | 117 +++- .../native-chat/NativeChatComposer.test.tsx | 2 +- .../native-chat/NativeChatComposer.tsx | 8 +- ...tiveChatStructuredSession.test-harness.tsx | 12 +- .../NativeChatStructuredSession.test.tsx | 27 + .../NativeChatStructuredSession.tsx | 19 +- .../native-chat/NativeChatView.test.tsx | 4 +- .../native-chat/native-chat-draft-cache.ts | 34 ++ ...structured-send-composition-clear.test.tsx | 86 ++- ...session-withdrawn-message-restore.test.tsx | 475 +++++++++++++++ ...agent-session-withdrawn-message-restore.ts | 76 +++ .../use-native-chat-composer-attachments.ts | 46 +- .../native-chat/use-native-chat-draft.ts | 53 +- .../use-structured-agent-session-mutate.ts | 23 +- ...-agent-session-outbox-unconfirmed-probe.ts | 77 +++ ...d-agent-session-outbox-withdrawal.test.tsx | 168 ++++++ .../use-structured-agent-session-outbox.ts | 93 ++- ...use-structured-agent-session-stop.test.tsx | 541 ++++++++++++++++++ ...tructured-agent-session-transport-state.ts | 10 +- .../use-structured-agent-session.ts | 31 +- ...-agent-session-accepted-send-capability.ts | 56 +- ...red-agent-session-host-capability.test.tsx | 53 ++ ...tructured-agent-session-host-capability.ts | 63 ++ .../agent-session-failure-words.test.ts | 17 + src/shared/agent-session-failure-words.ts | 5 + src/shared/agent-session-failure.ts | 3 + src/shared/agent-session-wire.ts | 4 +- src/shared/protocol-version.ts | 6 + .../structured-agent-session-params.ts | 6 +- ...ctured-agent-session-dispatch-rejection.ts | 10 + ...ctured-agent-session-main-agent-working.ts | 14 + ...d-agent-session-operation-identity.test.ts | 39 ++ ...ctured-agent-session-operation-identity.ts | 17 + ...ed-agent-session-outbox-stop-withdrawal.ts | 52 ++ .../structured-agent-session-owed-work.ts | 11 +- ...tured-agent-session-unanswered-dispatch.ts | 7 +- ...ss-version-agent-session-wire.unit.test.ts | 35 ++ 73 files changed, 3758 insertions(+), 266 deletions(-) create mode 100644 src/main/claude/claude-structured-conversation-stop.test.ts create mode 100644 src/main/claude/claude-structured-rate-limit-retry.test.ts create mode 100644 src/main/codex/codex-structured-conversation-stop.test.ts create mode 100644 src/main/codex/codex-structured-turn-open-wait.test.ts create mode 100644 src/main/codex/codex-structured-turn-open-wait.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx create mode 100644 src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx create mode 100644 src/renderer/src/runtime/structured-agent-session-host-capability.ts create mode 100644 src/shared/structured-agent-session-main-agent-working.ts create mode 100644 src/shared/structured-agent-session-operation-identity.test.ts create mode 100644 src/shared/structured-agent-session-operation-identity.ts create mode 100644 src/shared/structured-agent-session-outbox-stop-withdrawal.ts diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index f04d78cc9a7..2fa5c2b2a96 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -7,7 +7,7 @@ import { import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection' +import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, isStructuredAgentSessionThinking @@ -292,9 +292,7 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - isWorking: - turnId !== null || - hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), + isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), turnId, turnIndicator, ...turnTiming, diff --git a/src/main/claude/claude-structured-conversation-stop.test.ts b/src/main/claude/claude-structured-conversation-stop.test.ts new file mode 100644 index 00000000000..840125853ba --- /dev/null +++ b/src/main/claude/claude-structured-conversation-stop.test.ts @@ -0,0 +1,101 @@ +// A Stop that names no turn, against the Claude adapter: the gap between writing a message and +// Claude echoing it back is exactly where no turn id exists yet, and the interrupt must still land. + +import { describe, expect, it } from 'vitest' +import { acquired, fakeClaude, USER_MESSAGE } from './claude-structured-session-test-support' + +function interrupts(claude: ReturnType): number { + return claude.connections[0]!.calls.filter((call) => call.subtype === 'interrupt').length +} + +async function written(claude: ReturnType) { + const adapter = await acquired(claude) + // No replay: the message is written and Claude has not opened its turn. + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + return adapter +} + +describe('Claude Stop that names no turn', () => { + it('interrupts a written message before its turn opens', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + }) + + it('still refuses the placeholder a client used to name for that gap', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-none', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(interrupts(claude)).toBe(0) + }) + + it('interrupts a turn Claude opened on its own echo', async () => { + const claude = fakeClaude({ replayUuid: 'turn-T' }) + const adapter = await written(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + }) + + it('does nothing for another fence, or with nothing in flight', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await acquired(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: false + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 6 })).resolves.toEqual({ + cancelled: false + }) + expect(interrupts(claude)).toBe(0) + }) + + it('ends a turn that opens while the interrupt is on its way once, and the next send lands', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await written(claude) + const connection = claude.connections[0]! + // The echo that opens the turn arrives after Orca wrote the interrupt, before Claude answers it. + claude.routes.interrupt = () => { + connection.handlers.onMessage?.({ ...connection.sent[0]!, uuid: 'turn-late' }) + return undefined + } + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + expect(interrupts(claude)).toBe(1) + + delete claude.routes.interrupt + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-2', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(connection.sent).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index e1c1aa371bf..01af8ec9fc0 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -90,6 +90,29 @@ function waitForClaudeDispatchAdmission( }) } +/** + * A Stop that names no turn: the conversation asked to stop whatever this child has in flight. + * Claude's interrupt is session-scoped, so there is no turn identity to check — only that this is + * still the child the host judged, and that it has a turn open or a written message whose turn has + * not opened yet (the gap before its echo, which no client can name). + */ +function cancelClaudeConversation( + session: ClaudeSession, + sessions: Map, + request: CancelInput, + timeoutMs: number | undefined, + onDispatchSettledLate: ClaudeLateDispatchSettlement | undefined +): Promise<{ cancelled: boolean }> { + const acquisitionGeneration = session.acquisitionGeneration + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + ((request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null) !== null || + session.dispatchWaiters.length > 0) + return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) +} + export async function cancelClaudeStructuredTurn(input: { request: CancelInput sessions: Map @@ -106,10 +129,16 @@ export async function cancelClaudeStructuredTurn(input: { if (!prompt && session.startup.state === 'pending') { return { cancelled: false } } + const requestedTurnId = request.turnId + if (requestedTurnId === undefined) { + return prompt + ? { cancelled: false } + : cancelClaudeConversation(session, sessions, request, timeoutMs, input.onDispatchSettledLate) + } if (prompt && session.fence !== request.fence) { return { cancelled: false } } - const claim = prompt ? session.prompts.claimBound(prompt.itemId, request.turnId) : null + const claim = prompt ? session.prompts.claimBound(prompt.itemId, requestedTurnId) : null if (prompt && !claim) { return { cancelled: false } } @@ -125,7 +154,7 @@ export async function cancelClaudeStructuredTurn(input: { // means nothing has published an identity this request can contradict. const ownsRequestedTurn = (): boolean => { const liveTurnId = request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null - return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === request.turnId + return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === requestedTurnId } // The host supplies the durable latest submission; direct adapter callers fall back to // the current in-memory waiter so an unknown dispatch remains fenced without a latch. @@ -143,7 +172,7 @@ export async function cancelClaudeStructuredTurn(input: { const dispatchAdmissionAllowsCancellation = (): boolean => dispatchAdmissionIsCurrent() || (Boolean(prompt) && supportsClaudeQueuedInterruptCancellation(session)) - const compactionOwnsTurn = (): boolean => compactions.ownsTurn(request.sessionId, request.turnId) + const compactionOwnsTurn = (): boolean => compactions.ownsTurn(request.sessionId, requestedTurnId) const currentDispatchHasRetiredWaiter = (): boolean => session.retiredDispatchWaiters.some( (waiter) => waiter.dispatchSequence === session.dispatchSequence @@ -165,7 +194,7 @@ export async function cancelClaudeStructuredTurn(input: { session.acquisitionGeneration === acquisitionGeneration && (claim && prompt ? ownsRequestedTurn() && - session.prompts.ownsBoundClaim(claim, prompt.itemId, request.turnId) && + session.prompts.ownsBoundClaim(claim, prompt.itemId, requestedTurnId) && (dispatchAdmissionAllowsCancellation() || dispatchAdmissionExpired) : compactionOwnsTurn() || (ownsRequestedTurn() && diff --git a/src/main/claude/claude-structured-rate-limit-retry.test.ts b/src/main/claude/claude-structured-rate-limit-retry.test.ts new file mode 100644 index 00000000000..d7b0d5277fc --- /dev/null +++ b/src/main/claude/claude-structured-rate-limit-retry.test.ts @@ -0,0 +1,50 @@ +// Claude retrying a rate-limited request: the frames below follow Claude Code 2.1.280 against an +// HTTP 429 stub, captured with `--replay-user-messages --include-partial-messages`. The CLI writes +// only `api_retry` frames, and echoes the message only once a request gets through or is +// interrupted, so no turn opens: the send itself is all that says the session is working. + +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function apiRetry(attempt: number, observedAt: number) { + return { + type: 'message' as const, + sessionId: 'orca-session', + observedAt, + message: { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 1_000 * 2 ** (attempt - 1), + error_status: 429, + error: 'rate_limit', + uuid: `retry-${attempt}`, + session_id: 'claude-session' + } + } +} + +describe('Claude retrying a rate-limited request', () => { + it('opens no turn while it retries', () => { + const appended: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => appended.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink }) + + for (let attempt = 1; attempt <= 6; attempt += 1) { + translator.handle(apiRetry(attempt, 1_000 * attempt)) + } + + expect(appended.filter(({ body }) => readAgentJournalTurn(body) !== null)).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 20670147ca0..73bec7986c8 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -279,6 +279,80 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () 90_000 ) + // The window a Stop naming no turn exists for: Orca has written the message, and Claude has not + // started its reply, so no turn id exists. Only the live binary can say the interrupt lands there. + it.skipIf(!realClaudeAuthenticated)( + 'stops a message interrupted before its reply starts, and takes the next one', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + const frames = (from: number) => + events.slice(from).flatMap((event) => (event.type === 'message' ? [event.message] : [])) + const replyStarted = (from: number) => + frames(from).some( + (frame) => + frame.type === 'stream_event' && + typeof frame.event === 'object' && + frame.event !== null && + 'type' in frame.event && + frame.event.type === 'message_start' + ) + const result = async (from: number) => { + const deadline = Date.now() + 60_000 + for (;;) { + const found = frames(from).find((frame) => frame.type === 'result') + if (found || Date.now() >= deadline) { + return found + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + const send = (clientMessageId: string, text: string) => + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + fence: 1 + }) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-stop' + }) + const first = events.length + await expect( + send('real-cli-stop-1', 'Count from 1 to 300, one number per line, and nothing else.') + ).resolves.toEqual({ state: 'admitted' }) + const startedBeforeStop = replyStarted(first) + + await expect( + adapter.cancelTurn({ sessionId: 'real-cli-handshake', fence: 1 }) + ).resolves.toEqual({ + cancelled: true + }) + const stopped = await result(first) + + expect(startedBeforeStop).toBe(false) + // Claude 2.1.280 echoes the message, then ends that turn as interrupted without replying. + expect(stopped).toMatchObject({ subtype: 'error_during_execution' }) + expect(replyStarted(first)).toBe(false) + + const second = events.length + await expect(send('real-cli-stop-2', 'Reply with the single word ok.')).resolves.toEqual({ + state: 'admitted' + }) + await expect(result(second)).resolves.toMatchObject({ subtype: 'success' }) + } finally { + await adapter.closeAll() + } + }, + 150_000 + ) + // Orca installs a SessionStart hook, so its frame proves most real starts before the turn's // system/init, the only frame that says this CLI can cancel what it queued. it.skipIf(!realClaudeAuthenticated)( diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b5a7a5d3061..cd8836e4b82 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -118,9 +118,12 @@ export async function acquireClaudeSession({ persistClaudeTurnResumePoint(sessionId, liveSession, deps) } } + // Settled after the turn this echo opens is emitted: a send read as answered before its turn + // lands reads as nothing running, and Stop and Working blink off in between. + const settlements: (() => void)[] = [] const turnOrigin = liveSession ? resolveClaudeReplayTurn(liveSession, message, (settlement) => - deps.onDispatchSettledLate?.({ sessionId, ...settlement }) + settlements.push(() => deps.onDispatchSettledLate?.({ sessionId, ...settlement })) ) : null const startsTurn = turnOrigin !== null @@ -138,6 +141,9 @@ export async function acquireClaudeSession({ ...observedAt }) ) + for (const settle of settlements) { + settle() + } } const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index fbf50badaef..a7baf6a14b5 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -72,6 +73,7 @@ describe('requested-close durable turn timing', () => { reportedOptions: {}, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator } const sessions = new Map([['session-1', session]]) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts new file mode 100644 index 00000000000..1842afada72 --- /dev/null +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -0,0 +1,94 @@ +// A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers +// `turn/start` before it opens the turn, and refuses an interrupt until then. + +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_TEST_THREAD_ID, + codexTurnLifecycleRig +} from './codex-structured-dispatch-test-support' + +type Rig = Awaited> + +async function openedTurn(rig: Rig): Promise { + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + await sending +} + +const stop = (rig: Rig, resolveLiveTurnId?: () => string | null) => + rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + ...(resolveLiveTurnId ? { resolveLiveTurnId } : {}) + }) + +describe('a Codex Stop that names no turn', () => { + it('interrupts the turn Codex opened when the journal shows none yet', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + }) + + it('interrupts no turn that already ended', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + rig.turns.end('completed') + + await expect(stop(rig)).resolves.toEqual({ cancelled: false }) + expect(rig.interrupts()).toEqual([]) + }) + + it("names the journal's turn over the one it saw open, and carries Codex's refusal", async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(stop(rig, () => 'turn-journal')).resolves.toEqual({ + cancelled: false, + refusal: { + detail: { + text: 'expected active turn id turn-journal but found turn-1', + audience: 'person' + } + } + }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-journal']) + }) + + it('interrupts nothing for another fence', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + + await expect(rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 6 })).resolves.toEqual({ + cancelled: false + }) + expect(rig.interrupts()).toEqual([]) + }) + + it('interrupts no earlier turn while a compaction the journal shows has not started', async () => { + const rig = await codexTurnLifecycleRig() + await openedTurn(rig) + rig.turns.end('completed') + const compaction = rig.adapter.compact({ + turnId: 'compact:operation-1', + sessionId: 'session-1', + fence: 7 + }) + await vi.waitFor(() => + expect(rig.codex.connections[0]!.calls.at(-1)?.method).toBe('thread/compact/start') + ) + + await expect(stop(rig, () => 'compact:operation-1')).resolves.toEqual({ cancelled: false }) + expect(rig.interrupts()).toEqual([]) + + rig.notify('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact' } }) + rig.notify('thread/compacted', { threadId: CODEX_TEST_THREAD_ID }) + rig.notify('turn/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-compact', status: 'completed' } + }) + await expect(compaction).resolves.toEqual({ outcome: 'compacted' }) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index b8ea81d592c..b3c8a3fefeb 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -7,6 +7,7 @@ import { acquiredCodexAdapter, echoUserMessage, fakeCodexAppServer, + openAfterTurnStarts, startTurn, CODEX_TEST_THREAD_ID, CODEX_TEST_USER_MESSAGE, @@ -215,8 +216,9 @@ describe('codex dispatch admission', () => { await expect(send(adapter, 'client-unknown', 1_700_000_000_100)).rejects.toThrow( 'request timed out after write' ) - await send(adapter, 'client-later', 1_700_000_000_400) - startTurn(connection, 'turn-later') + const later = send(adapter, 'client-later', 1_700_000_000_400) + await openAfterTurnStarts(connection, 2, () => startTurn(connection, 'turn-later')) + await later echoUserMessage(connection, { turnId: 'turn-later', itemId: 'item-later', @@ -279,9 +281,10 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - await send(adapter, 'client-opening', 1_700_000_000_600) - await send(adapter, 'client-queued', 1_700_000_000_200) - startTurn(connection, 'turn-1') + const opening = send(adapter, 'client-opening', 1_700_000_000_600) + const queued = send(adapter, 'client-queued', 1_700_000_000_200) + await openAfterTurnStarts(connection, 2, () => startTurn(connection, 'turn-1')) + await Promise.all([opening, queued]) await send(adapter, 'client-mid-turn', 1_700_000_000_100) echoUserMessage(connection, { @@ -325,8 +328,9 @@ describe('codex dispatch admission', () => { const adapter = await acquiredCodexAdapter({ codex, settlements, sink: recorded.sink }) const connection = codex.connections[0]! - await send(adapter, 'client-late-echo', 1_700_000_000_100) - startTurn(connection, 'turn-1') + const sending = send(adapter, 'client-late-echo', 1_700_000_000_100) + await openAfterTurnStarts(connection, 1, () => startTurn(connection, 'turn-1')) + await sending connection.handlers.onNotification?.('turn/completed', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-1' } diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index 94826c1df12..a6791e5e788 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -126,3 +126,44 @@ describe('readCodexDispatchEcho', () => { ).toBeNull() }) }) + +describe('the turn Codex answered a send into but has not opened', () => { + const NONE_OPEN = new Set() + + it('is the turn the latest armed send was answered into', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2') + }) + + it('is none once Codex opened that turn', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull() + }) + + it('is none once that turn ended, even with its send still armed for an echo', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + // A completed end leaves its unechoed send armed. + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([]) + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() + }) + + it('is none for a send not yet answered, or answered on another thread', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-2', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index c1a020b2c1f..0d3794b5136 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -38,6 +38,9 @@ export type CodexDispatchEchoes = { * read after it; a send that end settles is no longer armed. */ bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null + /** The turn the latest armed send was answered into that is neither in `openTurnIds` nor ended: + * one Codex has picked for the send but not opened. */ + answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed waits for recovery. @@ -52,7 +55,10 @@ export type CodexDispatchEchoes = { } export function createCodexDispatchEchoes(): CodexDispatchEchoes { - const armed = new Map() + const armed = new Map< + string, + { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } + >() const endedTurns = new Map() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) @@ -79,14 +85,23 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { if (!entry) { return null } - const turn = turnKey(threadId, turnId) - entry.turn = turn - const end = endedTurns.get(turn) ?? null + entry.turn = { threadId, turnId } + const end = endedTurns.get(turnKey(threadId, turnId)) ?? null if (end && settles(end)) { armed.delete(clientMessageId) } return end }, + answeredUnopenedTurn: (threadId, openTurnIds) => { + const answered = [...armed.values()].flatMap(({ turn }) => + turn?.threadId === threadId && + !openTurnIds.has(turn.turnId) && + !endedTurns.has(turnKey(threadId, turn.turnId)) + ? [turn.turnId] + : [] + ) + return answered.at(-1) ?? null + }, endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -101,7 +116,9 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { return [] } const settled = [...armed].flatMap(([clientMessageId, entry]) => - entry.turn === turn ? [clientMessageId] : [] + entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn + ? [clientMessageId] + : [] ) for (const clientMessageId of settled) { armed.delete(clientMessageId) diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 566922d7422..86f19349ee2 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,3 +1,4 @@ +import { expect, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -10,6 +11,7 @@ import type { } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' export const CODEX_TEST_THREAD_ID = 'thread-abc' @@ -83,6 +85,7 @@ export async function acquiredCodexAdapter(input: { settlements: LateSettlement[] sink?: StructuredAgentSessionEventSink captureTurnProcesses?: CodexStructuredSessionAdapterDeps['captureTurnProcesses'] + requestTimeoutMs?: number }): Promise { const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -95,8 +98,11 @@ export async function acquiredCodexAdapter(input: { openConnection: input.codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, captureTurnProcesses: input.captureTurnProcesses ?? (async () => null), + // A Stop must never reach for real processes on this machine under the fake pid. + terminateTurnProcesses: async () => true, now: () => 1_700_000_000_500, - onDispatchSettledLate: (settlement) => input.settlements.push(settlement) + onDispatchSettledLate: (settlement) => input.settlements.push(settlement), + ...(input.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: input.requestTimeoutMs }) }) const identity: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -130,9 +136,50 @@ export function echoUserMessage( }) } -export function startTurn(connection: FakeConnection, turnId: string): void { +export function startTurn(connection: Pick, turnId: string): void { connection.handlers.onNotification?.('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: turnId } }) } + +/** Runs `open` once `count` sends reached Codex, which opens a turn only after it answers. */ +export async function openAfterTurnStarts( + connection: Pick, + count: number, + open: () => void +): Promise { + await vi.waitFor(() => + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(count) + ) + open() +} + +/** An acquired adapter over a fake Codex that keeps Codex's own turn bookkeeping. */ +export async function codexTurnLifecycleRig(options: { requestTimeoutMs?: number } = {}) { + const codex = fakeCodexAppServer() + const notify = (method: string, params: unknown): void => + codex.connections.at(-1)?.handlers.onNotification?.(method, params) + const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => notify) + Object.assign(codex.routes, turns.routes) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements, ...options }) + const send = (clientMessageId: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) + const interrupts = () => + codex.connections[0]!.calls.filter((call) => call.method === 'turn/interrupt') + return { codex, turns, adapter, send, settlements, notify, interrupts } +} + +/** The promise's value, or `held` when it has not settled `withinMs` after every earlier task. */ +export function settledWithin(promise: Promise, withinMs = 50): Promise { + return Promise.race([ + promise, + new Promise<'held'>((resolve) => setTimeout(() => resolve('held'), withinMs)) + ]) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 3029bff280b..73f279bd2c0 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { USER_MESSAGE, adapterFor, + answerWithOpenedTurn, fakeCodex, identityFor, type Route @@ -19,9 +20,9 @@ describe('Codex structured Fast mode dispatch', () => { } ], nextCursor: null - }), - 'turn/start': () => ({ turn: { id: 'turn-fast' } }) + }) }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-fast') const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), @@ -43,7 +44,8 @@ describe('Codex structured Fast mode dispatch', () => { }) it('uses Standard on the first turn after acquisition with Fast explicitly off', async () => { - const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-standard' } }) }) + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-standard') const adapter = adapterFor(codex) await adapter.acquire({ identity: identityFor('session-1'), @@ -91,10 +93,8 @@ describe('Codex structured Fast mode dispatch', () => { ], nextCursor: null })) - const codex = fakeCodex({ - 'model/list': listModels, - 'turn/start': () => ({ turn: { id: 'turn-recovered' } }) - }) + const codex = fakeCodex({ 'model/list': listModels }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-recovered') const adapter = adapterFor(codex) await expect( adapter.acquire({ diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index ecdc4c9a4be..7dd048dd08a 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -261,7 +261,7 @@ describe('Codex live prompt ownership', () => { fence: 7, prompt: { itemId: 'journal-prompt' } }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'journal-prompt', diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index d58f08e731d..1742d5ced69 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -1,6 +1,7 @@ import { AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError, + type AgentSessionCancelOutcome, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' @@ -16,19 +17,72 @@ import type { CodexStructuredTurnCancellation } from './codex-structured-turn-ca type CancelInput = Parameters[0] type AnswerInput = Parameters[0] +/** How long a Stop waits for Codex to open the turn it answered a send into. Under the quit + * path's eviction budget, which a close or quit queued behind the Stop spends. */ +export const CODEX_STOP_TURN_OPEN_WAIT_MS = 5_000 + +/** + * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported + * started and not yet ended, which the journal can trail by a publish, or else the one Codex + * answered a send into, once it opens. + */ +async function cancelCodexConversation( + input: Parameters[0], + session: CodexSession +): Promise { + const { request, sessions, compactions, cancellation } = input + const liveTurnId = request.resolveLiveTurnId?.() ?? null + // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. + const turnId = + liveTurnId === null + ? ([...(session.activeTurnIds ?? [])].at(-1) ?? (await openedAnsweredTurn(session))) + : compactions.providerTurnId(request.sessionId, liveTurnId) + if (!turnId) { + return { cancelled: false } + } + const acquisitionGeneration = session.acquisitionGeneration + return cancellation.cancel( + session, + session.threadId, + turnId, + () => + sessions.get(request.sessionId) === session && + !session.ended && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration + ) +} + +/** The turn Codex answered a send into and has not opened yet, once it opens; null when it ends, + * the thread stops running or the child exits first, or the wait runs out. */ +async function openedAnsweredTurn(session: CodexSession): Promise { + const openTurnIds = session.activeTurnIds ?? new Set() + const answered = session.dispatchEchoes.answeredUnopenedTurn(session.threadId, openTurnIds) + if (!answered) { + return null + } + // Codex refuses an interrupt before it opens the turn. + await session.turnOpenWaits.wait(answered, CODEX_STOP_TURN_OPEN_WAIT_MS) + return session.activeTurnIds?.has(answered) ? answered : null +} + export async function cancelCodexStructuredTurn(input: { request: CancelInput sessions: Map compactions: StructuredSessionCompaction cancellation: CodexStructuredTurnCancellation -}): Promise<{ cancelled: boolean }> { +}): Promise { const { request, sessions, compactions, cancellation } = input const session = requireLiveCodexSession(sessions, request.sessionId) - const turnId = compactions.providerTurnId(request.sessionId, request.turnId) + const prompt = request.prompt + const requestedTurnId = request.turnId + if (requestedTurnId === undefined) { + return prompt ? { cancelled: false } : cancelCodexConversation(input, session) + } + const turnId = compactions.providerTurnId(request.sessionId, requestedTurnId) if (!turnId) { return { cancelled: false } } - const prompt = request.prompt if (!prompt) { return cancellation.cancel(session, session.threadId, turnId) } @@ -49,7 +103,7 @@ export async function cancelCodexStructuredTurn(input: { !session.ended && session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && - compactions.providerTurnId(request.sessionId, request.turnId) === turnId && + compactions.providerTurnId(request.sessionId, requestedTurnId) === turnId && session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) let interruptConfirmed = false try { diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 06563cad748..5dc608fbb59 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -24,6 +24,7 @@ export function translateCodexNotification(input: { }): CodexJournalTranslationAdmission { const { sessionId, session, method, params, observedAt, dispatchSequenceAtReceipt } = input codexRewind.observeCodexRewindActivity(session, method, params) + session.turnOpenWaits.observe(session.threadId, method, params) if (input.turnCancellation.handleNotification(sessionId, session, method, params, observedAt)) { return { accepted: true } } diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index 3f2d32ee2eb..27e6eadb6c5 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -89,6 +89,19 @@ export function fakeCodex(routes: Record = {}): { return { connections, openConnection, routes } } +/** A `turn/start` route for a Codex that opened the turn before its answer was read. */ +export function answerWithOpenedTurn( + codex: Pick, 'connections'>, + turnId: string +): Route { + return () => { + codex.connections + .at(-1) + ?.handlers.onNotification?.('turn/started', { threadId: THREAD_ID, turn: { id: turnId } }) + return { turn: { id: turnId } } + } +} + export function adapterFor( codex: ReturnType, launch: Partial = {}, diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2fa337b6001..70b2ecd7147 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -16,6 +16,7 @@ import { USER_MESSAGE, acquired, adapterFor, + answerWithOpenedTurn, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' @@ -371,7 +372,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { describe('CodexStructuredSessionAdapter.dispatch', () => { it('admits a send as soon as Codex owns it', async () => { - const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodex() + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) const outcome = await adapter.dispatch({ @@ -512,9 +514,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { } ], nextCursor: null - }), - 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) }) + codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1') const adapter = await acquired(codex) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 1ac807a5ccd..2d6c50adae4 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -142,14 +142,14 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) await expect( (await acquired(absent)).cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, refusal: {} }) }) it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => { @@ -228,14 +228,22 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) + ).resolves.toEqual({ cancelled: false, unconfirmed: true }) expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) }) it('accepts an immediate resend after verified interruption', async () => { let nextTurn = 0 const codex = fakeCodex() - codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } }) + codex.routes['turn/start'] = () => { + // Codex opens each turn it answers; a send's dispatch waits for that. + const turnId = `turn-${++nextTurn}` + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: turnId } + }) + return { turn: { id: turnId } } + } codex.routes['turn/interrupt'] = () => { completeTurn(codex) return {} diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index dffa7157dd6..9d80a0fa982 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -76,7 +77,7 @@ function claudeAdapterStub(): StructuredAgentSessionAdapter { } describe('Codex structured session close lifecycle', () => { - it('forwards a one-shot exit when lifecycle admission is rejected', () => { + function backpressuredSession(requestedClose: boolean) { const connection: CodexAppServerConnection = { pid: 4321, closed: true, @@ -87,7 +88,6 @@ describe('Codex structured session close lifecycle', () => { close: async () => true } const prompts = new CodexPromptRegistry() - const clearPrompts = vi.spyOn(prompts, 'clear') const translator = { handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), dispose: vi.fn() @@ -96,7 +96,7 @@ describe('Codex structured session close lifecycle', () => { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, + requestedClose, fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, @@ -106,9 +106,15 @@ describe('Codex structured session close lifecycle', () => { reportedOptions: {}, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator } - const sessions = new Map([['session-1', session]]) + return { connection, prompts, translator, session, sessions: new Map([['session-1', session]]) } + } + + it('forwards a one-shot exit when lifecycle admission is rejected', () => { + const { connection, prompts, translator, session, sessions } = backpressuredSession(false) + const clearPrompts = vi.spyOn(prompts, 'clear') const onEvent = vi.fn() expect( @@ -129,6 +135,29 @@ describe('Codex structured session close lifecycle', () => { expect(translator.handle).toHaveBeenCalledOnce() }) + it("ends a Stop's wait for its turn to open when a requested close cannot publish its end yet", async () => { + const { connection, prompts, session, sessions } = backpressuredSession(true) + let released = false + void session.turnOpenWaits.wait('turn-1', 60_000).then(() => { + released = true + }) + + expect( + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection, + error: new Error('codex session closed'), + closedByOrca: true, + prompts + }) + ).toBe(false) + await Promise.resolve() + // Left for the retry, but the child is gone: nothing waits on a turn it would open. + expect(session.ended).toBe(false) + expect(released).toBe(true) + }) + it('mints a distinct child generation even when acquisitions share one fence', async () => { const { adapter } = adapterFixture() const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' } diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 91d6c8ff04d..5bf973308b1 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -29,6 +29,8 @@ export function handleCodexSessionExit(input: { return false } session.exitObservedAt ??= Date.now() + // Before the admission check: the child is gone whether or not its end was admitted. + session.turnOpenWaits.releaseAll() const event: StructuredAgentSessionEndedEvent = { type: 'ended', sessionId: input.sessionId, diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index a9fd420eb0a..aac9e2946cf 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import { @@ -62,6 +63,7 @@ function storeSession( reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator: null, catalogAccess: { store, fingerprint: FINGERPRINT, accountHomePath: '/homes/a' } } diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 47ed8b50e3a..d99ecc0305e 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,4 +1,5 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' @@ -36,6 +37,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), translator: null } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 7f40407e3ff..02c56b419bd 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -10,6 +10,10 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { + createCodexTurnOpenWaits, + type CodexTurnOpenWaits +} from './codex-structured-turn-open-wait' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' @@ -118,7 +122,11 @@ export type CodexSession = { threadId: string historyPath: string | null historyMode?: 'legacy' | 'paginated' + /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what + * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ activeTurnIds?: Set + /** Stops waiting for the turn Codex answered a send into to open. */ + turnOpenWaits: CodexTurnOpenWaits dispatchPending?: boolean prompts: CodexAcquisitionWindow['prompts'] options: Map @@ -149,8 +157,17 @@ export function mintCodexAcquisitionGeneration(deps: CodexStructuredSessionAdapt export function codexSessionLifecycle( fence: number, acquisitionGeneration: string -): Pick { - return { ended: false, requestedClose: false, fence, acquisitionGeneration } +): Pick< + CodexSession, + 'ended' | 'requestedClose' | 'fence' | 'acquisitionGeneration' | 'turnOpenWaits' +> { + return { + ended: false, + requestedClose: false, + fence, + acquisitionGeneration, + turnOpenWaits: createCodexTurnOpenWaits() + } } export function requireLiveCodexSession( diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 4418da81057..524ab252e4f 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -3,6 +3,8 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexSession, CodexStructuredSessionAdapterDeps, @@ -91,7 +93,7 @@ export class CodexStructuredTurnCancellation { turnId: string, isCurrent: () => boolean = () => true, onConfirmed?: () => CodexJournalTranslationAdmission - ): Promise<{ cancelled: boolean }> { + ): Promise { const state = this.state(session) const key = turnKey(threadId, turnId) state.blockedCompletions.add(key) @@ -151,7 +153,14 @@ export class CodexStructuredTurnCancellation { // A failed cancellation must not permanently divert the provider's later // completion for this turn. Let the normal completion path settle it. this.releaseCompletion(session, key) - return { cancelled: false } + if (acknowledged) { + return { cancelled: false, unconfirmed: true } + } + if (!requestError) { + return { cancelled: false } + } + const detail = providerDiagnosticOf(requestError) + return { cancelled: false, refusal: detail ? { detail } : {} } } private capture(pid: number | undefined): Promise { diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 60f5a3c5f38..38cc648f79d 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -36,17 +36,23 @@ async function turnEndRig() { body: CODEX_TEST_USER_MESSAGE, fence: 7 }) + // Codex answers a cold send before it opens the turn. + const sendAndOpen = async (clientMessageId: string) => { + const sending = send(clientMessageId) + await vi.waitFor(() => expect(turns.turnId).not.toBeNull()) + turns.start() + return sending + } const settledIds = () => settlements.map(({ clientMessageId }) => clientMessageId) const categoryOf = (settlement: LateSettlement | undefined) => settlement && 'state' in settlement ? classifyDispatchRejection(settlement).category : null - return { codex, turns, adapter, send, settlements, settledIds, categoryOf, bodies } + return { codex, turns, adapter, send, sendAndOpen, settlements, settledIds, categoryOf, bodies } } describe('a Codex send its turn ended without echoing', () => { it('is withdrawn when the turn is interrupted, once', async () => { const rig = await turnEndRig() - await expect(rig.send('client-1')).resolves.toEqual({ state: 'admitted' }) - rig.turns.start() + await expect(rig.sendAndOpen('client-1')).resolves.toEqual({ state: 'admitted' }) rig.turns.end('interrupted') @@ -62,8 +68,7 @@ describe('a Codex send its turn ended without echoing', () => { it('is rejected in Codex words when its failed turn ends without echoing it', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.codex.connections[0]!.handlers.onNotification?.('error', { threadId: CODEX_TEST_THREAD_ID, @@ -87,8 +92,7 @@ describe('a Codex send its turn ended without echoing', () => { it('is accepted when Codex records it after the error that fails its turn', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.echo('client-1') await rig.send('client-2') @@ -113,8 +117,7 @@ describe('a Codex send its turn ended without echoing', () => { it('stays pending, still armed, when its turn completes without echoing it', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.end('completed') expect(rig.settlements).toEqual([]) @@ -131,8 +134,7 @@ describe('a Codex send its turn ended without echoing', () => { it('accepts a send its turn echoed, with its key, exactly once', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.echo('client-1') rig.turns.end('interrupted') @@ -153,8 +155,7 @@ describe('a Codex send its turn ended without echoing', () => { it('ignores an echo that arrives after the withdrawal', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.turns.end('interrupted') rig.turns.echo('client-1') @@ -165,8 +166,7 @@ describe('a Codex send its turn ended without echoing', () => { it('settles each of two sends steered into one interrupted turn once', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') await rig.send('client-2') expect(rig.turns.turnId).toBe('turn-1') @@ -275,8 +275,7 @@ describe('a Codex send its turn ended without echoing', () => { it('ignores a turn end on a child thread', async () => { const rig = await turnEndRig() - await rig.send('client-1') - rig.turns.start() + await rig.sendAndOpen('client-1') rig.codex.connections[0]!.handlers.onNotification?.('turn/completed', { threadId: 'thread-child', diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts new file mode 100644 index 00000000000..125be2bba0b --- /dev/null +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -0,0 +1,147 @@ +// A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that +// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps +// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt +// until then. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CODEX_TEST_THREAD_ID, + codexTurnLifecycleRig, + settledWithin +} from './codex-structured-dispatch-test-support' +import { CODEX_STOP_TURN_OPEN_WAIT_MS } from './codex-structured-prompt-ownership' + +type Rig = Awaited> + +const ADMITTED = { state: 'admitted' } +const REFUSED = { cancelled: false } + +const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + +/** A cold send Codex answered into `turn-1` and has not opened. */ +async function answeredColdSend(rig: Rig): Promise { + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + // The send is not held for the turn to open: its handover ends at the answer. + expect(await settledWithin(sending)).toEqual(ADMITTED) +} + +/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +async function waitingStop(rig: Rig) { + await answeredColdSend(rig) + const stopping = stop(rig) + expect(await settledWithin(stopping)).toBe('held') + expect(rig.interrupts()).toEqual([]) + // Wrapped: an async function returning the promise itself would wait for it. + return { stopping } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a Codex send answered before its turn opens', () => { + it('is admitted at the answer when no Stop is pending', async () => { + const rig = await codexTurnLifecycleRig() + + await answeredColdSend(rig) + + expect(rig.turns.turnId).toBe('turn-1') + }) +}) + +describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { + it('waits for the turn to open, then stops it', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.turns.start() + + expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + expect(rig.turns.turnId).toBeNull() + }) + + it('does not wait when Codex opened the turn before its answer was read', async () => { + const rig = await codexTurnLifecycleRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + release() + await sending + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + }) + + it('does not wait for a send Codex steered into the running turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.start() + expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + }) + + it('stops nothing when the turn ends without opening', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.turns.end('interrupted') + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) + + it.each(['idle', 'systemError'])( + 'stops nothing when Codex reports the thread %s', + async (type) => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + } + ) + + it('keeps waiting when a child thread stops running', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.notify('thread/status/changed', { threadId: 'thread-child', status: { type: 'idle' } }) + + expect(await settledWithin(stopping)).toBe('held') + rig.turns.start() + expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + }) + + it('stops nothing when the child exits', async () => { + const rig = await codexTurnLifecycleRig() + const { stopping } = await waitingStop(rig) + + rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) + + expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing once its bound runs out', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + + await vi.advanceTimersByTimeAsync(CODEX_STOP_TURN_OPEN_WAIT_MS - 1) + expect(outcome).toBe('held') + await vi.advanceTimersByTimeAsync(1) + + expect(outcome).toEqual(REFUSED) + expect(rig.interrupts()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts new file mode 100644 index 00000000000..63e37294005 --- /dev/null +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -0,0 +1,54 @@ +// A Stop's wait for the turn Codex answered a send into to open, or provably not to: it ended, +// the thread stopped running, or the child is gone. Held in memory only. + +import { + codexThreadStoppedRunning, + readCodexThreadId, + readCodexTurnId +} from './codex-structured-thread-facts' + +export type CodexTurnOpenWaits = { + /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ + wait: (turnId: string, withinMs: number) => Promise + /** Ends the waits a notification on the session's own thread answers. */ + observe: (threadId: string, method: string, params: unknown) => void + /** Ends every wait: the child that would open their turns is gone. */ + releaseAll: () => void +} + +export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { + const waits = new Map<() => void, string>() + const release = (turnId?: string): void => { + for (const [endWait, waitedTurnId] of waits) { + if (turnId === undefined || waitedTurnId === turnId) { + endWait() + } + } + } + return { + wait: (turnId, withinMs) => + new Promise((resolve) => { + const endWait = (): void => { + clearTimeout(bound) + waits.delete(endWait) + resolve() + } + const bound = setTimeout(endWait, withinMs) + waits.set(endWait, turnId) + }), + observe: (threadId, method, params) => { + if ((readCodexThreadId(params) ?? threadId) !== threadId) { + return + } + if (method === 'thread/status/changed' && codexThreadStoppedRunning(params)) { + release() + return + } + const turnId = readCodexTurnId(params) + if (turnId && (method === 'turn/started' || method === 'turn/completed')) { + release(turnId) + } + }, + releaseAll: () => release() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index bc3c27ca5b1..e4ef8eb85f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -33,7 +33,10 @@ import { isAgentSessionWireRefusalCode, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' -import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { + ProviderDiagnostic, + SubmissionRejectionFact +} from '../../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -216,6 +219,14 @@ export type StructuredAgentSessionSetOptionInput = { fence: number } +/** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. + * `unconfirmed`: the provider took the Stop, but Orca could not confirm the turn's work ended. */ +export type AgentSessionCancelOutcome = { + cancelled: boolean + refusal?: { detail?: ProviderDiagnostic } + unconfirmed?: true +} + export type StructuredAgentSessionAdapter = { /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean @@ -270,10 +281,11 @@ export type StructuredAgentSessionAdapter = { onLateResult?: (result: StructuredSessionCompactionResult) => Promise }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill - * a turn the client never asked to stop. */ + * a turn the client never asked to stop. With no `turnId` the conversation asked to stop + * everything it has in flight — a running turn, or a dispatch whose turn has not opened yet. */ cancelTurn(input: { sessionId: string - turnId: string + turnId?: string fence: number prompt?: { itemId: string } /** Latest journal submission for this fence, when the host has one. */ @@ -282,7 +294,7 @@ export type StructuredAgentSessionAdapter = { * could have named. A function, not a value, because the guard re-checks after the * delivery fence may have waited. Absent for direct callers with no journal. */ resolveLiveTurnId?: () => string | null - }): Promise<{ cancelled: boolean }> + }): Promise /** Changes the provider thread's goal. `rejected` is the provider refusing the * change; a throw leaves its effect unknown. Absent where no goal exists. */ changeThreadGoal?(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts new file mode 100644 index 00000000000..0fe20620160 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -0,0 +1,146 @@ +// Claude's echo of a sent message both answers the send and opens its turn. Whatever order the +// host publishes those in, a chat reading its frames one at a time must read working throughout: +// Stop and every session list's Working come from that one rule. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +const claude = { current: fakeClaude({ replayUuid: null }) } + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-echo-working-')) + resetHostTestOperationIds() + claude.current = fakeClaude({ replayUuid: null }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + // As the runtime wires it. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.current.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function settled(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)) +} + +it('reads working at every published frame from the send through the echo that opens its turn', async () => { + const submissions = new Map() + const turns = new Map() + const working: boolean[] = [] + const fence = store.getRecord(SESSION)!.lease.runtimeFence + host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + if (event.type !== 'batch') { + return + } + for (const submission of event.batch.submissions) { + submissions.set(submission.clientMessageId, submission) + } + for (const item of event.batch.items) { + if (item.body.kind === 'turn') { + turns.set(item.itemId, item.body.state) + } + } + const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null + working.push( + isStructuredAgentSessionMainAgentWorking(running, [...submissions.values()], fence) + ) + } + }) + const body = hostTestMessage('hi') + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + await settled() + const connection = claude.current.connections[0]! + // Claude echoes the written message back, which is what opens its turn. + connection.handlers.onMessage?.({ ...connection.sent.at(-1)!, uuid: 'echo-uuid' }) + await settled() + + expect([...submissions.values()].map((submission) => submission.dispatchState)).toEqual([ + 'accepted' + ]) + expect([...turns.values()]).toEqual(['running']) + expect(working).not.toContain(false) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts new file mode 100644 index 00000000000..ab0368a6b92 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -0,0 +1,277 @@ +// A Stop that names no turn stops what the conversation has in flight: it withdraws what is +// queued, and interrupts a handed-over message even before the provider has opened its turn — +// the gap no client can name a turn for. Against the real host, store and journal. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { CancelParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const ALREADY_FINISHED = 'The provider had already finished this turn.' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let cancelTurn: Mock +let awaitStarted: Mock> + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) + resetHostTestOperationIds() + // Admitted, not accepted: the message is written and its turn has not opened. + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + awaitStarted = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const result = host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + return { id: clientOperationId, result } +} + +function stop(turnId?: string, clientOperationId = hostTestOperationId()) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) +} + +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +describe('a Stop that names no turn', () => { + it('is a valid cancel, and only a plain Stop may omit the turn', () => { + const envelope = { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: '0'.repeat(64) + } + expect(CancelParams.safeParse({ envelope }).success).toBe(true) + expect( + CancelParams.safeParse({ envelope, prompt: { itemId: 'item-1', expectedRevision: 1 } }) + .success + ).toBe(false) + expect(CancelParams.safeParse({ envelope, scope: 'background-tasks' }).success).toBe(false) + }) + + it('interrupts a handed-over message before its turn opens, as a cancellation', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + expect( + (await host.journalSnapshot(SESSION)).items.some((item) => item.body.kind === 'turn') + ).toBe(false) + + const stopped = await stop() + + expect(stopped).toEqual({ + ok: true, + replayed: false, + fence: 1, + cursor: expect.anything(), + value: { cancelled: true } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') + expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + it('withdraws what is queued on a ready child and asks the provider for nothing more', async () => { + const started = Promise.withResolvers() + awaitStarted.mockImplementationOnce(() => started.promise) + const { id, result } = send('hello') + await result + await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + started.resolve(undefined) + + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + expect(cancelTurn).not.toHaveBeenCalled() + await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) + + it('withdraws a send still on its way in, which the host takes first', async () => { + const { id, result } = send('hello') + const stopped = stop() + + expect(await result).toMatchObject({ ok: true }) + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + await host.flushStreamedEvents(SESSION) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('says the agent did not stop, in its words, when it refused', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(cancelTurn).toHaveBeenCalledOnce() + expect(await statusRows()).toEqual(["Codex didn't stop: no active turn to interrupt."]) + }) + + it('says the Stop is unconfirmed, not that nothing ran, when the provider took it', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ cancelled: false, unconfirmed: true }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await statusRows()).toEqual(['Cancellation was not confirmed.']) + }) + + it('says the agent had no turn to stop when it had none', async () => { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await statusRows()).toEqual(['Codex had no turn running to stop.']) + }) + + it('stops nothing when it reuses the id of a Stop the host already ran', async () => { + const operationId = hostTestOperationId() + expect(await stop(undefined, operationId)).toMatchObject({ ok: true, replayed: false }) + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + + // Why the client never reuses a no-turn Stop's id: the same id is the same Stop. + expect(await stop(undefined, operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(cancelTurn).toHaveBeenCalledOnce() + }) + + it('is a quiet no-op with nothing in flight', async () => { + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) +}) + +describe('a Stop that names its turn, as an older client sends it', () => { + it('reaches the provider with that turn and keeps its not-cancelled note', async () => { + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop('turn-1')).toMatchObject({ + ok: true, + value: { turnId: 'turn-1', cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId: 'turn-1' })) + expect(await statusRows()).toEqual([ALREADY_FINISHED]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 35782b676c2..1586bd4a1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -21,6 +21,7 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { agentSessionFailureWords, type AgentJournalDispatchRejection @@ -38,6 +39,7 @@ import { openForWrite, openWithAgent, sendPreparation, + structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { @@ -138,7 +140,7 @@ export function cancelStructuredAgentSessionTurn( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope - turnId: string + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } @@ -176,15 +178,29 @@ export function cancelStructuredAgentSessionTurn( ctx.fence, agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) ) + const named = params.turnId !== undefined ? { turnId: params.turnId } : {} const child = context.sessions.get(ctx.sessionId)?.child if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. await context.stopAgent(ctx.sessionId) - return { ok: true, value: { turnId: params.turnId, cancelled: true } } + return { ok: true, value: { ...named, cancelled: true } } } - return child - ? plan.run(ctx) - : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = + params.turnId !== undefined || + isStructuredAgentSessionMainAgentWorking( + ctx.journal.activeTurnId(), + ctx.journal.submissions(), + ctx.fence + ) + const record = context.deps.store.getRecord(ctx.sessionId) + return child && inFlight + ? plan.run({ + ...ctx, + failureTextContext: structuredAgentSessionFailureWordsContext(record) + }) + : { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } }, openForWrite(context, params.envelope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts new file mode 100644 index 00000000000..35c02972829 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -0,0 +1,229 @@ +// The chat's own Stop and every session list read whether the main agent is working through one +// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the +// status feed. Both leave the host from one publication edge, so once each has landed they must say +// the same thing. Against the real host, store, journal, stream and status feed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-background-task-wire' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../shared/structured-agent-session-reducer' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let events: StructuredAgentSessionEventSink | undefined +let backgroundTasks: AgentSessionBackgroundTaskState | null +let chat: StructuredAgentSessionState +let listed: AgentSessionStatusSummary | undefined + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) + resetHostTestOperationIds() + events = undefined + backgroundTasks = null + chat = EMPTY_STRUCTURED_AGENT_SESSION + listed = undefined + // Written, and the provider has neither opened a turn for it nor answered it. + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async (input) => { + events = input.events + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${input.fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: input.fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted: vi.fn(async () => undefined), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + backgroundTaskState: () => backgroundTasks + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + host.subscribe({ + id: 'chat-1', + sessionId: SESSION, + emit: (event) => { + chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) + } + }) + host.subscribeStatus({ + id: 'list-1', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + listed = structuredClone(event.session) + } else if (event.type === 'snapshot') { + listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed + } + } + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + expect( + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + ).toMatchObject({ ok: true }) + return clientOperationId +} + +async function provider(ordinal: number, body: AgentJournalItemBody): Promise { + events!.appendItem({ ...PROVIDER_ROW, ordinal }, body) + events!.publish() + await host.flushStreamedEvents(SESSION) +} + +/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ +function chatReadsWorking(): boolean { + return isStructuredAgentSessionMainAgentWorking( + activeStructuredAgentSessionTurnId(chat.items), + chat.submissions, + chat.fence + ) +} + +/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ +function listReads(): { mainAgent: string; row: string } { + const summary = listed + if (!summary?.status) { + return { mainAgent: 'none', row: 'none' } + } + const status = structuredAgentSessionAgentStatus({ ...summary, status: summary.status }) + return { mainAgent: status.mainAgent.state, row: status.state } +} + +describe('the main agent read by the chat and by a session list', () => { + it('agrees while a rate-limited request retries with no turn open', async () => { + const id = await send('hello') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) + // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. + await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) + await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) + + expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) + expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() + expect(chatReadsWorking()).toBe(true) + expect(listReads().mainAgent).toBe('working') + }) + + it('agrees once the handed-over message is answered and only a subagent still runs', async () => { + dispatch.mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + await send('fan out') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') + ) + await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + expect(chatReadsWorking()).toBe(true) + expect(listReads().mainAgent).toBe('working') + + await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) + backgroundTasks = { + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] + } + host.publishBackgroundTaskState(SESSION) + await host.flushStreamedEvents(SESSION) + + expect(chat.backgroundTasks?.tasks).toHaveLength(1) + // The row reads Working for the subagent; the main agent, and so Stop, does not. + expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) + expect(chatReadsWorking()).toBe(false) + }) + + it('agrees when the child that was handed the message exits', async () => { + await send('hello') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) + expect(chatReadsWorking()).toBe(true) + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: 'generation-1', + reason: 'codex app-server crashed', + cause: 'unexpected-exit' + }) + await host.flushStreamedEvents(SESSION) + + expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) + expect(chatReadsWorking()).toBe(false) + expect(listReads().mainAgent).not.toBe('working') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index fd56944f3bd..2007f9bc174 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -98,7 +98,7 @@ export function sendPlan(params: { export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope - turnId: string + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } @@ -108,7 +108,7 @@ export function cancelPlan(params: { // Stop is a conversation write; a prompt or background-task cancel needs the live child. ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), fields: { - turnId: params.turnId, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}) @@ -116,14 +116,17 @@ export function cancelPlan(params: { run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, - turnId: params.turnId, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. - replay: () => ({ turnId: params.turnId, cancelled: false }) + replay: () => ({ + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), + cancelled: false + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49642b9a897..46c4638dc4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -27,6 +27,7 @@ import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/jour import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { + AgentSessionCancelOutcome, AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase @@ -53,6 +54,8 @@ export type AgentSessionTurnContext = { flushStreamedEvents: () => Promise /** What the host holds about the child this dispatch is for, read at the moment it is needed. */ providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a Stop's refusal row names. */ + failureTextContext?: AgentSessionFailureWordsContext now: () => number } @@ -239,7 +242,8 @@ export async function performCancel( ctx: AgentSessionTurnContext, input: { clientOperationId: string - turnId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } @@ -252,30 +256,51 @@ export async function performCancel( } } let cancelled = false - let note: AgentJournalStatusItem = { kind: 'status', text: 'Cancellation requested.' } + let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) - cancelled = input.scope - ? ( - await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) - }) - )?.cancelled === true - : ( - await ctx.adapter.cancelTurn({ - sessionId: ctx.sessionId, - turnId: input.turnId, - fence: ctx.fence, - // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => ctx.journal.activeTurnId(), - ...(dispatchStatus ? { dispatchStatus } : {}), - ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) - }) - ).cancelled - if (!cancelled) { + const outcome: AgentSessionCancelOutcome = input.scope + ? { + cancelled: + ( + await ctx.adapter.stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) + }) + )?.cancelled === true + } + : await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + fence: ctx.fence, + // The journal is what the client read to name a turn, so it is what judges the request. + resolveLiveTurnId: () => ctx.journal.activeTurnId(), + ...(dispatchStatus ? { dispatchStatus } : {}), + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) + }) + cancelled = outcome.cancelled + if (!cancelled && input.turnId !== undefined) { note = { kind: 'status', text: 'The provider had already finished this turn.' } + } else if (!cancelled && input.prompt) { + note = null + } else if (!cancelled && outcome.unconfirmed) { + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + } else if (!cancelled) { + // Sent only while the chat reads working, so a Stop that ended nothing must say why. + const detail = outcome.refusal?.detail + note = { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('stopRefused', detail ? { detail } : {}), + { ...ctx.failureTextContext, surface: 'row' } + ) + } } } catch (error) { if (input.prompt) { @@ -290,10 +315,11 @@ export async function performCancel( if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } - if (input.scope) { - return { ok: true, value: { turnId: input.turnId, cancelled } } + const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } + if (input.scope || note === null) { + return { ok: true, value } } // Keyed by the operation id so a replayed cancel upserts one item, not two. await appendStatus(ctx, input.clientOperationId, note) - return { ok: true, value: { turnId: input.turnId, cancelled } } + return { ok: true, value } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index a8378489c02..f1e27aec3de 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -153,6 +154,8 @@ describe('capability gating', () => { expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) // A client tells a host that accepts first, and admits a writer-free Stop before a turn, by it. expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + // And a cancel naming no turn, which a host that only accepts first still refuses as invalid. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY) // Separate from the structured capability on purpose: a host can serve the rest of the diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index fc06a2a782b..c86f7c85242 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,5 +1,6 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn -// withdraws it, and nothing reads as working after. Driven through the shipped host, +// withdraws it, and nothing reads as working after. A Stop sent before Codex opens that +// turn waits for it to open, since Codex refuses an interrupt until then. Driven through the shipped host, // journal and Codex adapter; only the Codex child is fake, keeping Codex 0.157's // turn bookkeeping. @@ -13,6 +14,8 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import { CODEX_STOP_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-prompt-ownership' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' @@ -24,6 +27,7 @@ import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { CHILD_EVICTION_TIMEOUT_MS } from '../native-chat/agent-session-wire/structured-agent-session-host-teardown' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -50,6 +54,8 @@ let host: StructuredAgentSessionHost let fence: number let handlers: CodexAppServerConnectionHandlers | undefined let answers: number +let interrupts: number +let childCloses: number let turns: ReturnType let operations = 0 @@ -78,10 +84,10 @@ async function send(text: string): Promise { return sent.value.clientMessageId } -async function stop(turnId: string): Promise { +async function stop(turnId?: string): Promise { const stopped = await host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId }), - turnId + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) }) if (!stopped.ok) { throw new Error(JSON.stringify(stopped.refusal)) @@ -111,6 +117,8 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) answers = 0 + interrupts = 0 + childCloses = 0 turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -135,6 +143,7 @@ beforeEach(async () => { return turns.routes['turn/start']() } if (method === 'turn/interrupt') { + interrupts += 1 return turns.routes['turn/interrupt'](params) } return {} @@ -142,7 +151,10 @@ beforeEach(async () => { notify: () => {}, respond: () => {}, respondWithError: () => {}, - close: async () => true + close: async () => { + childCloses += 1 + return true + } } return connection } @@ -215,3 +227,98 @@ describe('a Codex send its turn ended without taking it', () => { expect(after.owesWork).toBe(false) }) }) + +describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { + it('waits for the turn to open, then stops it and withdraws the send', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + + const stopping = stop() + // Without the wait, it would reach Codex now, which would refuse it, and be done. + expect(await settledWithin(stopping, 1_000)).toBe('held') + expect(interrupts).toBe(0) + turns.start() + await stopping + + expect(interrupts).toBe(1) + expect(turns.turnId).toBeNull() + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + expect((await settled()).owesWork).toBe(false) + }) +}) + +describe('a Stop in that window that the turn never opens for', () => { + async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) + } + + async function waitingStop(): Promise<{ stopping: Promise }> { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + expect(await settledWithin(stopping, 200)).toBe('held') + return { stopping } + } + + it('says Codex had no turn running when that turn ends first', async () => { + const { stopping } = await waitingStop() + + turns.end('interrupted') + await stopping + + expect(interrupts).toBe(0) + expect(await statusRows()).toContain('Codex had no turn running to stop.') + }) + + it('lets a chat closed behind it close within its bound and one eviction', async () => { + const { stopping } = await waitingStop() + + const closing = host.close(SESSION) + + expect( + await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) + ).not.toBe('held') + expect(await settledWithin(stopping, 0)).not.toBe('held') + expect(childCloses).toBe(1) + expect(interrupts).toBe(0) + expect(await statusRows()).toContain('Codex had no turn running to stop.') + }) + + it('lets the app quit behind it within the eviction budget, and still close the child', async () => { + await waitingStop() + + expect( + await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) + ).not.toBe('held') + expect(childCloses).toBe(1) + }) +}) + +describe('a cold send with no Stop behind it', () => { + /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ + const PROMPTLY_MS = 1_000 + + async function answeredColdSend(): Promise { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + expect(turns.turnId).toBe('turn-1') + } + + it('never delays closing the chat', async () => { + await answeredColdSend() + + expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held') + expect(childCloses).toBe(1) + }) + + it('never delays quitting the app', async () => { + await answeredColdSend() + + expect(await settledWithin(stopStructuredAgentSessionRuntime(), PROMPTLY_MS)).not.toBe('held') + expect(childCloses).toBe(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 8eb4c9b7469..bf0cdd4bc6c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -100,7 +100,7 @@ vi.mock('@/lib/native-chat-telemetry', () => ({ vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { mocks.draftScopeKeys.push(scopeKey) - return { draft: mocks.draft, setDraft: mocks.setDraft } + return { draft: mocks.draft, setDraft: mocks.setDraft, flushDraftAppends: () => {} } } })) vi.mock('./native-chat-draft-cache', () => ({ diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 60735d705e2..922ddfd20cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -74,7 +74,11 @@ const NativeChatComposerPane = forwardRef(null) const [dictationPressed, setDictationPressed] = useState(false) - const imeEnterGesture = useImeEnterGestureOwnership() const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, @@ -386,6 +389,7 @@ const NativeChatComposerPane = forwardRef(), messages: null as null | unknown[], messageListProps: initialMessageListProps, - composerProps: null as null | { + composerProps: nullable<{ launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean - }, + onStop?: () => void + }>(), approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], journalItems: new Array(), respond: vi.fn<(...args: never[]) => unknown>(), cancel: vi.fn<(...args: never[]) => unknown>(), + stop: vi.fn<() => unknown>(), handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), pasteFromClipboard: vi.fn<(...args: never[]) => unknown>(), submissions: [] as unknown[], @@ -99,6 +101,8 @@ export function createStructuredSessionMocks() { showBackgroundTasks: false, isWorking: false, turnId: null as string | null, + // Unset: Stop follows the turn, as against an older host. + canStop: nullable(), supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -178,6 +182,8 @@ export function createStructuredSessionMocks() { supportsStopAll: mocks.supportsBackgroundTaskStopAll }, turnId: mocks.turnId, + canStop: mocks.canStop ?? mocks.turnId !== null, + stop: mocks.stop, threadGoal: mocks.threadGoal, cancel: mocks.cancel, stopBackgroundTask: (taskId?: string) => @@ -288,6 +294,7 @@ export function createStructuredSessionMocks() { mocks.journalItems = [] mocks.respond.mockReset() mocks.cancel.mockReset() + mocks.stop.mockReset() mocks.handlePasteEvent.mockReset() mocks.pasteFromClipboard.mockReset() mocks.submissions = [] @@ -295,6 +302,7 @@ export function createStructuredSessionMocks() { mocks.showBackgroundTasks = false mocks.isWorking = false mocks.turnId = null + mocks.canStop = null mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 9a40f63530d..9279ca63f20 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -27,6 +27,7 @@ vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' describe('NativeChatStructuredSession', () => { afterEach(() => { @@ -34,6 +35,22 @@ describe('NativeChatStructuredSession', () => { resetStructuredSessionMocks() }) + it('gives what a Stop withdrew back to the composer this pane shows', () => { + render( + + ) + const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1') + expect(mocks.composerProps).toMatchObject({ paneKey }) + expect(mocks.controllerProps).toMatchObject({ composerScopeKey: paneKey }) + }) + it('routes the launch draft and app-menu paste to the structured composer', () => { const draft = { tabId: 'structured-draft-tab', @@ -367,6 +384,16 @@ describe('NativeChatStructuredSession', () => { expect(screen.getByRole('list', { name: 'Agents' })).toBeTruthy() }) + it('offers Stop before any turn opens when the controller can stop, and stops through it', () => { + mocks.canStop = true + render(claudeSessionView('structured-tab-pre-turn', 'session-pre-turn')) + + expect(mocks.composerProps?.isWorking).toBe(true) + act(() => mocks.composerProps?.onStop?.()) + expect(mocks.stop).toHaveBeenCalledOnce() + expect(mocks.cancel).not.toHaveBeenCalled() + }) + it('keeps the strip mounted through a running turn, with the turn owning the voice', () => { // The strip stands for work that OUTLIVES a turn, so `show` is true while // `isMonitoring` is false: mounted, but not speaking as the live indicator. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index a6bb8ad8d28..1f416bcc070 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -43,8 +43,13 @@ export function NativeChatStructuredSession( const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) + const paneKey = useMemo( + () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), + [props.sessionId, props.tabId] + ) const controller = useStructuredAgentSession({ ...props, + composerScopeKey: paneKey, providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) @@ -62,10 +67,6 @@ export function NativeChatStructuredSession( id: string sequence: number } | null>(null) - const paneKey = useMemo( - () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), - [props.sessionId, props.tabId] - ) const rootRef = useRef(null) const composerRef = useRef(null) const paneCommands = useStructuredNativeChatPaneCommands({ @@ -376,14 +377,8 @@ export function NativeChatStructuredSession( targetPtyId={null} agent={props.agent} canSend={!prompt} - // Stop, not status: only a provider-minted turn can be interrupted, so the button - // must not flip while a dispatch is still unanswered. - isWorking={controller.turnId !== null} - onStop={() => { - if (controller.turnId) { - void controller.cancel(controller.turnId) - } - }} + isWorking={controller.canStop} + onStop={() => void controller.stop()} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/NativeChatView.test.tsx b/src/renderer/src/components/native-chat/NativeChatView.test.tsx index cc25dabc125..e7e09d2070a 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.test.tsx @@ -35,8 +35,10 @@ function renderResolution( ) } +const notComposing = (): boolean => false + function DraftProbe({ paneKey, sessionId }: { paneKey: string; sessionId: string | null }) { - const { draft, setDraft } = useNativeChatDraft(paneKey) + const { draft, setDraft } = useNativeChatDraft(paneKey, notComposing) return (